diff --git a/src/check.ts b/src/check.ts index 4775f85..3a976a2 100644 --- a/src/check.ts +++ b/src/check.ts @@ -1,266 +1,108 @@ -import { AtpAgent } from "@atproto/api"; -import dotenv from "dotenv"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import { execSync } from "child_process"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -dotenv.config({ path: "./src/.env" }); +import { loadConfig, runConfigChecks } from "./lib/config"; +import { createAgent } from "./lib/atproto"; +import { checkSshConnection } from "./lib/checks/ssh"; +import { checkGitHubApi } from "./lib/checks/github"; +import { checkDependencies } from "./lib/checks/deps"; async function runHealthCheck() { + loadConfig(); -console.log("šŸ” Running Tangled Sync Health Check...\n"); - -const checks: { category: string; name: string; status: boolean; message: string }[] = []; -let errors = 0; -let warnings = 0; - -// ===== CONFIGURATION CHECKS ===== -console.log("šŸ“‹ Configuration Checks\n"); - -const envPath = path.join(__dirname, ".env"); -const envExists = fs.existsSync(envPath); -checks.push({ - category: "config", - name: ".env file", - status: envExists, - message: envExists ? "Found at src/.env" : "Missing! Copy src/.env.example to src/.env" -}); -if (!envExists) errors++; - -const requiredVars = [ - { name: "BASE_DIR", description: "Base directory for repos" }, - { name: "GITHUB_USER", description: "GitHub username" }, - { name: "ATPROTO_DID", description: "AT Proto DID" }, - { name: "BLUESKY_PDS", description: "Bluesky PDS URL" }, - { name: "BLUESKY_USERNAME", description: "Bluesky username" }, - { name: "BLUESKY_PASSWORD", description: "Bluesky app password" }, -]; - -requiredVars.forEach(({ name, description }) => { - const value = process.env[name]; - const exists = !!value && value.trim().length > 0; - checks.push({ - category: "config", - name: name, - status: exists, - message: exists ? `Set` : `Missing (${description})` - }); - if (!exists) errors++; -}); - -// Check BASE_DIR -const baseDir = process.env.BASE_DIR; -if (baseDir) { - const baseDirExists = fs.existsSync(baseDir); - checks.push({ - category: "config", - name: "BASE_DIR path", - status: baseDirExists, - message: baseDirExists ? `Exists: ${baseDir}` : `Missing (will be created): ${baseDir}` - }); - if (!baseDirExists) warnings++; -} - -// Check DID format -const did = process.env.ATPROTO_DID; -if (did) { - const validDid = did.startsWith("did:plc:") || did.startsWith("did:web:"); - checks.push({ - category: "config", - name: "DID format", - status: validDid, - message: validDid ? "Valid" : "Invalid! Should start with 'did:plc:' or 'did:web:'" - }); - if (!validDid) errors++; -} - -// Check PDS URL -const pds = process.env.BLUESKY_PDS; -if (pds) { - const validPds = pds.startsWith("http://") || pds.startsWith("https://"); - checks.push({ - category: "config", - name: "PDS URL", - status: validPds, - message: validPds ? pds : "Invalid! Should start with 'https://'" - }); - if (!validPds) errors++; -} - -// Print config results -checks.filter(c => c.category === "config").forEach((check) => { - const icon = check.status ? "āœ…" : "āŒ"; - console.log(`${icon} ${check.name}: ${check.message}`); -}); + console.log("šŸ” Running Tangled Sync Health Check...\n"); -// ===== AT PROTO CONNECTION CHECK ===== -console.log("\nšŸ” AT Proto Connection Check\n"); + let errors = 0; + let warnings = 0; -const canTestConnection = process.env.BLUESKY_USERNAME && - process.env.BLUESKY_PASSWORD && - process.env.BLUESKY_PDS && - process.env.ATPROTO_DID; - -if (canTestConnection) { - try { - const agent = new AtpAgent({ service: process.env.BLUESKY_PDS! }); - - const loginResponse = await agent.login({ - identifier: process.env.BLUESKY_USERNAME!, - password: process.env.BLUESKY_PASSWORD! - }); - - console.log(`āœ… Login successful`); - console.log(` DID: ${loginResponse.data.did}`); - console.log(` Handle: ${loginResponse.data.handle}`); - - if (loginResponse.data.did !== process.env.ATPROTO_DID) { - console.log(`āš ļø DID mismatch!`); - console.log(` Expected: ${process.env.ATPROTO_DID}`); - console.log(` Got: ${loginResponse.data.did}`); - warnings++; - } - - // Test fetching records - const records = await agent.api.com.atproto.repo.listRecords({ - repo: loginResponse.data.did, - collection: "sh.tangled.repo", - limit: 5, - }); - - console.log(`āœ… Can access AT Proto records`); - console.log(` Found ${records.data.records.length} sample records`); - - } catch (error: any) { - console.log(`āŒ AT Proto connection failed`); - console.log(` Error: ${error.message}`); - errors++; + // ── Configuration checks ────────────────────────────────────── + console.log("šŸ“‹ Configuration Checks\n"); + const configChecks = runConfigChecks(); + for (const check of configChecks) { + const icon = check.status ? "āœ…" : "āŒ"; + console.log(`${icon} ${check.name}: ${check.message}`); + if (!check.status) errors++; } -} else { - console.log("ā­ļø Skipped (missing credentials)"); -} -// ===== SSH CONNECTION CHECK ===== -console.log("\nšŸ”‘ SSH Connection Check\n"); - -try { - const sshTest = execSync("ssh -T git@tangled.sh 2>&1", { - encoding: "utf-8", - timeout: 5000 - }); - - if (sshTest.includes("successfully authenticated") || sshTest.includes("Hi")) { - console.log("āœ… SSH connection to Tangled works"); - console.log(` ${sshTest.trim().split('\n')[0]}`); + // ── AT Proto connection check ───────────────────────────────── + console.log("\nšŸ” AT Proto Connection Check\n"); + const canConnect = + process.env.BLUESKY_USERNAME && + process.env.BLUESKY_PASSWORD && + process.env.BLUESKY_PDS && + process.env.ATPROTO_DID; + + if (canConnect) { + try { + const agent = await createAgent(); + const did = process.env.ATPROTO_DID!; + + if (agent.session?.did !== did) { + console.log( + `āš ļø DID mismatch! Expected: ${did}, Got: ${agent.session?.did}`, + ); + warnings++; + } + + const records = await agent.api.com.atproto.repo.listRecords({ + repo: did, + collection: "sh.tangled.repo", + limit: 5, + }); + console.log( + `āœ… Can access AT Proto records (${records.data.records.length} sample records)`, + ); + } catch (error: any) { + console.log(`āŒ AT Proto connection failed: ${error.message}`); + errors++; + } } else { - console.log("āš ļø SSH connection uncertain"); - console.log(` Response: ${sshTest.trim()}`); - warnings++; + console.log("ā­ļø Skipped (missing credentials)"); } -} catch (error: any) { - const output = error.stdout?.toString() || error.message; - - if (output.includes("successfully authenticated") || output.includes("Hi")) { - console.log("āœ… SSH connection to Tangled works"); + + // ── SSH connection check ────────────────────────────────────── + console.log("\nšŸ”‘ SSH Connection Check\n"); + const sshResult = await checkSshConnection(); + const sshIcon = sshResult.status ? "āœ…" : "āŒ"; + console.log(`${sshIcon} ${sshResult.message}`); + if (!sshResult.status) errors++; + + // ── GitHub API check ────────────────────────────────────────── + console.log("\nšŸ™ GitHub API Check\n"); + const ghResult = await checkGitHubApi(); + if (ghResult) { + const ghIcon = ghResult.status ? "āœ…" : "āŒ"; + console.log(`${ghIcon} ${ghResult.message}`); + if (!ghResult.status) errors++; } else { - console.log("āŒ SSH connection to Tangled failed"); - console.log(" Make sure your SSH key is added at https://tangled.org/settings/keys"); - errors++; + console.log("ā­ļø Skipped (no GITHUB_USER set)"); } -} -// ===== GITHUB API CHECK ===== -console.log("\nšŸ™ GitHub API Check\n"); - -if (process.env.GITHUB_USER) { - try { - const response = execSync(`curl -s "https://api.github.com/users/${process.env.GITHUB_USER}"`, { - encoding: "utf-8", - timeout: 5000 - }); - - const data = JSON.parse(response); - - if (data.login) { - console.log(`āœ… GitHub user found: ${data.login}`); - console.log(` Public repos: ${data.public_repos || 0}`); - } else { - console.log(`āŒ GitHub user not found: ${process.env.GITHUB_USER}`); - errors++; - } - } catch (error: any) { - console.log(`āš ļø Could not check GitHub API`); - console.log(` ${error.message}`); - warnings++; + // ── Dependencies check ──────────────────────────────────────── + console.log("\nšŸ“¦ Dependencies Check\n"); + const depResults = await checkDependencies(); + for (const dep of depResults) { + const depIcon = dep.status ? "āœ…" : "āŒ"; + console.log(`${depIcon} ${dep.message}`); + if (!dep.status) errors++; } -} else { - console.log("ā­ļø Skipped (no GITHUB_USER set)"); -} - -// ===== DEPENDENCIES CHECK ===== -console.log("\nšŸ“¦ Dependencies Check\n"); - -let hasAtproto = false; -let hasDotenv = false; - -try { - await import("@atproto/api"); - hasAtproto = true; - console.log("āœ… @atproto/api installed"); -} catch { - console.log("āŒ @atproto/api not installed (run: npm install)"); - errors++; -} -try { - await import("dotenv"); - hasDotenv = true; - console.log("āœ… dotenv installed"); -} catch { - console.log("āŒ dotenv not installed (run: npm install)"); - errors++; -} - -// ===== SUMMARY ===== -console.log("\n" + "=".repeat(50)); - -if (errors === 0 && warnings === 0) { - console.log("āœ… All checks passed! Ready to sync."); - console.log("\nNext steps:"); - console.log(" npm run sync # Sync new repos only"); - console.log(" npm run sync:force # Force sync all repos"); -} else { - if (errors > 0) { - console.log(`āŒ ${errors} error(s) found - please fix before syncing`); - } - if (warnings > 0) { - console.log(`āš ļø ${warnings} warning(s) - review before syncing`); - } - - console.log("\nSee SETUP.md for detailed troubleshooting"); - - if (errors > 0) { - process.exit(1); + // ── Summary ─────────────────────────────────────────────────── + console.log("\n" + "=".repeat(50)); + if (errors === 0 && warnings === 0) { + console.log("āœ… All checks passed! Ready to sync."); + console.log("\nNext steps:"); + console.log(" npm run sync # Sync new repos only"); + console.log(" npm run sync:force # Force sync all repos"); + } else { + if (errors > 0) + console.log(`āŒ ${errors} error(s) found - please fix before syncing`); + if (warnings > 0) + console.log(`āš ļø ${warnings} warning(s) - review before syncing`); + console.log("\nSee SETUP.md for detailed troubleshooting"); + if (errors > 0) process.exit(1); } + console.log("=".repeat(50)); } -console.log("=".repeat(50)); - -// Additional recommendations -if (process.env.BLUESKY_PASSWORD && !process.env.BLUESKY_PASSWORD.includes("-")) { - console.log("\nšŸ’” Tip: Your password might be a regular password."); - console.log(" Consider using an App Password from Bluesky settings for better security."); -} - -} - -// Run the health check runHealthCheck().catch((error) => { - console.error("\nāŒ Health check failed with error:"); - console.error(error); + console.error("\nāŒ Health check failed with error:", error); process.exit(1); }); diff --git a/src/index.ts b/src/index.ts index aa7f354..b417b59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,49 +1,42 @@ -import { AtpAgent } from "@atproto/api"; -import dotenv from "dotenv"; +import { loadConfig } from "./lib/config"; +import { createAgent } from "./lib/atproto"; +import { ensureDir, run, generateTid, TangledRepoRecord } from "./lib/utils"; import fs from "fs"; import path from "path"; -import { execSync } from "child_process"; -dotenv.config({ path: "./src/.env" }); +loadConfig(); const FORCE_SYNC = process.argv.includes("--force"); - const BASE_DIR = process.env.BASE_DIR!; const GITHUB_USER = process.env.GITHUB_USER!; const ATPROTO_DID = process.env.ATPROTO_DID!; -const BLUESKY_PDS = process.env.BLUESKY_PDS!; const TANGLED_BASE_URL = `git@tangled.sh:${ATPROTO_DID}`; -const agent = new AtpAgent({ service: BLUESKY_PDS }); - -async function login() { - const username = process.env.BLUESKY_USERNAME; - const password = process.env.BLUESKY_PASSWORD; - if (!username || !password) { - throw new Error("Missing Bluesky credentials. Please set BLUESKY_USERNAME and BLUESKY_PASSWORD in src/.env"); - } - - try { - const response = await agent.login({ identifier: username, password }); - console.log(`[LOGIN] Successfully logged in to AT Proto as ${response.data.did}`); - console.log(`[LOGIN] Session handle: ${response.data.handle}`); - return response; - } catch (error: any) { - console.error("[ERROR] Failed to login to AT Proto:", error.message); - throw error; - } -} +// ── GitHub repo fetching ─────────────────────────────────────── -async function getGitHubRepos(): Promise<{ clone_url: string; name: string; description?: string }[]> { - const curl = `curl -s "https://api.github.com/users/${GITHUB_USER}/repos?per_page=200"`; - const output = run(curl); +async function getGitHubRepos(): Promise< + { clone_url: string; name: string; description?: string }[] +> { + const output = run( + `curl -s "https://api.github.com/users/${GITHUB_USER}/repos?per_page=200"`, + ); const json = JSON.parse(output); return json .filter((r: any) => r.name !== GITHUB_USER) - .map((r: any) => ({ clone_url: r.clone_url, name: r.name, description: r.description })); + .map((r: any) => ({ + clone_url: r.clone_url, + name: r.name, + description: r.description, + })); } -async function ensureTangledRemoteAndPush(repoDir: string, repoName: string, cloneUrl: string) { +// ── Tangled remote & push ────────────────────────────────────── + +async function ensureTangledRemoteAndPush( + repoDir: string, + repoName: string, + cloneUrl: string, +) { const tangledUrl = `${TANGLED_BASE_URL}/${repoName}`; try { const remotes = run("git remote", repoDir).split("\n"); @@ -61,81 +54,31 @@ async function ensureTangledRemoteAndPush(repoDir: string, repoName: string, clo run(`git push tangled main`, repoDir); console.log(`[PUSH] Pushed main to Tangled`); } catch (error) { - console.warn(`[WARN] Could not push ${repoName} to Tangled. Check SSH or repo existence.`); - } -} - -const BASE32_SORTABLE = "234567abcdefghijklmnopqrstuvwxyz"; - -function run(cmd: string, cwd?: string): string { - const options: import("child_process").ExecSyncOptions = { - cwd, - stdio: "pipe", - shell: process.env.SHELL || "/bin/bash", - encoding: "utf-8", - }; - return execSync(cmd, options).toString().trim(); -} - -function ensureDir(dir: string) { - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); -} - -function generateClockId(): number { - return Math.floor(Math.random() * 1024); -} - -function toBase32Sortable(num: bigint): string { - if (num === 0n) return "2222222222222"; - let result = ""; - while (num > 0n) { - result = BASE32_SORTABLE[Number(num % 32n)] + result; - num = num / 32n; + console.warn( + `[WARN] Could not push ${repoName} to Tangled. Check SSH or repo existence.`, + ); } - return result.padStart(13, "2"); -} - -function generateTid(): string { - const nowMicroseconds = BigInt(Date.now()) * 1000n; - const clockId = generateClockId(); - const tidBigInt = (nowMicroseconds << 10n) | BigInt(clockId); - return toBase32Sortable(tidBigInt); } -// Tangled repo schema typing (matches sh.tangled.repo lexicon) -interface TangledRepoRecord { - $type: "sh.tangled.repo"; - name: string; // required - knot: string; // required - createdAt: string; // required (ISO 8601 datetime) - spindle?: string; // optional CI runner - description?: string; // optional, max 140 graphemes - website?: string; // optional URI - topics?: string[]; // optional array of topics - source?: string; // optional source URI - labels?: string[]; // optional array of at-uri labels -} +// ── Tangled record management ────────────────────────────────── -// Cache for existing repo records const recordCache: Record = {}; async function ensureTangledRecord( - agent: AtpAgent, - atprotoDid: string, - githubUser: string, + agent: Awaited>, repoName: string, - description?: string + description?: string, ): Promise<{ tid: string; existed: boolean }> { if (recordCache[repoName]) { return { tid: recordCache[repoName], existed: true }; } - let cursor: string | undefined = undefined; + let cursor: string | undefined; let tid: string | null = null; do { const res: any = await agent.api.com.atproto.repo.listRecords({ - repo: atprotoDid, + repo: ATPROTO_DID, collection: "sh.tangled.repo", limit: 50, cursor, @@ -150,56 +93,57 @@ async function ensureTangledRecord( return { tid, existed: true }; } } - cursor = res.data.cursor; } while (!tid && cursor); - if (!tid) { - tid = generateTid(); - const record: TangledRepoRecord = { - $type: "sh.tangled.repo", - name: repoName, - knot: "knot1.tangled.sh", - createdAt: new Date().toISOString(), - description: description ?? repoName, - source: `https://github.com/${githubUser}/${repoName}`, - labels: [], - }; - - try { - const result = await agent.api.com.atproto.repo.putRecord({ - repo: atprotoDid, - collection: "sh.tangled.repo", - rkey: tid, - record, - }); - console.log(`[CREATED] ATProto record URI: ${result.data.uri}`); - } catch (error: any) { - console.error(`[ERROR] Failed to create ATProto record for ${repoName}:`, error.message); - throw error; - } + tid = generateTid(); + const record: TangledRepoRecord = { + $type: "sh.tangled.repo", + name: repoName, + knot: "knot1.tangled.sh", + createdAt: new Date().toISOString(), + description: description ?? repoName, + source: `https://github.com/${GITHUB_USER}/${repoName}`, + labels: [], + }; - recordCache[repoName] = tid; - console.log(`[CREATED] Tangled record for ${repoName} (TID: ${tid})`); - return { tid, existed: false }; + try { + const result = await agent.api.com.atproto.repo.putRecord({ + repo: ATPROTO_DID, + collection: "sh.tangled.repo", + rkey: tid, + record, + }); + console.log(`[CREATED] ATProto record URI: ${result.data.uri}`); + } catch (error: any) { + console.error( + `[ERROR] Failed to create ATProto record for ${repoName}:`, + error.message, + ); + throw error; } + recordCache[repoName] = tid; + console.log(`[CREATED] Tangled record for ${repoName} (TID: ${tid})`); return { tid, existed: false }; } -function updateReadme(baseDir: string, repoName: string, atprotoDid: string) { +// ── README update ────────────────────────────────────────────── + +function updateReadme(baseDir: string, repoName: string) { const repoDir = path.join(baseDir, repoName); const readmeFiles = ["README.md", "README.MD", "README.txt", "README"]; - const readmeFile = readmeFiles.find((f) => fs.existsSync(path.join(repoDir, f))); + const readmeFile = readmeFiles.find((f) => + fs.existsSync(path.join(repoDir, f)), + ); if (!readmeFile) return; + const readmePath = path.join(repoDir, readmeFile); const content = fs.readFileSync(readmePath, "utf-8"); if (!/tangled\.org/i.test(content)) { fs.appendFileSync( readmePath, - ` -Mirrored on Tangled: https://tangled.org/${atprotoDid}/${repoName} -` + `\nMirrored on Tangled: https://tangled.org/${ATPROTO_DID}/${repoName}\n`, ); run(`git add ${readmeFile}`, repoDir); run(`git commit -m "Add Tangled mirror reference to README"`, repoDir); @@ -208,36 +152,31 @@ Mirrored on Tangled: https://tangled.org/${atprotoDid}/${repoName} } } +// ── Main orchestrator ────────────────────────────────────────── + async function main() { console.log("[STARTUP] Starting Tangled Sync..."); - if (FORCE_SYNC) { + if (FORCE_SYNC) console.log("[MODE] Force sync enabled - will process all repos"); - } console.log(`[CONFIG] Base directory: ${BASE_DIR}`); console.log(`[CONFIG] GitHub user: ${GITHUB_USER}`); console.log(`[CONFIG] ATProto DID: ${ATPROTO_DID}`); - console.log(`[CONFIG] PDS: ${BLUESKY_PDS}`); - - // Login to AT Proto - await login(); - - // Ensure base directory exists + + const agent = await createAgent(); ensureDir(BASE_DIR); - - // Fetch GitHub repositories + console.log(`[GITHUB] Fetching repositories for ${GITHUB_USER}...`); const repos = await getGitHubRepos(); console.log(`[GITHUB] Found ${repos.length} repositories`); - + let reposToProcess = repos; let skippedRepos: typeof repos = []; - + if (!FORCE_SYNC) { - // Fetch all existing Tangled records upfront console.log(`[ATPROTO] Fetching existing Tangled records...`); - let cursor: string | undefined = undefined; + let cursor: string | undefined; const existingRepos = new Set(); - + do { const res: any = await agent.api.com.atproto.repo.listRecords({ repo: ATPROTO_DID, @@ -245,7 +184,7 @@ async function main() { limit: 100, cursor, }); - + for (const record of res.data.records) { const value = record.value as TangledRepoRecord; if (value.name) { @@ -253,33 +192,41 @@ async function main() { recordCache[value.name] = record.rkey; } } - cursor = res.data.cursor; } while (cursor); - - console.log(`[ATPROTO] Found ${existingRepos.size} existing Tangled records`); - - // Separate repos into new and existing - reposToProcess = repos.filter(r => !existingRepos.has(r.name)); - skippedRepos = repos.filter(r => existingRepos.has(r.name)); - + + console.log( + `[ATPROTO] Found ${existingRepos.size} existing Tangled records`, + ); + + reposToProcess = repos.filter((r) => !existingRepos.has(r.name)); + skippedRepos = repos.filter((r) => existingRepos.has(r.name)); + console.log(`[INFO] ${reposToProcess.length} new repos to sync`); - console.log(`[INFO] ${skippedRepos.length} repos already synced (skipping)\n`); - + console.log( + `[INFO] ${skippedRepos.length} repos already synced (skipping)\n`, + ); + if (skippedRepos.length > 0) { - console.log("[SKIPPED] The following repos already have AT Proto records:"); - skippedRepos.forEach(r => console.log(` - ${r.name}`)); + console.log( + "[SKIPPED] The following repos already have AT Proto records:", + ); + skippedRepos.forEach((r) => console.log(` - ${r.name}`)); console.log(""); } } else { - console.log("[INFO] Processing all ${repos.length} repos (force sync mode)\n"); + console.log( + `[INFO] Processing all ${repos.length} repos (force sync mode)\n`, + ); } let syncedCount = 0; let errorCount = 0; for (const { clone_url, name: repoName, description } of reposToProcess) { - console.log(`\n[PROGRESS] Processing ${repoName} (${syncedCount + 1}/${reposToProcess.length})`); + console.log( + `\n[PROGRESS] Processing ${repoName} (${syncedCount + 1}/${reposToProcess.length})`, + ); const repoDir = path.join(BASE_DIR, repoName); try { @@ -291,28 +238,22 @@ async function main() { } await ensureTangledRemoteAndPush(repoDir, repoName, clone_url); - updateReadme(BASE_DIR, repoName, ATPROTO_DID); - const result = await ensureTangledRecord(agent, ATPROTO_DID, GITHUB_USER, repoName, description); - - if (!result.existed) { - syncedCount++; - } + updateReadme(BASE_DIR, repoName); + const result = await ensureTangledRecord(agent, repoName, description); + + if (!result.existed) syncedCount++; } catch (error: any) { console.error(`[ERROR] Failed to sync ${repoName}: ${error.message}`); errorCount++; } } - - console.log(`\n${'='.repeat(50)}`); + + console.log(`\n${"=".repeat(50)}`); console.log(`[COMPLETE] Sync finished!`); console.log(` āœ… New repos synced: ${syncedCount}`); - if (!FORCE_SYNC) { - console.log(` ā­ļø Repos skipped: ${skippedRepos.length}`); - } - if (errorCount > 0) { - console.log(` āŒ Errors: ${errorCount}`); - } - console.log(`${'='.repeat(50)}`); + if (!FORCE_SYNC) console.log(` ā­ļø Repos skipped: ${skippedRepos.length}`); + if (errorCount > 0) console.log(` āŒ Errors: ${errorCount}`); + console.log(`${"=".repeat(50)}`); } main().catch(console.error); diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts new file mode 100644 index 0000000..8ddae55 --- /dev/null +++ b/src/lib/atproto.ts @@ -0,0 +1,28 @@ +import { AtpAgent } from "@atproto/api"; + +/** Create and authenticate an AtpAgent from environment variables. */ +export async function createAgent(): Promise { + const username = process.env.BLUESKY_USERNAME; + const password = process.env.BLUESKY_PASSWORD; + const pds = process.env.BLUESKY_PDS; + + if (!username || !password) { + throw new Error( + "Missing Bluesky credentials. Please set BLUESKY_USERNAME and BLUESKY_PASSWORD in src/.env", + ); + } + + const agent = new AtpAgent({ service: pds! }); + + try { + const response = await agent.login({ identifier: username, password }); + console.log( + `[LOGIN] Successfully logged in to AT Proto as ${response.data.did}`, + ); + console.log(`[LOGIN] Session handle: ${response.data.handle}`); + return agent; + } catch (error: any) { + console.error("[ERROR] Failed to login to AT Proto:", error.message); + throw error; + } +} diff --git a/src/lib/checks/deps.ts b/src/lib/checks/deps.ts new file mode 100644 index 0000000..142a518 --- /dev/null +++ b/src/lib/checks/deps.ts @@ -0,0 +1,30 @@ +export interface CheckResult { + status: boolean; + message: string; +} + +export async function checkDependencies(): Promise { + const results: CheckResult[] = []; + + try { + await import("@atproto/api"); + results.push({ status: true, message: "@atproto/api installed" }); + } catch { + results.push({ + status: false, + message: "@atproto/api not installed (run: npm install)", + }); + } + + try { + await import("dotenv"); + results.push({ status: true, message: "dotenv installed" }); + } catch { + results.push({ + status: false, + message: "dotenv not installed (run: npm install)", + }); + } + + return results; +} diff --git a/src/lib/checks/github.ts b/src/lib/checks/github.ts new file mode 100644 index 0000000..88bc2cb --- /dev/null +++ b/src/lib/checks/github.ts @@ -0,0 +1,32 @@ +import { execSync } from "child_process"; + +export interface CheckResult { + status: boolean; + message: string; +} + +export async function checkGitHubApi(): Promise { + const githubUser = process.env.GITHUB_USER; + if (!githubUser) return null; + + try { + const response = execSync( + `curl -s "https://api.github.com/users/${githubUser}"`, + { encoding: "utf-8", timeout: 5000 }, + ); + const data = JSON.parse(response); + + if (data.login) { + return { + status: true, + message: `GitHub user found: ${data.login} (${data.public_repos || 0} public repos)`, + }; + } + return { status: false, message: `GitHub user not found: ${githubUser}` }; + } catch (error: any) { + return { + status: false, + message: `Could not check GitHub API: ${error.message}`, + }; + } +} diff --git a/src/lib/checks/ssh.ts b/src/lib/checks/ssh.ts new file mode 100644 index 0000000..126f6dc --- /dev/null +++ b/src/lib/checks/ssh.ts @@ -0,0 +1,36 @@ +import { execSync } from "child_process"; + +export interface CheckResult { + status: boolean; + message: string; +} + +export async function checkSshConnection(): Promise { + try { + const sshTest = execSync("ssh -T git@tangled.sh 2>&1", { + encoding: "utf-8", + timeout: 5000, + }); + + if ( + sshTest.includes("successfully authenticated") || + sshTest.includes("Hi") + ) { + return { status: true, message: sshTest.trim().split("\n")[0] }; + } + return { status: false, message: `Uncertain: ${sshTest.trim()}` }; + } catch (error: any) { + const output = error.stdout?.toString() || error.message; + if ( + output.includes("successfully authenticated") || + output.includes("Hi") + ) { + return { status: true, message: "SSH connection to Tangled works" }; + } + return { + status: false, + message: + "SSH connection failed. Add your SSH key at https://tangled.org/settings/keys", + }; + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..b8d37b8 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,108 @@ +import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; + +/** Load .env from src/.env, relative to the project root. */ +export function loadConfig(): void { + dotenv.config({ path: "./src/.env" }); +} + +/** Required environment variables for Tangled Sync. */ +export const REQUIRED_VARS = [ + { name: "BASE_DIR", description: "Base directory for repos" }, + { name: "GITHUB_USER", description: "GitHub username" }, + { name: "ATPROTO_DID", description: "AT Proto DID" }, + { name: "BLUESKY_PDS", description: "Bluesky PDS URL" }, + { name: "BLUESKY_USERNAME", description: "Bluesky username" }, + { name: "BLUESKY_PASSWORD", description: "Bluesky app password" }, +] as const; + +export interface ConfigCheck { + name: string; + status: boolean; + message: string; +} + +/** Validate all required env vars are present. */ +export function validateEnvVars(): ConfigCheck[] { + return REQUIRED_VARS.map(({ name, description }) => { + const value = process.env[name]; + const exists = !!value && value.trim().length > 0; + return { + name, + status: exists, + message: exists ? `āœ“ Set` : `āœ— Missing (${description})`, + }; + }); +} + +/** Check that the .env file exists on disk. */ +export function checkEnvFile(envPath?: string): ConfigCheck { + const p = envPath ?? path.join(process.cwd(), "src", ".env"); + const exists = fs.existsSync(p); + return { + name: ".env file", + status: exists, + message: exists + ? `Found at src/.env` + : "Missing! Copy src/.env.example to src/.env", + }; +} + +/** Check that BASE_DIR exists on disk. */ +export function checkBaseDir(): ConfigCheck | null { + const baseDir = process.env.BASE_DIR; + if (!baseDir) return null; + const exists = fs.existsSync(baseDir); + return { + name: "BASE_DIR path", + status: exists, + message: exists + ? `Exists: ${baseDir}` + : `Missing (will be created): ${baseDir}`, + }; +} + +/** Validate DID format. */ +export function checkDidFormat(): ConfigCheck | null { + const did = process.env.ATPROTO_DID; + if (!did) return null; + const valid = did.startsWith("did:plc:") || did.startsWith("did:web:"); + return { + name: "DID format", + status: valid, + message: valid + ? "Valid DID format" + : "Invalid! Should start with 'did:plc:' or 'did:web:'", + }; +} + +/** Validate PDS URL format. */ +export function checkPdsUrl(): ConfigCheck | null { + const pds = process.env.BLUESKY_PDS; + if (!pds) return null; + const valid = pds.startsWith("http://") || pds.startsWith("https://"); + return { + name: "PDS URL format", + status: valid, + message: valid + ? `Valid URL: ${pds}` + : "Invalid! Should start with 'https://'", + }; +} + +/** Run all configuration checks. */ +export function runConfigChecks(): ConfigCheck[] { + const results: ConfigCheck[] = [checkEnvFile(), ...validateEnvVars()]; + + const baseDirCheck = checkBaseDir(); + if (baseDirCheck) results.push(baseDirCheck); + + const didCheck = checkDidFormat(); + if (didCheck) results.push(didCheck); + + const pdsCheck = checkPdsUrl(); + if (pdsCheck) results.push(pdsCheck); + + return results; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a54b403 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,60 @@ +import fs from "fs"; +import { execSync } from "child_process"; + +// ── Filesystem ───────────────────────────────────────────────── + +export function ensureDir(dir: string): void { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +// ── Shell ────────────────────────────────────────────────────── + +export function run(cmd: string, cwd?: string): string { + const options: import("child_process").ExecSyncOptions = { + cwd, + stdio: "pipe", + shell: process.env.SHELL || "/bin/bash", + encoding: "utf-8", + }; + return execSync(cmd, options).toString().trim(); +} + +// ── TID generation ───────────────────────────────────────────── + +const BASE32_SORTABLE = "234567abcdefghijklmnopqrstuvwxyz"; + +function generateClockId(): number { + return Math.floor(Math.random() * 1024); +} + +function toBase32Sortable(num: bigint): string { + if (num === 0n) return "2222222222222"; + let result = ""; + while (num > 0n) { + result = BASE32_SORTABLE[Number(num % 32n)] + result; + num = num / 32n; + } + return result.padStart(13, "2"); +} + +export function generateTid(): string { + const nowMicroseconds = BigInt(Date.now()) * 1000n; + const clockId = generateClockId(); + const tidBigInt = (nowMicroseconds << 10n) | BigInt(clockId); + return toBase32Sortable(tidBigInt); +} + +// ── Tangled repo types ───────────────────────────────────────── + +export interface TangledRepoRecord { + $type: "sh.tangled.repo"; + name: string; + knot: string; + createdAt: string; + spindle?: string; + description?: string; + website?: string; + topics?: string[]; + source?: string; + labels?: string[]; +} diff --git a/src/validate-config.ts b/src/validate-config.ts deleted file mode 100644 index d8a4600..0000000 --- a/src/validate-config.ts +++ /dev/null @@ -1,109 +0,0 @@ -import dotenv from "dotenv"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -dotenv.config({ path: "./src/.env" }); - -console.log("šŸ” Validating Tangled Sync Configuration...\n"); - -const checks: { name: string; status: boolean; message: string }[] = []; - -// Check .env file exists -const envPath = path.join(__dirname, ".env"); -const envExists = fs.existsSync(envPath); -checks.push({ - name: ".env file", - status: envExists, - message: envExists ? "Found at src/.env" : "Missing! Copy src/.env.example to src/.env" -}); - -// Check required environment variables -const requiredVars = [ - { name: "BASE_DIR", description: "Base directory for repos" }, - { name: "GITHUB_USER", description: "GitHub username" }, - { name: "ATPROTO_DID", description: "AT Proto DID" }, - { name: "BLUESKY_PDS", description: "Bluesky PDS URL" }, - { name: "BLUESKY_USERNAME", description: "Bluesky username" }, - { name: "BLUESKY_PASSWORD", description: "Bluesky app password" }, -]; - -requiredVars.forEach(({ name, description }) => { - const value = process.env[name]; - const exists = !!value && value.trim().length > 0; - checks.push({ - name: `${name}`, - status: exists, - message: exists ? `āœ“ Set (${description})` : `āœ— Missing (${description})` - }); -}); - -// Validate BASE_DIR -const baseDir = process.env.BASE_DIR; -if (baseDir) { - const baseDirExists = fs.existsSync(baseDir); - checks.push({ - name: "BASE_DIR exists", - status: baseDirExists, - message: baseDirExists ? `Directory exists: ${baseDir}` : `Directory missing: ${baseDir} (will be created)` - }); -} - -// Validate DID format -const did = process.env.ATPROTO_DID; -if (did) { - const validDid = did.startsWith("did:plc:") || did.startsWith("did:web:"); - checks.push({ - name: "DID format", - status: validDid, - message: validDid ? "Valid DID format" : "Invalid! Should start with 'did:plc:' or 'did:web:'" - }); -} - -// Validate PDS URL -const pds = process.env.BLUESKY_PDS; -if (pds) { - const validPds = pds.startsWith("http://") || pds.startsWith("https://"); - checks.push({ - name: "PDS URL format", - status: validPds, - message: validPds ? `Valid URL: ${pds}` : "Invalid! Should start with 'https://'" - }); -} - -// Print results -console.log("Configuration Check Results:\n"); -let allPassed = true; - -checks.forEach((check) => { - const icon = check.status ? "āœ…" : "āŒ"; - console.log(`${icon} ${check.name}: ${check.message}`); - if (!check.status) allPassed = false; -}); - -console.log("\n" + "=".repeat(50) + "\n"); - -if (allPassed) { - console.log("āœ… All checks passed! You're ready to run:"); - console.log(" npm run test-atproto # Test AT Proto connection"); - console.log(" npm run sync # Run the full sync"); -} else { - console.log("āŒ Some checks failed. Please fix the issues above."); - console.log(" See SETUP.md for detailed instructions."); - process.exit(1); -} - -// Additional recommendations -console.log("\nšŸ’” Recommendations:"); - -if (process.env.BLUESKY_PASSWORD && !process.env.BLUESKY_PASSWORD.includes("-")) { - console.log(" āš ļø Your password looks like it might be a regular password."); - console.log(" Consider using an App Password from Bluesky settings."); -} - -console.log(" šŸ“š Read SETUP.md for detailed setup instructions"); -console.log(" šŸ” Never commit your .env file to version control"); -console.log(" šŸ”‘ Make sure your SSH key is added to Tangled");