Tend your corner of the atmosphere. spores.garden turns your AT Protocol records into a personal site with unique themes. Your data never leaves your PDS. Grow something that's truly yours. spores.garden
at main 349 lines 11 kB view raw
1import fs from 'fs'; 2import path from 'path'; 3import { fileURLToPath } from 'url'; 4 5const __filename = fileURLToPath(import.meta.url); 6const __dirname = path.dirname(__filename); 7const rootDir = path.resolve(__dirname, '..'); 8 9const XRPC_CREATE_SESSION = 'com.atproto.server.createSession'; 10const XRPC_LIST_RECORDS = 'com.atproto.repo.listRecords'; 11const XRPC_PUT_RECORD = 'com.atproto.repo.putRecord'; 12const XRPC_DELETE_RECORD = 'com.atproto.repo.deleteRecord'; 13 14const COLLECTIONS = [ 15 'garden.spores.site.config', 16 'garden.spores.site.layout', 17 'garden.spores.site.section', 18 'garden.spores.site.profile', 19 'garden.spores.content.text', 20 'garden.spores.content.image', 21 'garden.spores.social.flower', 22 'garden.spores.social.takenFlower', 23 'garden.spores.item.specialSpore', 24 'coop.hypha.spores.site.config', 25 'coop.hypha.spores.site.layout', 26 'coop.hypha.spores.site.section', 27 'coop.hypha.spores.site.profile', 28 'coop.hypha.spores.content.text', 29 'coop.hypha.spores.content.image', 30 'coop.hypha.spores.social.flower', 31 'coop.hypha.spores.social.takenFlower', 32 'coop.hypha.spores.item.specialSpore', 33]; 34 35const MAX_PAGES = 500; 36 37function usage() { 38 console.log(`Usage: 39 node scripts/garden-data-tools.js backup [--out <file>] 40 node scripts/garden-data-tools.js reset [--dry-run] [--yes] 41 node scripts/garden-data-tools.js restore --from <file> [--dry-run] 42 43Required env (for all commands): 44 ATPROTO_IDENTIFIER 45 ATPROTO_APP_PASSWORD 46 47Optional env: 48 ATPROTO_AUTH_SERVICE (default: https://bsky.social) 49 ATPROTO_PDS_URL (override resolved PDS) 50 ATPROTO_REPO_DID (defaults to authenticated DID) 51 52Examples: 53 node scripts/garden-data-tools.js backup 54 node scripts/garden-data-tools.js reset --dry-run 55 node scripts/garden-data-tools.js reset --yes 56 node scripts/garden-data-tools.js restore --from backups/spores-garden-backup-2026-02-16.json 57`); 58} 59 60function normalizeServiceUrl(url) { 61 return url.endsWith('/') ? url.slice(0, -1) : url; 62} 63 64function parseArgs(argv) { 65 const [command, ...rest] = argv; 66 const flags = new Map(); 67 for (let i = 0; i < rest.length; i++) { 68 const item = rest[i]; 69 if (!item.startsWith('--')) continue; 70 const key = item.slice(2); 71 const next = rest[i + 1]; 72 if (!next || next.startsWith('--')) { 73 flags.set(key, true); 74 } else { 75 flags.set(key, next); 76 i += 1; 77 } 78 } 79 return { command, flags }; 80} 81 82function didWebToDidDocumentUrl(did) { 83 const withoutPrefix = did.slice('did:web:'.length); 84 const parts = withoutPrefix.split(':'); 85 const host = parts.shift(); 86 const pathParts = parts.map(decodeURIComponent); 87 const pathSuffix = pathParts.length ? `/${pathParts.join('/')}` : ''; 88 return `https://${host}${pathSuffix}/did.json`; 89} 90 91async function jsonRequest(url, method, body, accessJwt) { 92 const res = await fetch(url, { 93 method, 94 headers: { 95 'Content-Type': 'application/json', 96 ...(accessJwt ? { Authorization: `Bearer ${accessJwt}` } : {}), 97 }, 98 body: body ? JSON.stringify(body) : undefined, 99 }); 100 if (!res.ok) { 101 const text = await res.text(); 102 throw new Error(`${method} ${url} failed (${res.status}): ${text}`); 103 } 104 if (res.status === 204) return null; 105 return res.json(); 106} 107 108async function xrpcPost(serviceUrl, method, body, accessJwt) { 109 return jsonRequest(`${normalizeServiceUrl(serviceUrl)}/xrpc/${method}`, 'POST', body, accessJwt); 110} 111 112async function xrpcGet(serviceUrl, method, query, accessJwt) { 113 const params = new URLSearchParams(); 114 for (const [k, v] of Object.entries(query || {})) { 115 if (v === undefined || v === null || v === '') continue; 116 params.set(k, String(v)); 117 } 118 const url = `${normalizeServiceUrl(serviceUrl)}/xrpc/${method}?${params.toString()}`; 119 return jsonRequest(url, 'GET', undefined, accessJwt); 120} 121 122async function authenticate() { 123 const authService = process.env.ATPROTO_AUTH_SERVICE || 'https://bsky.social'; 124 const identifier = process.env.ATPROTO_IDENTIFIER; 125 const password = process.env.ATPROTO_APP_PASSWORD; 126 if (!identifier || !password) { 127 throw new Error('Missing ATPROTO_IDENTIFIER or ATPROTO_APP_PASSWORD'); 128 } 129 const session = await xrpcPost(authService, XRPC_CREATE_SESSION, { identifier, password }); 130 if (!session?.accessJwt || !session?.did) { 131 throw new Error('Invalid session response (missing accessJwt or did)'); 132 } 133 return { accessJwt: session.accessJwt, did: session.did }; 134} 135 136async function resolvePdsFromDid(did) { 137 const didDocUrl = did.startsWith('did:plc:') 138 ? `https://plc.directory/${did}` 139 : did.startsWith('did:web:') 140 ? didWebToDidDocumentUrl(did) 141 : null; 142 143 if (!didDocUrl) { 144 throw new Error(`Unsupported DID method for PDS resolution: ${did}`); 145 } 146 147 const didDoc = await jsonRequest(didDocUrl, 'GET'); 148 const services = Array.isArray(didDoc?.service) ? didDoc.service : []; 149 const pds = services.find((s) => s?.type === 'AtprotoPersonalDataServer') || services[0]; 150 if (!pds?.serviceEndpoint || typeof pds.serviceEndpoint !== 'string') { 151 throw new Error(`No PDS endpoint found in DID document for ${did}`); 152 } 153 return pds.serviceEndpoint; 154} 155 156async function buildContext() { 157 const { accessJwt, did: authDid } = await authenticate(); 158 const repoDid = process.env.ATPROTO_REPO_DID || authDid; 159 const pdsUrl = process.env.ATPROTO_PDS_URL || await resolvePdsFromDid(repoDid); 160 return { accessJwt, authDid, repoDid, pdsUrl }; 161} 162 163function backupFilename(repoDid) { 164 const ts = new Date().toISOString().replace(/[:.]/g, '-'); 165 return `spores-garden-backup-${repoDid}-${ts}.json`; 166} 167 168async function listAllRecords(ctx, collection) { 169 const all = []; 170 const seen = new Set(); 171 let cursor = undefined; 172 for (let page = 0; page < MAX_PAGES; page++) { 173 const response = await xrpcGet(ctx.pdsUrl, XRPC_LIST_RECORDS, { 174 repo: ctx.repoDid, 175 collection, 176 limit: 100, 177 cursor, 178 }, ctx.accessJwt).catch((error) => { 179 const msg = String(error?.message || error); 180 if (msg.includes('Could not locate record') || msg.includes('RepoNotFound') || msg.includes('InvalidRequest')) { 181 return { records: [] }; 182 } 183 throw error; 184 }); 185 186 const records = response?.records || []; 187 all.push(...records); 188 const next = response?.cursor; 189 if (!next) break; 190 if (next === cursor || seen.has(next)) break; 191 seen.add(next); 192 cursor = next; 193 } 194 return all; 195} 196 197async function collectGardenRecords(ctx) { 198 const byCollection = {}; 199 let total = 0; 200 for (const collection of COLLECTIONS) { 201 const records = await listAllRecords(ctx, collection); 202 byCollection[collection] = records; 203 total += records.length; 204 console.log(` ${collection}: ${records.length}`); 205 } 206 return { byCollection, total }; 207} 208 209async function backupCommand(ctx, flags) { 210 const { byCollection, total } = await collectGardenRecords(ctx); 211 const outArg = flags.get('out'); 212 const outFile = outArg 213 ? path.resolve(process.cwd(), String(outArg)) 214 : path.join(rootDir, 'backups', backupFilename(ctx.repoDid)); 215 216 fs.mkdirSync(path.dirname(outFile), { recursive: true }); 217 218 const backup = { 219 version: 1, 220 generatedAt: new Date().toISOString(), 221 repoDid: ctx.repoDid, 222 collections: byCollection, 223 totalRecords: total, 224 }; 225 226 fs.writeFileSync(outFile, JSON.stringify(backup, null, 2)); 227 console.log(`\nBackup complete: ${outFile}`); 228 console.log(`Total records: ${total}`); 229} 230 231function extractRkey(uri) { 232 const parts = String(uri || '').split('/'); 233 return parts.length >= 5 ? parts[4] : null; 234} 235 236async function resetCommand(ctx, flags) { 237 const dryRun = !!flags.get('dry-run'); 238 const yes = !!flags.get('yes'); 239 240 if (!dryRun && !yes) { 241 throw new Error('Refusing destructive reset without --yes (or use --dry-run first).'); 242 } 243 244 const { byCollection, total } = await collectGardenRecords(ctx); 245 console.log(`\nRecords matched for reset: ${total}`); 246 if (dryRun) { 247 console.log('Dry run only. No records were deleted.'); 248 return; 249 } 250 251 let deleted = 0; 252 for (const collection of COLLECTIONS) { 253 const records = byCollection[collection] || []; 254 for (const record of records) { 255 const rkey = extractRkey(record.uri); 256 if (!rkey) continue; 257 await xrpcPost(ctx.pdsUrl, XRPC_DELETE_RECORD, { 258 repo: ctx.repoDid, 259 collection, 260 rkey, 261 }, ctx.accessJwt); 262 deleted += 1; 263 } 264 } 265 266 console.log(`Reset complete. Deleted ${deleted} records.`); 267} 268 269async function restoreCommand(ctx, flags) { 270 const from = flags.get('from'); 271 const dryRun = !!flags.get('dry-run'); 272 if (!from || typeof from !== 'string') { 273 throw new Error('Missing --from <backup-file>'); 274 } 275 276 const filePath = path.resolve(process.cwd(), from); 277 if (!fs.existsSync(filePath)) { 278 throw new Error(`Backup file not found: ${filePath}`); 279 } 280 281 const backup = JSON.parse(fs.readFileSync(filePath, 'utf8')); 282 if (!backup?.collections || typeof backup.collections !== 'object') { 283 throw new Error(`Invalid backup format: ${filePath}`); 284 } 285 286 let total = 0; 287 for (const collection of COLLECTIONS) { 288 const records = backup.collections[collection] || []; 289 total += records.length; 290 } 291 console.log(`Records to restore: ${total}`); 292 if (dryRun) { 293 console.log('Dry run only. No records were written.'); 294 return; 295 } 296 297 let written = 0; 298 for (const collection of COLLECTIONS) { 299 const records = backup.collections[collection] || []; 300 for (const record of records) { 301 const rkey = extractRkey(record.uri); 302 const value = record?.value; 303 if (!rkey || !value || typeof value !== 'object') continue; 304 await xrpcPost(ctx.pdsUrl, XRPC_PUT_RECORD, { 305 repo: ctx.repoDid, 306 collection, 307 rkey, 308 record: value, 309 validate: true, 310 }, ctx.accessJwt); 311 written += 1; 312 } 313 } 314 315 console.log(`Restore complete. Wrote ${written} records.`); 316} 317 318async function main() { 319 const { command, flags } = parseArgs(process.argv.slice(2)); 320 if (!command || command === '--help' || command === '-h' || flags.get('help') || flags.get('h')) { 321 usage(); 322 return; 323 } 324 325 const ctx = await buildContext(); 326 console.log(`Authenticated DID: ${ctx.authDid}`); 327 console.log(`Target repo DID: ${ctx.repoDid}`); 328 console.log(`PDS endpoint: ${ctx.pdsUrl}\n`); 329 330 if (command === 'backup') { 331 await backupCommand(ctx, flags); 332 return; 333 } 334 if (command === 'reset') { 335 await resetCommand(ctx, flags); 336 return; 337 } 338 if (command === 'restore') { 339 await restoreCommand(ctx, flags); 340 return; 341 } 342 343 throw new Error(`Unknown command: ${command}`); 344} 345 346main().catch((error) => { 347 console.error(`Error: ${error?.message || error}`); 348 process.exitCode = 1; 349});