@ascorbic/pds 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,6 +8,9 @@ import { spawn } from "node:child_process";
8
8
  import { experimental_patchConfig, experimental_readRawConfig } from "wrangler";
9
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
+ import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver";
12
+ import { check, didDocument, getPdsEndpoint } from "@atproto/common-web";
13
+ import pc from "picocolors";
11
14
 
12
15
  //#region src/cli/utils/wrangler.ts
13
16
  /**
@@ -37,6 +40,21 @@ function getVars() {
37
40
  return rawConfig.vars || {};
38
41
  }
39
42
  /**
43
+ * Get current worker name from wrangler config
44
+ */
45
+ function getWorkerName() {
46
+ const { rawConfig } = experimental_readRawConfig({});
47
+ return rawConfig.name;
48
+ }
49
+ /**
50
+ * Set worker name in wrangler config
51
+ */
52
+ function setWorkerName(name) {
53
+ const { configPath } = experimental_readRawConfig({});
54
+ if (!configPath) throw new Error("No wrangler config found");
55
+ experimental_patchConfig(configPath, { name });
56
+ }
57
+ /**
40
58
  * Set a secret using wrangler secret put
41
59
  */
42
60
  async function setSecret(name, value) {
@@ -171,24 +189,30 @@ async function hashPassword(password) {
171
189
  return bcrypt.hash(password, 10);
172
190
  }
173
191
  /**
174
- * Prompt for password with confirmation
192
+ * Prompt for password with confirmation (max 3 attempts)
175
193
  */
176
- async function promptPassword() {
177
- const password = await p.password({ message: "Enter password:" });
178
- if (p.isCancel(password)) {
179
- p.cancel("Cancelled");
180
- process.exit(0);
181
- }
182
- const confirm = await p.password({ message: "Confirm password:" });
183
- if (p.isCancel(confirm)) {
184
- p.cancel("Cancelled");
185
- process.exit(0);
186
- }
187
- if (password !== confirm) {
188
- p.log.error("Passwords do not match");
189
- process.exit(1);
194
+ async function promptPassword(handle) {
195
+ const message = handle ? `Choose a password for @${handle}:` : "Enter password:";
196
+ const MAX_ATTEMPTS = 3;
197
+ let attempts = 0;
198
+ while (attempts < MAX_ATTEMPTS) {
199
+ attempts++;
200
+ const password = await p.password({ message });
201
+ if (p.isCancel(password)) {
202
+ p.cancel("Cancelled");
203
+ process.exit(0);
204
+ }
205
+ const confirm = await p.password({ message: "Confirm password:" });
206
+ if (p.isCancel(confirm)) {
207
+ p.cancel("Cancelled");
208
+ process.exit(0);
209
+ }
210
+ if (password === confirm) return password;
211
+ p.log.error("Passwords do not match. Try again.");
190
212
  }
191
- return password;
213
+ p.log.error("Too many failed attempts.");
214
+ p.cancel("Password setup cancelled");
215
+ process.exit(1);
192
216
  }
193
217
  /**
194
218
  * Set a secret value, either locally (.dev.vars) or via wrangler
@@ -315,12 +339,124 @@ const secretCommand = defineCommand({
315
339
  }
316
340
  });
317
341
 
342
+ //#endregion
343
+ //#region src/cli/utils/handle-resolver.ts
344
+ /**
345
+ * Utilities for resolving AT Protocol handles to DIDs
346
+ */
347
+ const resolver = new AtprotoDohHandleResolver({ dohEndpoint: "https://cloudflare-dns.com/dns-query" });
348
+ /**
349
+ * Resolve a handle to a DID using the AT Protocol handle resolution methods.
350
+ * Uses DNS-over-HTTPS via Cloudflare for DNS resolution.
351
+ */
352
+ async function resolveHandleToDid(handle) {
353
+ try {
354
+ return await resolver.resolve(handle, { signal: AbortSignal.timeout(1e4) });
355
+ } catch (err) {
356
+ return null;
357
+ }
358
+ }
359
+
360
+ //#endregion
361
+ //#region src/did-resolver.ts
362
+ /**
363
+ * DID resolution for Cloudflare Workers
364
+ *
365
+ * We can't use @atproto/identity directly because it uses `redirect: "error"`
366
+ * which Cloudflare Workers doesn't support. This is a simple implementation
367
+ * that's compatible with Workers.
368
+ */
369
+ const PLC_DIRECTORY = "https://plc.directory";
370
+ const TIMEOUT_MS = 3e3;
371
+ var DidResolver = class {
372
+ plcUrl;
373
+ timeout;
374
+ cache;
375
+ constructor(opts = {}) {
376
+ this.plcUrl = opts.plcUrl ?? PLC_DIRECTORY;
377
+ this.timeout = opts.timeout ?? TIMEOUT_MS;
378
+ this.cache = opts.didCache;
379
+ }
380
+ async resolve(did) {
381
+ if (this.cache) {
382
+ const cached = await this.cache.checkCache(did);
383
+ if (cached && !cached.expired) {
384
+ if (cached.stale) this.cache.refreshCache(did, () => this.resolveNoCache(did), cached);
385
+ return cached.doc;
386
+ }
387
+ }
388
+ const doc = await this.resolveNoCache(did);
389
+ if (doc && this.cache) await this.cache.cacheDid(did, doc);
390
+ else if (!doc && this.cache) await this.cache.clearEntry(did);
391
+ return doc;
392
+ }
393
+ async resolveNoCache(did) {
394
+ if (did.startsWith("did:web:")) return this.resolveDidWeb(did);
395
+ if (did.startsWith("did:plc:")) return this.resolveDidPlc(did);
396
+ throw new Error(`Unsupported DID method: ${did}`);
397
+ }
398
+ async resolveDidWeb(did) {
399
+ const parts = did.split(":").slice(2);
400
+ if (parts.length === 0) throw new Error(`Invalid did:web format: ${did}`);
401
+ if (parts.length > 1) throw new Error(`Unsupported did:web with path: ${did}`);
402
+ const domain = decodeURIComponent(parts[0]);
403
+ const url = new URL(`https://${domain}/.well-known/did.json`);
404
+ if (url.hostname === "localhost") url.protocol = "http:";
405
+ const controller = new AbortController();
406
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
407
+ try {
408
+ const res = await fetch(url.toString(), {
409
+ signal: controller.signal,
410
+ redirect: "manual",
411
+ headers: { accept: "application/did+ld+json,application/json" }
412
+ });
413
+ if (res.status >= 300 && res.status < 400) return null;
414
+ if (!res.ok) return null;
415
+ const doc = await res.json();
416
+ return this.validateDidDoc(did, doc);
417
+ } finally {
418
+ clearTimeout(timeoutId);
419
+ }
420
+ }
421
+ async resolveDidPlc(did) {
422
+ const url = new URL(`/${encodeURIComponent(did)}`, this.plcUrl);
423
+ const controller = new AbortController();
424
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
425
+ try {
426
+ const res = await fetch(url.toString(), {
427
+ signal: controller.signal,
428
+ redirect: "manual",
429
+ headers: { accept: "application/did+ld+json,application/json" }
430
+ });
431
+ if (res.status >= 300 && res.status < 400) return null;
432
+ if (res.status === 404) return null;
433
+ if (!res.ok) throw new Error(`PLC directory error: ${res.status} ${res.statusText}`);
434
+ const doc = await res.json();
435
+ return this.validateDidDoc(did, doc);
436
+ } finally {
437
+ clearTimeout(timeoutId);
438
+ }
439
+ }
440
+ validateDidDoc(did, doc) {
441
+ if (!check.is(doc, didDocument)) return null;
442
+ if (doc.id !== did) return null;
443
+ return doc;
444
+ }
445
+ };
446
+
318
447
  //#endregion
319
448
  //#region src/cli/commands/init.ts
320
449
  /**
321
450
  * Interactive PDS setup wizard
322
451
  */
323
452
  /**
453
+ * Slugify a handle to create a worker name
454
+ * e.g., "example.com" -> "example-com-pds"
455
+ */
456
+ function slugifyHandle(handle) {
457
+ return handle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-pds";
458
+ }
459
+ /**
324
460
  * Run wrangler types to regenerate TypeScript types
325
461
  */
326
462
  function runWranglerTypes() {
@@ -354,48 +490,233 @@ const initCommand = defineCommand({
354
490
  default: false
355
491
  } },
356
492
  async run({ args }) {
357
- p.intro("PDS Setup Wizard");
493
+ p.intro("🦋 PDS Setup");
358
494
  const isProduction = args.production;
359
495
  if (isProduction) p.log.info("Production mode: secrets will be deployed via wrangler");
496
+ else p.log.info("Let's set up your new home in the Atmosphere!");
360
497
  const wranglerVars = getVars();
361
498
  const devVars = readDevVars();
362
499
  const currentVars = {
363
500
  ...devVars,
364
501
  ...wranglerVars
365
502
  };
366
- const hostname = await p.text({
367
- message: "PDS hostname:",
368
- placeholder: "pds.example.com",
369
- initialValue: currentVars.PDS_HOSTNAME || "",
370
- validate: (v) => !v ? "Hostname is required" : void 0
503
+ const isMigrating = await p.confirm({
504
+ message: "Are you migrating an existing Bluesky account? 🦋",
505
+ initialValue: false
371
506
  });
372
- if (p.isCancel(hostname)) {
373
- p.cancel("Cancelled");
507
+ if (p.isCancel(isMigrating)) {
508
+ p.cancel("Setup cancelled");
374
509
  process.exit(0);
375
510
  }
376
- const handle = await p.text({
377
- message: "Account handle:",
378
- placeholder: "alice." + hostname,
379
- initialValue: currentVars.HANDLE || "",
380
- validate: (v) => !v ? "Handle is required" : void 0
381
- });
382
- if (p.isCancel(handle)) {
383
- p.cancel("Cancelled");
384
- process.exit(0);
385
- }
386
- const didDefault = "did:web:" + hostname;
387
- const did = await p.text({
388
- message: "Account DID:",
389
- placeholder: didDefault,
390
- initialValue: currentVars.DID || didDefault,
391
- validate: (v) => {
392
- if (!v) return "DID is required";
393
- if (!v.startsWith("did:")) return "DID must start with did:";
511
+ let did;
512
+ let handle;
513
+ let hostname;
514
+ let workerName;
515
+ let initialActive;
516
+ const currentWorkerName = getWorkerName();
517
+ if (isMigrating) {
518
+ p.log.info("Time to pack your bags! 🧳");
519
+ p.log.info("Your account will be inactive until you've moved your data over.");
520
+ let hostedDomains = [
521
+ ".bsky.social",
522
+ ".bsky.network",
523
+ ".bsky.team"
524
+ ];
525
+ const isHostedHandle = (h) => hostedDomains.some((domain) => h.endsWith(domain));
526
+ let resolvedDid = null;
527
+ let existingHandle = null;
528
+ let attempts = 0;
529
+ const MAX_ATTEMPTS = 3;
530
+ while (!resolvedDid && attempts < MAX_ATTEMPTS) {
531
+ attempts++;
532
+ const currentHandle = await p.text({
533
+ message: "Your current Bluesky/ATProto handle:",
534
+ placeholder: "example.bsky.social",
535
+ validate: (v) => !v ? "Handle is required" : void 0
536
+ });
537
+ if (p.isCancel(currentHandle)) {
538
+ p.cancel("Cancelled");
539
+ process.exit(0);
540
+ }
541
+ existingHandle = currentHandle;
542
+ const spinner$1 = p.spinner();
543
+ spinner$1.start("Finding you in the Atmosphere...");
544
+ resolvedDid = await resolveHandleToDid(currentHandle);
545
+ if (!resolvedDid) {
546
+ spinner$1.stop("Not found");
547
+ p.log.error(`Failed to resolve handle "${currentHandle}"`);
548
+ const action = await p.select({
549
+ message: "What would you like to do?",
550
+ options: [{
551
+ value: "retry",
552
+ label: "Try a different handle"
553
+ }, {
554
+ value: "manual",
555
+ label: "Enter DID manually"
556
+ }]
557
+ });
558
+ if (p.isCancel(action)) {
559
+ p.cancel("Cancelled");
560
+ process.exit(0);
561
+ }
562
+ if (action === "manual") {
563
+ const manualDid = await p.text({
564
+ message: "Enter your DID:",
565
+ placeholder: "did:plc:...",
566
+ validate: (v) => {
567
+ if (!v) return "DID is required";
568
+ if (!v.startsWith("did:")) return "DID must start with did:";
569
+ }
570
+ });
571
+ if (p.isCancel(manualDid)) {
572
+ p.cancel("Cancelled");
573
+ process.exit(0);
574
+ }
575
+ resolvedDid = manualDid;
576
+ }
577
+ } else {
578
+ try {
579
+ const pdsService = (await new DidResolver().resolve(resolvedDid))?.service?.find((s) => s.type === "AtprotoPersonalDataServer" || s.id === "#atproto_pds");
580
+ if (pdsService?.serviceEndpoint) {
581
+ const describeRes = await fetch(`${pdsService.serviceEndpoint}/xrpc/com.atproto.server.describeServer`);
582
+ if (describeRes.ok) {
583
+ const desc = await describeRes.json();
584
+ if (desc.availableUserDomains?.length) hostedDomains = desc.availableUserDomains.map((d) => d.startsWith(".") ? d : `.${d}`);
585
+ }
586
+ }
587
+ } catch {}
588
+ spinner$1.stop(`Found you! ${resolvedDid}`);
589
+ if (isHostedHandle(existingHandle)) {
590
+ const theirDomain = hostedDomains.find((d) => existingHandle.endsWith(d));
591
+ const domainExample = theirDomain ? `*${theirDomain}` : "*.bsky.social";
592
+ p.log.warn(`You'll need a custom domain for your new handle (not ${domainExample}). You can set this up after transferring your data.`);
593
+ }
594
+ if (attempts >= MAX_ATTEMPTS) {
595
+ p.log.error("Unable to resolve handle after 3 attempts.");
596
+ p.log.info("");
597
+ p.log.info("You can:");
598
+ p.log.info(" 1. Double-check your handle spelling");
599
+ p.log.info(" 2. Provide your DID directly if you know it");
600
+ p.log.info(" 3. Run 'pds init' again when ready");
601
+ p.outro("Initialization cancelled.");
602
+ process.exit(1);
603
+ }
604
+ }
394
605
  }
395
- });
396
- if (p.isCancel(did)) {
397
- p.cancel("Cancelled");
398
- process.exit(0);
606
+ did = resolvedDid;
607
+ const defaultHandle = existingHandle && !isHostedHandle(existingHandle) ? existingHandle : currentVars.HANDLE || "";
608
+ handle = await p.text({
609
+ message: "New account handle (must be a domain you control):",
610
+ placeholder: "example.com",
611
+ initialValue: defaultHandle,
612
+ validate: (v) => {
613
+ if (!v) return "Handle is required";
614
+ if (isHostedHandle(v)) return "You need a custom domain - hosted handles like *.bsky.social won't work";
615
+ }
616
+ });
617
+ if (p.isCancel(handle)) {
618
+ p.cancel("Cancelled");
619
+ process.exit(0);
620
+ }
621
+ hostname = await p.text({
622
+ message: "Domain where you'll deploy your PDS:",
623
+ placeholder: handle,
624
+ initialValue: currentVars.PDS_HOSTNAME || handle,
625
+ validate: (v) => !v ? "Hostname is required" : void 0
626
+ });
627
+ if (p.isCancel(hostname)) {
628
+ p.cancel("Cancelled");
629
+ process.exit(0);
630
+ }
631
+ const defaultWorkerName = currentWorkerName || slugifyHandle(handle);
632
+ workerName = await p.text({
633
+ message: "Cloudflare Worker name:",
634
+ placeholder: defaultWorkerName,
635
+ initialValue: defaultWorkerName,
636
+ validate: (v) => {
637
+ if (!v) return "Worker name is required";
638
+ if (!/^[a-z0-9-]+$/.test(v)) return "Worker name can only contain lowercase letters, numbers, and hyphens";
639
+ }
640
+ });
641
+ if (p.isCancel(workerName)) {
642
+ p.cancel("Cancelled");
643
+ process.exit(0);
644
+ }
645
+ initialActive = "false";
646
+ } else {
647
+ p.log.info("A fresh start in the Atmosphere! ✨");
648
+ hostname = await p.text({
649
+ message: "Domain where you'll deploy your PDS:",
650
+ placeholder: "pds.example.com",
651
+ initialValue: currentVars.PDS_HOSTNAME || "",
652
+ validate: (v) => !v ? "Hostname is required" : void 0
653
+ });
654
+ if (p.isCancel(hostname)) {
655
+ p.cancel("Cancelled");
656
+ process.exit(0);
657
+ }
658
+ handle = await p.text({
659
+ message: "Account handle:",
660
+ placeholder: hostname,
661
+ initialValue: currentVars.HANDLE || hostname,
662
+ validate: (v) => !v ? "Handle is required" : void 0
663
+ });
664
+ if (p.isCancel(handle)) {
665
+ p.cancel("Cancelled");
666
+ process.exit(0);
667
+ }
668
+ const didDefault = "did:web:" + hostname;
669
+ did = await p.text({
670
+ message: "Account DID:",
671
+ placeholder: didDefault,
672
+ initialValue: currentVars.DID || didDefault,
673
+ validate: (v) => {
674
+ if (!v) return "DID is required";
675
+ if (!v.startsWith("did:")) return "DID must start with did:";
676
+ }
677
+ });
678
+ if (p.isCancel(did)) {
679
+ p.cancel("Cancelled");
680
+ process.exit(0);
681
+ }
682
+ const defaultWorkerName = currentWorkerName || slugifyHandle(handle);
683
+ workerName = await p.text({
684
+ message: "Cloudflare Worker name:",
685
+ placeholder: defaultWorkerName,
686
+ initialValue: defaultWorkerName,
687
+ validate: (v) => {
688
+ if (!v) return "Worker name is required";
689
+ if (!/^[a-z0-9-]+$/.test(v)) return "Worker name can only contain lowercase letters, numbers, and hyphens";
690
+ }
691
+ });
692
+ if (p.isCancel(workerName)) {
693
+ p.cancel("Cancelled");
694
+ process.exit(0);
695
+ }
696
+ initialActive = "true";
697
+ if (handle === hostname) p.note([
698
+ "Your handle matches your PDS hostname, so your PDS will",
699
+ "automatically handle domain verification for you!",
700
+ "",
701
+ "For did:web, your PDS serves the DID document at:",
702
+ ` https://${hostname}/.well-known/did.json`,
703
+ "",
704
+ "For handle verification, it serves:",
705
+ ` https://${hostname}/.well-known/atproto-did`,
706
+ "",
707
+ "No additional DNS or hosting setup needed. Easy! 🎉"
708
+ ].join("\n"), "Identity Setup 🪪");
709
+ else p.note([
710
+ "For did:web, your PDS will serve the DID document at:",
711
+ ` https://${hostname}/.well-known/did.json`,
712
+ "",
713
+ "To verify your handle, create a DNS TXT record:",
714
+ ` _atproto.${handle} TXT "did=${did}"`,
715
+ "",
716
+ "Or serve a file at:",
717
+ ` https://${handle}/.well-known/atproto-did`,
718
+ ` containing: ${did}`
719
+ ].join("\n"), "Identity Setup 🪪");
399
720
  }
400
721
  const spinner = p.spinner();
401
722
  let authToken;
@@ -424,14 +745,14 @@ const initCommand = defineCommand({
424
745
  return secret;
425
746
  });
426
747
  passwordHash = await getOrGenerateSecret("PASSWORD_HASH", devVars, async () => {
427
- const password = await promptPassword();
748
+ const password = await promptPassword(handle);
428
749
  spinner.start("Hashing password...");
429
750
  const hash = await hashPassword(password);
430
751
  spinner.stop("Password hashed");
431
752
  return hash;
432
753
  });
433
754
  } else {
434
- const password = await promptPassword();
755
+ const password = await promptPassword(handle);
435
756
  spinner.start("Hashing password...");
436
757
  passwordHash = await hashPassword(password);
437
758
  spinner.stop("Password hashed");
@@ -448,11 +769,13 @@ const initCommand = defineCommand({
448
769
  spinner.stop("Signing keypair generated");
449
770
  }
450
771
  spinner.start("Updating wrangler.jsonc...");
772
+ setWorkerName(workerName);
451
773
  setVars({
452
774
  PDS_HOSTNAME: hostname,
453
775
  DID: did,
454
776
  HANDLE: handle,
455
- SIGNING_KEY_PUBLIC: signingKeyPublic
777
+ SIGNING_KEY_PUBLIC: signingKeyPublic,
778
+ INITIAL_ACTIVE: initialActive
456
779
  });
457
780
  spinner.stop("wrangler.jsonc updated");
458
781
  const local = !isProduction;
@@ -471,20 +794,49 @@ const initCommand = defineCommand({
471
794
  spinner.stop("Failed to generate types (wrangler types)");
472
795
  }
473
796
  p.note([
474
- "Configuration summary:",
475
- "",
476
- " PDS_HOSTNAME: " + hostname,
797
+ " Worker name: " + workerName,
798
+ " PDS hostname: " + hostname,
477
799
  " DID: " + did,
478
- " HANDLE: " + handle,
479
- " SIGNING_KEY_PUBLIC: " + signingKeyPublic,
800
+ " Handle: " + handle,
801
+ " Public signing key: " + signingKeyPublic.slice(0, 20) + "...",
480
802
  "",
481
- isProduction ? "Secrets deployed to Cloudflare" : "Secrets saved to .dev.vars",
803
+ isProduction ? "Secrets deployed to Cloudflare ☁️" : "Secrets saved to .dev.vars",
482
804
  "",
483
805
  "Auth token (save this!):",
484
806
  " " + authToken
485
- ].join("\n"), "Setup Complete");
486
- if (isProduction) p.outro("Your PDS is configured! Run 'wrangler deploy' to deploy.");
487
- else p.outro("Your PDS is configured! Run 'pnpm dev' to start locally.");
807
+ ].join("\n"), "Your New Home 🏠");
808
+ let deployedSecrets = isProduction;
809
+ if (!isProduction) {
810
+ const deployNow = await p.confirm({
811
+ message: "Push secrets to Cloudflare now?",
812
+ initialValue: false
813
+ });
814
+ if (!p.isCancel(deployNow) && deployNow) {
815
+ spinner.start("Deploying secrets to Cloudflare...");
816
+ await setSecretValue("AUTH_TOKEN", authToken, false);
817
+ await setSecretValue("SIGNING_KEY", signingKey, false);
818
+ await setSecretValue("JWT_SECRET", jwtSecret, false);
819
+ await setSecretValue("PASSWORD_HASH", passwordHash, false);
820
+ spinner.stop("Secrets deployed to Cloudflare");
821
+ deployedSecrets = true;
822
+ }
823
+ }
824
+ if (isMigrating) p.note([
825
+ deployedSecrets ? "Deploy your worker and run the migration:" : "Push secrets, deploy, and run the migration:",
826
+ "",
827
+ ...deployedSecrets ? [] : [" pnpm pds init --production", ""],
828
+ " wrangler deploy",
829
+ " pnpm pds migrate",
830
+ "",
831
+ "To test locally first:",
832
+ " pnpm dev # in one terminal",
833
+ " pnpm pds migrate --dev # in another",
834
+ "",
835
+ "Then update your identity and flip the switch! 🦋",
836
+ " https://atproto.com/guides/account-migration"
837
+ ].join("\n"), "Next Steps 🧳");
838
+ if (deployedSecrets) p.outro("Run 'wrangler deploy' to launch your PDS! 🚀");
839
+ else p.outro("Run 'pnpm dev' to start your PDS locally! 🦋");
488
840
  }
489
841
  });
490
842
  /**
@@ -505,6 +857,757 @@ async function getOrGenerateSecret(name, devVars, generate) {
505
857
  return generate();
506
858
  }
507
859
 
860
+ //#endregion
861
+ //#region src/cli/utils/pds-client.ts
862
+ var PDSClientError = class extends Error {
863
+ constructor(status, error, message) {
864
+ super(message);
865
+ this.status = status;
866
+ this.error = error;
867
+ this.name = "PDSClientError";
868
+ }
869
+ };
870
+ var PDSClient = class {
871
+ authToken;
872
+ constructor(baseUrl, authToken) {
873
+ this.baseUrl = baseUrl;
874
+ this.authToken = authToken;
875
+ }
876
+ /**
877
+ * Set the auth token for subsequent requests
878
+ */
879
+ setAuthToken(token) {
880
+ this.authToken = token;
881
+ }
882
+ /**
883
+ * Make an XRPC request
884
+ */
885
+ async xrpc(method, endpoint, options = {}) {
886
+ const url = new URL(`/xrpc/${endpoint}`, this.baseUrl);
887
+ if (options.params) for (const [key, value] of Object.entries(options.params)) url.searchParams.set(key, value);
888
+ const headers = {};
889
+ if (options.auth && this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
890
+ if (options.contentType) headers["Content-Type"] = options.contentType;
891
+ else if (options.body && !(options.body instanceof Uint8Array)) headers["Content-Type"] = "application/json";
892
+ const res = await fetch(url.toString(), {
893
+ method,
894
+ headers,
895
+ body: options.body ? options.body instanceof Uint8Array ? options.body : JSON.stringify(options.body) : void 0
896
+ });
897
+ if (!res.ok) {
898
+ const errorBody = await res.json().catch(() => ({}));
899
+ throw new PDSClientError(res.status, errorBody.error ?? "Unknown", errorBody.message ?? `Request failed: ${res.status}`);
900
+ }
901
+ if ((res.headers.get("content-type") ?? "").includes("application/json")) return res.json();
902
+ return {};
903
+ }
904
+ /**
905
+ * Make a raw request that returns bytes
906
+ */
907
+ async xrpcBytes(method, endpoint, options = {}) {
908
+ const url = new URL(`/xrpc/${endpoint}`, this.baseUrl);
909
+ if (options.params) for (const [key, value] of Object.entries(options.params)) url.searchParams.set(key, value);
910
+ const headers = {};
911
+ if (options.auth && this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
912
+ if (options.contentType) headers["Content-Type"] = options.contentType;
913
+ const res = await fetch(url.toString(), {
914
+ method,
915
+ headers,
916
+ body: options.body
917
+ });
918
+ if (!res.ok) {
919
+ const errorBody = await res.json().catch(() => ({}));
920
+ throw new PDSClientError(res.status, errorBody.error ?? "Unknown", errorBody.message ?? `Request failed: ${res.status}`);
921
+ }
922
+ return {
923
+ bytes: new Uint8Array(await res.arrayBuffer()),
924
+ mimeType: res.headers.get("content-type") ?? "application/octet-stream"
925
+ };
926
+ }
927
+ /**
928
+ * Create a session with identifier and password
929
+ */
930
+ async createSession(identifier, password) {
931
+ return this.xrpc("POST", "com.atproto.server.createSession", { body: {
932
+ identifier,
933
+ password
934
+ } });
935
+ }
936
+ /**
937
+ * Get repository description including collections
938
+ */
939
+ async describeRepo(did) {
940
+ return this.xrpc("GET", "com.atproto.repo.describeRepo", { params: { repo: did } });
941
+ }
942
+ /**
943
+ * Get profile stats from AppView (posts, follows, followers counts)
944
+ */
945
+ async getProfileStats(did) {
946
+ try {
947
+ const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`);
948
+ if (!res.ok) return null;
949
+ const profile = await res.json();
950
+ return {
951
+ postsCount: profile.postsCount ?? 0,
952
+ followsCount: profile.followsCount ?? 0,
953
+ followersCount: profile.followersCount ?? 0
954
+ };
955
+ } catch {
956
+ return null;
957
+ }
958
+ }
959
+ /**
960
+ * Export repository as CAR file
961
+ */
962
+ async getRepo(did) {
963
+ const { bytes } = await this.xrpcBytes("GET", "com.atproto.sync.getRepo", { params: { did } });
964
+ return bytes;
965
+ }
966
+ /**
967
+ * Get a blob by CID
968
+ */
969
+ async getBlob(did, cid) {
970
+ return this.xrpcBytes("GET", "com.atproto.sync.getBlob", { params: {
971
+ did,
972
+ cid
973
+ } });
974
+ }
975
+ /**
976
+ * List blobs in repository
977
+ */
978
+ async listBlobs(did, cursor) {
979
+ const params = { did };
980
+ if (cursor) params.cursor = cursor;
981
+ return this.xrpc("GET", "com.atproto.sync.listBlobs", { params });
982
+ }
983
+ /**
984
+ * Get account status including migration progress
985
+ */
986
+ async getAccountStatus() {
987
+ return this.xrpc("GET", "com.atproto.server.getAccountStatus", { auth: true });
988
+ }
989
+ /**
990
+ * Import repository from CAR file
991
+ */
992
+ async importRepo(carBytes) {
993
+ return this.xrpc("POST", "com.atproto.repo.importRepo", {
994
+ body: carBytes,
995
+ contentType: "application/vnd.ipld.car",
996
+ auth: true
997
+ });
998
+ }
999
+ /**
1000
+ * List blobs that are missing (referenced but not imported)
1001
+ */
1002
+ async listMissingBlobs(limit, cursor) {
1003
+ const params = {};
1004
+ if (limit) params.limit = String(limit);
1005
+ if (cursor) params.cursor = cursor;
1006
+ return this.xrpc("GET", "com.atproto.repo.listMissingBlobs", {
1007
+ params,
1008
+ auth: true
1009
+ });
1010
+ }
1011
+ /**
1012
+ * Upload a blob
1013
+ */
1014
+ async uploadBlob(bytes, mimeType) {
1015
+ return (await this.xrpc("POST", "com.atproto.repo.uploadBlob", {
1016
+ body: bytes,
1017
+ contentType: mimeType,
1018
+ auth: true
1019
+ })).blob;
1020
+ }
1021
+ /**
1022
+ * Reset migration state (only works on deactivated accounts)
1023
+ */
1024
+ async resetMigration() {
1025
+ return this.xrpc("POST", "gg.mk.experimental.resetMigration", { auth: true });
1026
+ }
1027
+ /**
1028
+ * Activate account to enable writes
1029
+ */
1030
+ async activateAccount() {
1031
+ await this.xrpc("POST", "com.atproto.server.activateAccount", { auth: true });
1032
+ }
1033
+ /**
1034
+ * Deactivate account to disable writes
1035
+ */
1036
+ async deactivateAccount() {
1037
+ await this.xrpc("POST", "com.atproto.server.deactivateAccount", { auth: true });
1038
+ }
1039
+ /**
1040
+ * Check if the PDS is reachable
1041
+ */
1042
+ async healthCheck() {
1043
+ try {
1044
+ return (await fetch(new URL("/xrpc/_health", this.baseUrl).toString())).ok;
1045
+ } catch {
1046
+ return false;
1047
+ }
1048
+ }
1049
+ };
1050
+
1051
+ //#endregion
1052
+ //#region src/cli/utils/cli-helpers.ts
1053
+ /**
1054
+ * Shared CLI utilities for PDS commands
1055
+ */
1056
+ /**
1057
+ * Get target PDS URL based on mode
1058
+ */
1059
+ function getTargetUrl(isDev, pdsHostname) {
1060
+ const LOCAL_PDS_URL = "http://localhost:5173";
1061
+ if (isDev) return LOCAL_PDS_URL;
1062
+ if (!pdsHostname) throw new Error("PDS_HOSTNAME not configured in wrangler.jsonc");
1063
+ return `https://${pdsHostname}`;
1064
+ }
1065
+ /**
1066
+ * Extract domain from URL
1067
+ */
1068
+ function getDomain(url) {
1069
+ try {
1070
+ return new URL(url).hostname;
1071
+ } catch {
1072
+ return url;
1073
+ }
1074
+ }
1075
+
1076
+ //#endregion
1077
+ //#region src/cli/commands/migrate.ts
1078
+ function detectPackageManager() {
1079
+ const userAgent = process.env.npm_config_user_agent || "";
1080
+ if (userAgent.startsWith("yarn")) return "yarn";
1081
+ if (userAgent.startsWith("pnpm")) return "pnpm";
1082
+ if (userAgent.startsWith("bun")) return "bun";
1083
+ return "npm";
1084
+ }
1085
+ const brightNote$1 = (lines) => lines.map((l) => `\x1b[0m${l}`).join("\n");
1086
+ const bold$1 = (text) => pc.bold(text);
1087
+ /**
1088
+ * Format number with commas
1089
+ */
1090
+ function formatNumber(n) {
1091
+ return n.toLocaleString();
1092
+ }
1093
+ /**
1094
+ * Format bytes to human-readable size
1095
+ */
1096
+ function formatBytes(bytes) {
1097
+ if (bytes < 1024) return `${bytes} B`;
1098
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1099
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1100
+ }
1101
+ const migrateCommand = defineCommand({
1102
+ meta: {
1103
+ name: "migrate",
1104
+ description: "Migrate account from source PDS to your new PDS"
1105
+ },
1106
+ args: {
1107
+ clean: {
1108
+ type: "boolean",
1109
+ description: "Reset migration and start fresh",
1110
+ default: false
1111
+ },
1112
+ dev: {
1113
+ type: "boolean",
1114
+ description: "Target local development server instead of production",
1115
+ default: false
1116
+ }
1117
+ },
1118
+ async run({ args }) {
1119
+ const pm = detectPackageManager();
1120
+ const isDev = args.dev;
1121
+ const vars = getVars();
1122
+ let targetUrl;
1123
+ try {
1124
+ targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME);
1125
+ } catch (err) {
1126
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
1127
+ p.log.info("Run 'pds init' first to configure your PDS.");
1128
+ process.exit(1);
1129
+ }
1130
+ const targetDomain = getDomain(targetUrl);
1131
+ p.intro("🦋 PDS Migration");
1132
+ const spinner = p.spinner();
1133
+ spinner.start(`Checking PDS at ${targetDomain}...`);
1134
+ const targetClient = new PDSClient(targetUrl);
1135
+ if (!await targetClient.healthCheck()) {
1136
+ spinner.stop(`PDS not responding at ${targetDomain}`);
1137
+ if (isDev) {
1138
+ p.log.error(`Your local PDS isn't running at ${targetUrl}`);
1139
+ p.log.info(`Start it with: ${pm} dev`);
1140
+ } else {
1141
+ p.log.error(`Your PDS isn't responding at ${targetUrl}`);
1142
+ p.log.info("Make sure your worker is deployed: wrangler deploy");
1143
+ p.log.info(`Or test locally first: ${pm} pds migrate --dev`);
1144
+ }
1145
+ p.outro("Migration cancelled.");
1146
+ process.exit(1);
1147
+ }
1148
+ spinner.stop(`Connected to ${targetDomain}`);
1149
+ const wranglerVars = getVars();
1150
+ const config = {
1151
+ ...readDevVars(),
1152
+ ...wranglerVars
1153
+ };
1154
+ const did = config.DID;
1155
+ const handle = config.HANDLE;
1156
+ const authToken = config.AUTH_TOKEN;
1157
+ if (!did) {
1158
+ p.log.error("No DID configured. Run 'pds init' first.");
1159
+ p.outro("Migration cancelled.");
1160
+ process.exit(1);
1161
+ }
1162
+ if (!authToken) {
1163
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
1164
+ p.outro("Migration cancelled.");
1165
+ process.exit(1);
1166
+ }
1167
+ targetClient.setAuthToken(authToken);
1168
+ spinner.start(`Looking up @${handle}...`);
1169
+ const didDoc = await new DidResolver().resolve(did);
1170
+ if (!didDoc) {
1171
+ spinner.stop("Failed to resolve DID");
1172
+ p.log.error(`Could not resolve DID: ${did}`);
1173
+ p.outro("Migration cancelled.");
1174
+ process.exit(1);
1175
+ }
1176
+ const sourcePdsUrl = getPdsEndpoint(didDoc);
1177
+ if (!sourcePdsUrl) {
1178
+ spinner.stop("No PDS found in DID document");
1179
+ p.log.error("Could not find PDS endpoint in DID document");
1180
+ p.outro("Migration cancelled.");
1181
+ process.exit(1);
1182
+ }
1183
+ const sourceDomain = getDomain(sourcePdsUrl);
1184
+ spinner.stop(`Found your account at ${sourceDomain}`);
1185
+ spinner.start("Checking account status...");
1186
+ let status;
1187
+ try {
1188
+ status = await targetClient.getAccountStatus();
1189
+ } catch (err) {
1190
+ spinner.stop("Failed to get account status");
1191
+ p.log.error(err instanceof Error ? err.message : "Could not get account status");
1192
+ p.outro("Migration cancelled.");
1193
+ process.exit(1);
1194
+ }
1195
+ spinner.stop("Account status retrieved");
1196
+ if (args.clean) {
1197
+ if (status.active) {
1198
+ p.log.error("Cannot reset: account is active");
1199
+ p.log.info("The --clean flag only works on deactivated accounts.");
1200
+ p.log.info("Your account is already live in the Atmosphere.");
1201
+ p.log.info("");
1202
+ p.log.info("If you need to re-import, first deactivate:");
1203
+ p.log.info(" pnpm pds deactivate");
1204
+ p.outro("Migration cancelled.");
1205
+ process.exit(1);
1206
+ }
1207
+ p.note(brightNote$1([
1208
+ bold$1("This will permanently delete from your new PDS:"),
1209
+ "",
1210
+ ` • ${formatNumber(status.repoBlocks)} repository blocks`,
1211
+ ` • ${formatNumber(status.importedBlobs)} imported images`,
1212
+ " • All blob tracking data",
1213
+ "",
1214
+ bold$1(`Your data on ${sourceDomain} is NOT affected.`),
1215
+ "You'll need to re-import everything."
1216
+ ]), "⚠️ Reset Migration Data");
1217
+ const confirmReset = await p.confirm({
1218
+ message: "Are you sure you want to delete this data?",
1219
+ initialValue: false
1220
+ });
1221
+ if (p.isCancel(confirmReset) || !confirmReset) {
1222
+ p.cancel("Keeping your data.");
1223
+ process.exit(0);
1224
+ }
1225
+ spinner.start("Resetting migration state...");
1226
+ try {
1227
+ const result = await targetClient.resetMigration();
1228
+ spinner.stop(`Deleted ${formatNumber(result.blocksDeleted)} blocks, ${formatNumber(result.blobsCleared)} blobs`);
1229
+ } catch (err) {
1230
+ spinner.stop("Reset failed");
1231
+ p.log.error(err instanceof Error ? err.message : "Could not reset migration");
1232
+ p.outro("Migration cancelled.");
1233
+ process.exit(1);
1234
+ }
1235
+ p.log.success("Clean slate! Starting fresh migration...");
1236
+ status = await targetClient.getAccountStatus();
1237
+ }
1238
+ if (status.active) {
1239
+ p.log.warn("Your account is already active in the Atmosphere!");
1240
+ p.log.info("No migration needed - your PDS is live.");
1241
+ p.outro("All good! 🦋");
1242
+ return;
1243
+ }
1244
+ spinner.start(`Fetching your account details from ${sourceDomain}...`);
1245
+ const sourceClient = new PDSClient(sourcePdsUrl);
1246
+ try {
1247
+ await sourceClient.describeRepo(did);
1248
+ } catch (err) {
1249
+ spinner.stop("Failed to fetch account details");
1250
+ p.log.error(err instanceof Error ? err.message : "Could not fetch account details from source PDS");
1251
+ p.outro("Migration cancelled.");
1252
+ process.exit(1);
1253
+ }
1254
+ const profileStats = await sourceClient.getProfileStats(did);
1255
+ spinner.stop("Account details fetched");
1256
+ const needsRepoImport = status.repoBlocks === 0 || status.indexedRecords === 0 && status.expectedBlobs === 0;
1257
+ const needsBlobSync = status.expectedBlobs - status.importedBlobs > 0 || needsRepoImport;
1258
+ if (!needsRepoImport && needsBlobSync) {
1259
+ p.log.info("Welcome back!");
1260
+ p.log.info("Looks like you started packing earlier. Let's pick up where we left off.");
1261
+ p.note([
1262
+ `@${handle} (${did.slice(0, 20)}...)`,
1263
+ "",
1264
+ "✓ Repository imported",
1265
+ `◐ Images: ${formatNumber(status.importedBlobs)}/${formatNumber(status.expectedBlobs)} transferred`
1266
+ ].join("\n"), "Migration Progress");
1267
+ const continueTransfer = await p.confirm({
1268
+ message: "Continue transferring images?",
1269
+ initialValue: true
1270
+ });
1271
+ if (p.isCancel(continueTransfer) || !continueTransfer) {
1272
+ p.cancel("Migration paused.");
1273
+ process.exit(0);
1274
+ }
1275
+ } else if (needsRepoImport) {
1276
+ p.log.info("Time to pack your bags!");
1277
+ p.log.info("Let's move your Bluesky account to its new home in the Atmosphere.");
1278
+ const statsLines = profileStats ? [
1279
+ ` 📝 ${formatNumber(profileStats.postsCount)} posts`,
1280
+ ` 👥 ${formatNumber(profileStats.followsCount)} follows`,
1281
+ ` ...plus all your images, likes, and blocks`
1282
+ ] : [` 📝 Posts, follows, images, likes, and blocks`];
1283
+ p.note(brightNote$1([
1284
+ bold$1(`@${handle}`) + ` (${did.slice(0, 20)}...)`,
1285
+ "",
1286
+ `Currently at: ${sourceDomain}`,
1287
+ `Moving to: ${targetDomain}`,
1288
+ "",
1289
+ "What you're bringing:",
1290
+ ...statsLines
1291
+ ]), "Your Bluesky Account 🦋");
1292
+ p.log.info("This will copy your data - nothing is changed or deleted on Bluesky.");
1293
+ const proceed = await p.confirm({
1294
+ message: "Ready to start packing?",
1295
+ initialValue: true
1296
+ });
1297
+ if (p.isCancel(proceed) || !proceed) {
1298
+ p.cancel("Migration cancelled.");
1299
+ process.exit(0);
1300
+ }
1301
+ } else {
1302
+ p.log.success("All packed and moved! 🦋");
1303
+ showNextSteps(pm, sourceDomain);
1304
+ p.outro("Welcome to your new home in the Atmosphere! 🦋");
1305
+ return;
1306
+ }
1307
+ const isBlueskyPds = sourceDomain.endsWith(".bsky.network");
1308
+ const passwordPrompt = isBlueskyPds ? "Your current Bluesky password:" : `Your ${sourceDomain} password:`;
1309
+ const password = await p.password({ message: passwordPrompt });
1310
+ if (p.isCancel(password)) {
1311
+ p.cancel("Migration cancelled.");
1312
+ process.exit(0);
1313
+ }
1314
+ spinner.start(`Logging in to ${isBlueskyPds ? "Bluesky" : sourceDomain}...`);
1315
+ try {
1316
+ const session = await sourceClient.createSession(did, password);
1317
+ sourceClient.setAuthToken(session.accessJwt);
1318
+ spinner.stop("Authenticated successfully");
1319
+ } catch (err) {
1320
+ spinner.stop("Login failed");
1321
+ if (err instanceof PDSClientError) p.log.error(`Authentication failed: ${err.message}`);
1322
+ else p.log.error(err instanceof Error ? err.message : "Authentication failed");
1323
+ p.outro("Migration cancelled.");
1324
+ process.exit(1);
1325
+ }
1326
+ if (needsRepoImport) {
1327
+ spinner.start("Packing your repository...");
1328
+ let carBytes;
1329
+ try {
1330
+ carBytes = await sourceClient.getRepo(did);
1331
+ spinner.stop(`Downloaded ${formatBytes(carBytes.length)} from ${sourceDomain}`);
1332
+ } catch (err) {
1333
+ spinner.stop("Export failed");
1334
+ p.log.error(err instanceof Error ? err.message : "Could not export repository");
1335
+ p.outro("Migration cancelled.");
1336
+ process.exit(1);
1337
+ }
1338
+ spinner.start(`Unpacking at ${targetDomain}...`);
1339
+ try {
1340
+ await targetClient.importRepo(carBytes);
1341
+ spinner.stop("Repository imported");
1342
+ } catch (err) {
1343
+ spinner.stop("Import failed");
1344
+ p.log.error(err instanceof Error ? err.message : "Could not import repository");
1345
+ p.outro("Migration cancelled.");
1346
+ process.exit(1);
1347
+ }
1348
+ status = await targetClient.getAccountStatus();
1349
+ }
1350
+ if (status.expectedBlobs - status.importedBlobs > 0) {
1351
+ let synced = 0;
1352
+ let totalBlobs = 0;
1353
+ let cursor;
1354
+ let failedBlobs = [];
1355
+ const progressBar = (current, total) => {
1356
+ const width = 20;
1357
+ const ratio = total > 0 ? Math.min(1, current / total) : 0;
1358
+ const filled = Math.round(ratio * width);
1359
+ const empty = width - filled;
1360
+ return `${"█".repeat(filled)}${"░".repeat(empty)} ${current}/${total}`;
1361
+ };
1362
+ spinner.start("Counting images to transfer...");
1363
+ let countCursor;
1364
+ do {
1365
+ const page = await targetClient.listMissingBlobs(500, countCursor);
1366
+ totalBlobs += page.blobs.length;
1367
+ countCursor = page.cursor;
1368
+ } while (countCursor);
1369
+ spinner.message(`Transferring images ${progressBar(0, totalBlobs)}`);
1370
+ do {
1371
+ const page = await targetClient.listMissingBlobs(100, cursor);
1372
+ cursor = page.cursor;
1373
+ for (const blob of page.blobs) try {
1374
+ const { bytes, mimeType } = await sourceClient.getBlob(did, blob.cid);
1375
+ await targetClient.uploadBlob(bytes, mimeType);
1376
+ synced++;
1377
+ spinner.message(`Transferring images ${progressBar(synced, totalBlobs)}`);
1378
+ } catch (err) {
1379
+ synced++;
1380
+ failedBlobs.push(blob.cid);
1381
+ spinner.message(`Transferring images ${progressBar(synced, totalBlobs)}`);
1382
+ }
1383
+ } while (cursor);
1384
+ if (failedBlobs.length > 0) {
1385
+ spinner.stop(`Transferred ${formatNumber(synced - failedBlobs.length)} images (${failedBlobs.length} failed)`);
1386
+ p.log.warn(`Run 'pds migrate' again to retry failed transfers.`);
1387
+ } else spinner.stop(`Transferred ${formatNumber(synced)} images`);
1388
+ }
1389
+ spinner.start("Verifying migration...");
1390
+ const finalStatus = await targetClient.getAccountStatus();
1391
+ spinner.stop("Verification complete");
1392
+ if (finalStatus.importedBlobs >= finalStatus.expectedBlobs) p.log.success("All packed and moved! 🦋");
1393
+ else {
1394
+ p.log.warn(`Migration partially complete. ${finalStatus.expectedBlobs - finalStatus.importedBlobs} images remaining.`);
1395
+ p.log.info("Run 'pds migrate' again to continue.");
1396
+ }
1397
+ showNextSteps(pm, sourceDomain);
1398
+ p.outro("Welcome to your new home in the Atmosphere! 🦋");
1399
+ }
1400
+ });
1401
+ function showNextSteps(pm, sourceDomain) {
1402
+ p.note(brightNote$1([
1403
+ bold$1("Your data is safe in your new PDS."),
1404
+ "Two more steps to go live in the Atmosphere:",
1405
+ "",
1406
+ bold$1("1. Update your identity"),
1407
+ " Tell the network where you live now.",
1408
+ ` (Requires email verification from ${sourceDomain})`,
1409
+ "",
1410
+ bold$1("2. Flip the switch"),
1411
+ ` ${pm} pds activate`,
1412
+ "",
1413
+ "Docs: https://atproto.com/guides/account-migration"
1414
+ ]), "Almost there!");
1415
+ }
1416
+
1417
+ //#endregion
1418
+ //#region src/cli/commands/activate.ts
1419
+ /**
1420
+ * Activate account command - enables writes after migration
1421
+ */
1422
+ const activateCommand = defineCommand({
1423
+ meta: {
1424
+ name: "activate",
1425
+ description: "Activate your account to enable writes and go live"
1426
+ },
1427
+ args: { dev: {
1428
+ type: "boolean",
1429
+ description: "Target local development server instead of production",
1430
+ default: false
1431
+ } },
1432
+ async run({ args }) {
1433
+ const isDev = args.dev;
1434
+ p.intro("🦋 Activate Account");
1435
+ const vars = getVars();
1436
+ let targetUrl;
1437
+ try {
1438
+ targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME);
1439
+ } catch (err) {
1440
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
1441
+ p.log.info("Run 'pds init' first to configure your PDS.");
1442
+ process.exit(1);
1443
+ }
1444
+ const targetDomain = getDomain(targetUrl);
1445
+ const wranglerVars = getVars();
1446
+ const config = {
1447
+ ...readDevVars(),
1448
+ ...wranglerVars
1449
+ };
1450
+ const authToken = config.AUTH_TOKEN;
1451
+ const handle = config.HANDLE;
1452
+ if (!authToken) {
1453
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
1454
+ p.outro("Activation cancelled.");
1455
+ process.exit(1);
1456
+ }
1457
+ const client = new PDSClient(targetUrl, authToken);
1458
+ const spinner = p.spinner();
1459
+ spinner.start(`Checking PDS at ${targetDomain}...`);
1460
+ if (!await client.healthCheck()) {
1461
+ spinner.stop(`PDS not responding at ${targetDomain}`);
1462
+ p.log.error(`Your PDS isn't responding at ${targetUrl}`);
1463
+ if (!isDev) p.log.info("Make sure your worker is deployed: wrangler deploy");
1464
+ p.outro("Activation cancelled.");
1465
+ process.exit(1);
1466
+ }
1467
+ spinner.stop(`Connected to ${targetDomain}`);
1468
+ spinner.start("Checking account status...");
1469
+ const status = await client.getAccountStatus();
1470
+ spinner.stop("Account status retrieved");
1471
+ if (status.active) {
1472
+ p.log.warn("Your account is already active!");
1473
+ p.log.info("No action needed - you're live in the Atmosphere. 🦋");
1474
+ p.outro("All good!");
1475
+ return;
1476
+ }
1477
+ p.note([
1478
+ `@${handle || "your-handle"}`,
1479
+ "",
1480
+ "This will enable writes and make your account live.",
1481
+ "Make sure you've:",
1482
+ " ✓ Updated your DID document to point here",
1483
+ " ✓ Completed email verification (if required)"
1484
+ ].join("\n"), "Ready to go live?");
1485
+ const confirm = await p.confirm({
1486
+ message: "Activate account?",
1487
+ initialValue: true
1488
+ });
1489
+ if (p.isCancel(confirm) || !confirm) {
1490
+ p.cancel("Activation cancelled.");
1491
+ process.exit(0);
1492
+ }
1493
+ spinner.start("Activating account...");
1494
+ try {
1495
+ await client.activateAccount();
1496
+ spinner.stop("Account activated!");
1497
+ } catch (err) {
1498
+ spinner.stop("Activation failed");
1499
+ p.log.error(err instanceof Error ? err.message : "Could not activate account");
1500
+ p.outro("Activation failed.");
1501
+ process.exit(1);
1502
+ }
1503
+ p.log.success("Welcome to the Atmosphere! 🦋");
1504
+ p.log.info("Your account is now live and accepting writes.");
1505
+ p.outro("All set!");
1506
+ }
1507
+ });
1508
+
1509
+ //#endregion
1510
+ //#region src/cli/commands/deactivate.ts
1511
+ /**
1512
+ * Deactivate account command - disables writes for re-import
1513
+ */
1514
+ const brightNote = (lines) => lines.map((l) => `\x1b[0m${l}`).join("\n");
1515
+ const bold = (text) => pc.bold(text);
1516
+ const deactivateCommand = defineCommand({
1517
+ meta: {
1518
+ name: "deactivate",
1519
+ description: "Deactivate your account to enable re-import"
1520
+ },
1521
+ args: { dev: {
1522
+ type: "boolean",
1523
+ description: "Target local development server instead of production",
1524
+ default: false
1525
+ } },
1526
+ async run({ args }) {
1527
+ const isDev = args.dev;
1528
+ p.intro("🦋 Deactivate Account");
1529
+ const vars = getVars();
1530
+ let targetUrl;
1531
+ try {
1532
+ targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME);
1533
+ } catch (err) {
1534
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
1535
+ p.log.info("Run 'pds init' first to configure your PDS.");
1536
+ process.exit(1);
1537
+ }
1538
+ const targetDomain = getDomain(targetUrl);
1539
+ const wranglerVars = getVars();
1540
+ const config = {
1541
+ ...readDevVars(),
1542
+ ...wranglerVars
1543
+ };
1544
+ const authToken = config.AUTH_TOKEN;
1545
+ const handle = config.HANDLE;
1546
+ if (!authToken) {
1547
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
1548
+ p.outro("Deactivation cancelled.");
1549
+ process.exit(1);
1550
+ }
1551
+ const client = new PDSClient(targetUrl, authToken);
1552
+ const spinner = p.spinner();
1553
+ spinner.start(`Checking PDS at ${targetDomain}...`);
1554
+ if (!await client.healthCheck()) {
1555
+ spinner.stop(`PDS not responding at ${targetDomain}`);
1556
+ p.log.error(`Your PDS isn't responding at ${targetUrl}`);
1557
+ if (!isDev) p.log.info("Make sure your worker is deployed: wrangler deploy");
1558
+ p.outro("Deactivation cancelled.");
1559
+ process.exit(1);
1560
+ }
1561
+ spinner.stop(`Connected to ${targetDomain}`);
1562
+ spinner.start("Checking account status...");
1563
+ const status = await client.getAccountStatus();
1564
+ spinner.stop("Account status retrieved");
1565
+ if (!status.active) {
1566
+ p.log.warn("Your account is already deactivated.");
1567
+ p.log.info("Writes are disabled. Use 'pds activate' to re-enable.");
1568
+ p.outro("Already deactivated.");
1569
+ return;
1570
+ }
1571
+ p.note(brightNote([
1572
+ bold(`⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`),
1573
+ "",
1574
+ "Your account will:",
1575
+ " • Stop accepting new posts, follows, and other writes",
1576
+ " • Remain readable in the Atmosphere",
1577
+ " • Allow you to use 'pds migrate --clean' to re-import",
1578
+ "",
1579
+ bold("Only deactivate if you need to re-import your data.")
1580
+ ]), "Deactivate Account");
1581
+ const confirm = await p.confirm({
1582
+ message: "Are you sure you want to deactivate?",
1583
+ initialValue: false
1584
+ });
1585
+ if (p.isCancel(confirm) || !confirm) {
1586
+ p.cancel("Deactivation cancelled.");
1587
+ process.exit(0);
1588
+ }
1589
+ spinner.start("Deactivating account...");
1590
+ try {
1591
+ await client.deactivateAccount();
1592
+ spinner.stop("Account deactivated");
1593
+ } catch (err) {
1594
+ spinner.stop("Deactivation failed");
1595
+ p.log.error(err instanceof Error ? err.message : "Could not deactivate account");
1596
+ p.outro("Deactivation failed.");
1597
+ process.exit(1);
1598
+ }
1599
+ p.log.success("Account deactivated");
1600
+ p.log.info("Writes are now disabled.");
1601
+ p.log.info("");
1602
+ p.log.info("To re-import your data:");
1603
+ p.log.info(" pnpm pds migrate --clean");
1604
+ p.log.info("");
1605
+ p.log.info("To re-enable writes:");
1606
+ p.log.info(" pnpm pds activate");
1607
+ p.outro("Deactivated.");
1608
+ }
1609
+ });
1610
+
508
1611
  //#endregion
509
1612
  //#region src/cli/index.ts
510
1613
  /**
@@ -518,7 +1621,10 @@ runMain(defineCommand({
518
1621
  },
519
1622
  subCommands: {
520
1623
  init: initCommand,
521
- secret: secretCommand
1624
+ secret: secretCommand,
1625
+ migrate: migrateCommand,
1626
+ activate: activateCommand,
1627
+ deactivate: deactivateCommand
522
1628
  }
523
1629
  }));
524
1630