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
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});