Move from GitHub to Tangled

Initial bootstrap commit

+211
+1
LICENSE
··· 1 + MIT License
+13
README.md
··· 1 + 2 + # Tangled Sync 3 + 4 + This bootstrap creates a TypeScript project that syncs GitHub repos to Tangled and 5 + publishes ATProto records for each repository. 6 + 7 + See `src/config.env` for configuration. After running this script, run `npm install` 8 + and then `npm run sync` from the project directory. 9 + 10 + **Crucially**, before running `npm run sync`, you must **verify your SSH connection** to Tangled: 11 + 12 + 1. Run `ssh -T git@tangled.sh` and ensure it succeeds. 13 + 2. If the tangled remote does not exist for a GitHub repo, the script will attempt to create it on first run, but this requires an active, working SSH key.
+20
package.json
··· 1 + 2 + { 3 + "name": "tangled-sync", 4 + "version": "1.0.0", 5 + "description": "Sync GitHub repos to Tangled with ATProto records", 6 + "main": "src/index.ts", 7 + "scripts": { 8 + "sync": "ts-node src/index.ts" 9 + }, 10 + "dependencies": { 11 + "@atproto/api": "^0.17.2", 12 + "dotenv": "^16.0.0" 13 + }, 14 + "devDependencies": { 15 + "typescript": "^5.0.0", 16 + "ts-node": "^10.0.0" 17 + }, 18 + "author": "", 19 + "license": "MIT" 20 + }
+7
src/config.env
··· 1 + 2 + BLUESKY_USERNAME=your_bsky_username 3 + BLUESKY_PASSWORD=your_bsky_password 4 + BLUESKY_PDS=https://bsky.social # <-- ADDED PDS URL 5 + BASE_DIR=/Volumes/Storage/Developer/Git 6 + GITHUB_USER=ewanc26 7 + ATPROTO_DID=did:plc:ofrbh253gwicbkc5nktqepol
+89
src/index.ts
··· 1 + 2 + import { BskyAgent } from "@atproto/api"; 3 + import * as dotenv from "dotenv"; 4 + import * as fs from "fs"; 5 + import * as path from "path"; 6 + import { run, ensureDir, ensureTangledRecord, updateReadme } from "./repo-utils"; 7 + 8 + dotenv.config({ path: "./src/config.env" }); 9 + 10 + const BASE_DIR = process.env.BASE_DIR!; 11 + const GITHUB_USER = process.env.GITHUB_USER!; 12 + const ATPROTO_DID = process.env.ATPROTO_DID!; 13 + const BLUESKY_PDS = process.env.BLUESKY_PDS!; // <-- GET PDS URL 14 + const TANGLED_BASE_URL = `git@tangled.sh:${ATPROTO_DID}`; 15 + 16 + // Initialize BskyAgent with the specified PDS URL 17 + const agent = new BskyAgent({ service: BLUESKY_PDS }); // <-- USE PDS URL 18 + 19 + async function login() { 20 + if (!process.env.BLUESKY_USERNAME || !process.env.BLUESKY_PASSWORD) { 21 + throw new Error("Missing Bluesky credentials"); 22 + } 23 + await agent.login({ identifier: process.env.BLUESKY_USERNAME, password: process.env.BLUESKY_PASSWORD }); 24 + console.log("[LOGIN] Logged in to Bluesky"); 25 + } 26 + 27 + async function getGitHubRepos(): Promise<{ clone_url: string, name: string, description?: string }[]> { 28 + // We use `shell: true` in repo-utils for this command as it contains quotes and a pipe is complex to pass as an array. 29 + const curl = `curl -s "https://api.github.com/users/${GITHUB_USER}/repos?per_page=200"`; 30 + const output = run(curl); 31 + const json = JSON.parse(output); 32 + return json.filter((r: any) => r.name !== GITHUB_USER).map((r: any) => ({ clone_url: r.clone_url, name: r.name, description: r.description })) as any[]; 33 + } 34 + 35 + // ----------------------------------------------------- 36 + // NEW/MODIFIED LOGIC HERE: ENSURE REMOTE AND PUSH SAFELY 37 + // ----------------------------------------------------- 38 + async function ensureTangledRemoteAndPush(repoDir: string, repoName: string, cloneUrl: string) { 39 + const tangledUrl = `${TANGLED_BASE_URL}/${repoName}`; 40 + 41 + try { 42 + const remotes = run("git remote", repoDir).split("\n"); 43 + 44 + if (!remotes.includes("tangled")) { 45 + console.log(`[REMOTE] 'tangled' remote not found for ${repoName}. Attempting to add it...`); 46 + run(`git remote add tangled ${tangledUrl}`, repoDir); 47 + console.log(`[REMOTE] Added Tangled remote: ${tangledUrl}`); 48 + } 49 + 50 + // Safety check: ensure 'origin' push URL is not pointing to tangled.sh 51 + const originPushUrl = run("git remote get-url --push origin", repoDir); 52 + if (originPushUrl.includes("tangled.sh")) { 53 + run(`git remote set-url --push origin ${cloneUrl}`, repoDir); 54 + console.log(`[REMOTE] Removed Tangled from origin push URL and set to: ${cloneUrl}`); 55 + } 56 + 57 + // Attempt to push. This is the point where the SSH connection is tested for this repo. 58 + run(`git push tangled main`, repoDir); 59 + console.log(`[PUSH] Pushed main branch to Tangled successfully.`); 60 + 61 + } catch (error) { 62 + console.log(`[FAIL] Push to Tangled failed for ${repoName}. This is expected if the remote repository does not exist on tangled.sh or SSH is not configured.`); 63 + console.log(`[HINT] You must ensure 'ssh -T git@tangled.sh' works before running the sync.`); 64 + // The script continues to the next repo/record creation even if the push fails. 65 + } 66 + } 67 + 68 + async function main() { 69 + await login(); 70 + ensureDir(BASE_DIR); 71 + const repos = await getGitHubRepos(); 72 + 73 + for (const { clone_url, name: repoName, description } of repos) { 74 + console.log(` 75 + [PROGRESS] Processing ${repoName}`); 76 + const repoDir = path.join(BASE_DIR, repoName); 77 + if (!fs.existsSync(repoDir)) { 78 + run(`git clone ${clone_url} ${repoDir}`); 79 + console.log(`[CLONE] ${repoName}`); 80 + } 81 + 82 + await ensureTangledRemoteAndPush(repoDir, repoName, clone_url); 83 + 84 + updateReadme(BASE_DIR, repoName, ATPROTO_DID); 85 + await ensureTangledRecord(agent, ATPROTO_DID, GITHUB_USER, repoName, description); 86 + } 87 + } 88 + 89 + main().catch(console.error);
+81
src/repo-utils.ts
··· 1 + 2 + import { BskyAgent } from "@atproto/api"; 3 + import * as child_process from "child_process"; 4 + import * as fs from "fs"; 5 + import * as path from "path"; 6 + 7 + const BASE32_SORTABLE = '234567abcdefghijklmnopqrstuvwxyz'; 8 + 9 + export function run(cmd: string, cwd?: string): string { 10 + // Use shell: true for commands that contain pipes or shell built-ins 11 + return child_process.execSync(cmd, { cwd, stdio: "pipe", shell: true }).toString().trim(); 12 + } 13 + 14 + export function ensureDir(dir: string) { 15 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 16 + } 17 + 18 + function generateClockId(): number { return Math.floor(Math.random() * 1024); } 19 + 20 + function toBase32Sortable(num: bigint): string { 21 + if (num === 0n) return '2222222222222'; 22 + let result = ''; 23 + while (num > 0n) { 24 + result = BASE32_SORTABLE[Number(num % 32n)] + result; 25 + num = num / 32n; 26 + } 27 + return result.padStart(13, '2'); 28 + } 29 + 30 + export function generateTid(): string { 31 + const nowMicroseconds = BigInt(Date.now()) * 1000n; 32 + const clockId = generateClockId(); 33 + const tidBigInt = (nowMicroseconds << 10n) | BigInt(clockId); 34 + return toBase32Sortable(tidBigInt); 35 + } 36 + 37 + export async function ensureTangledRecord(agent: BskyAgent, atprotoDid: string, githubUser: string, repoName: string, description?: string) { 38 + let tid: string; 39 + let exists = true; 40 + while (exists) { 41 + tid = generateTid(); 42 + try { 43 + await agent.api.com.atproto.repo.getRecord({ repo: atprotoDid, collection: "sh.tangled.repo", rkey: tid }); 44 + exists = true; 45 + } catch { 46 + exists = false; 47 + } 48 + } 49 + 50 + const record = { 51 + $type: "sh.tangled.repo", 52 + knot: "knot1.tangled.sh", 53 + name: repoName, 54 + createdAt: new Date().toISOString(), 55 + description: description || repoName, 56 + labels: [], 57 + source: `https://github.com/${github_user}/${repoName}`, 58 + spindle: "", 59 + }; 60 + 61 + await agent.api.com.atproto.repo.putRecord({ repo: atprotoDid, collection: "sh.tangled.repo", rkey: tid, record }); 62 + console.log(`[CREATED] Tangled record for ${repoName} (TID: ${tid})`); 63 + } 64 + 65 + export function updateReadme(baseDir: string, repoName: string, atprotoDid: string) { 66 + const repoDir = path.join(baseDir, repoName); 67 + const readmeFiles = ["README.md", "README.MD", "README.txt", "README"]; 68 + const readmeFile = readmeFiles.find(f => fs.existsSync(path.join(repoDir, f))); 69 + if (!readmeFile) return; 70 + const readmePath = path.join(repoDir, readmeFile); 71 + const content = fs.readFileSync(readmePath, "utf-8"); 72 + if (!/tangled\.org/i.test(content)) { 73 + fs.appendFileSync(readmePath, ` 74 + Mirrored on Tangled: https://tangled.org/${atprotoDid}/${repoName} 75 + `); 76 + run(`git add ${readmeFile}`, repoDir); 77 + run(`git commit -m "Add Tangled mirror reference to README"`, repoDir); 78 + run(`git push origin main`, repoDir); 79 + console.log(`[README] Updated for ${repoName}`); 80 + } 81 + }