@getcirrus/pds 0.5.0 → 0.6.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,11 +8,12 @@ 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 { CompositeDidDocumentResolver, DohJsonHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from "@atcute/identity-resolver";
12
11
  import pc from "picocolors";
12
+ import QRCode from "qrcode";
13
13
  import { Client, ClientResponseError, ok } from "@atcute/client";
14
14
  import "@atcute/bluesky";
15
15
  import "@atcute/atproto";
16
+ import { CompositeDidDocumentResolver, DohJsonHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from "@atcute/identity-resolver";
16
17
  import { getPdsEndpoint } from "@atcute/identity";
17
18
 
18
19
  //#region src/cli/utils/wrangler.ts
@@ -422,650 +423,182 @@ const secretCommand = defineCommand({
422
423
  });
423
424
 
424
425
  //#endregion
425
- //#region src/cli/utils/cli-helpers.ts
426
- /**
427
- * Shared CLI utilities for PDS commands
428
- */
429
- /**
430
- * Prompt for text input, exiting on cancel
431
- */
432
- async function promptText(options) {
433
- const result = await p.text(options);
434
- if (p.isCancel(result)) {
435
- p.cancel("Cancelled");
436
- process.exit(0);
437
- }
438
- return result;
439
- }
440
- /**
441
- * Prompt for confirmation, exiting on cancel
442
- */
443
- async function promptConfirm(options) {
444
- const result = await p.confirm(options);
445
- if (p.isCancel(result)) {
446
- p.cancel("Cancelled");
447
- process.exit(0);
448
- }
449
- return result;
450
- }
426
+ //#region src/cli/utils/pds-client.ts
451
427
  /**
452
- * Prompt for selection, exiting on cancel
428
+ * HTTP client for AT Protocol PDS XRPC endpoints
429
+ * Uses @atcute/client for type-safe XRPC calls
453
430
  */
454
- async function promptSelect(options) {
455
- const result = await p.select(options);
456
- if (p.isCancel(result)) {
457
- p.cancel("Cancelled");
458
- process.exit(0);
459
- }
460
- return result;
461
- }
462
431
  /**
463
- * Get target PDS URL based on mode
432
+ * Create a fetch handler that adds optional auth token
464
433
  */
465
- function getTargetUrl(isDev, pdsHostname) {
466
- if (isDev) return `http://localhost:${process.env.PORT ? parseInt(process.env.PORT) ?? "5173" : "5173"}`;
467
- if (!pdsHostname) throw new Error("PDS_HOSTNAME not configured in wrangler.jsonc");
468
- return `https://${pdsHostname}`;
434
+ function createAuthHandler(baseUrl, token) {
435
+ return async (pathname, init) => {
436
+ const url = new URL(pathname, baseUrl);
437
+ const headers = new Headers(init.headers);
438
+ if (token) headers.set("Authorization", `Bearer ${token}`);
439
+ return fetch(url, {
440
+ ...init,
441
+ headers
442
+ });
443
+ };
469
444
  }
470
- /**
471
- * Extract domain from URL
472
- */
473
- function getDomain(url) {
474
- try {
475
- return new URL(url).hostname;
476
- } catch {
477
- return url;
445
+ var PDSClient = class PDSClient {
446
+ client;
447
+ authToken;
448
+ constructor(baseUrl, authToken) {
449
+ this.baseUrl = baseUrl;
450
+ this.authToken = authToken;
451
+ this.client = new Client({ handler: createAuthHandler(baseUrl, authToken) });
478
452
  }
479
- }
480
- /**
481
- * Detect which package manager is being used based on npm_config_user_agent
482
- */
483
- function detectPackageManager() {
484
- const userAgent = process.env.npm_config_user_agent || "";
485
- if (userAgent.startsWith("yarn")) return "yarn";
486
- if (userAgent.startsWith("pnpm")) return "pnpm";
487
- if (userAgent.startsWith("bun")) return "bun";
488
- return "npm";
489
- }
490
- /**
491
- * Format a command for the detected package manager
492
- * npm always needs "run" for scripts, pnpm/yarn/bun can use shorthand
493
- * except for "deploy" which conflicts with pnpm's built-in deploy command
494
- */
495
- function formatCommand(pm, ...args) {
496
- if (pm === "npm" || args[0] === "deploy") return `${pm} run ${args.join(" ")}`;
497
- return `${pm} ${args.join(" ")}`;
498
- }
499
-
500
- //#endregion
501
- //#region src/cli/utils/handle-resolver.ts
502
- /**
503
- * Utilities for resolving AT Protocol handles to DIDs
504
- */
505
- const resolver = new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" });
506
- /**
507
- * Resolve a handle to a DID using the AT Protocol handle resolution methods.
508
- * Uses DNS-over-HTTPS via Cloudflare for DNS resolution.
509
- */
510
- async function resolveHandleToDid(handle) {
511
- try {
512
- return await resolver.resolve(handle, { signal: AbortSignal.timeout(1e4) });
513
- } catch {
514
- return null;
453
+ /**
454
+ * Set the auth token for subsequent requests
455
+ */
456
+ setAuthToken(token) {
457
+ this.authToken = token;
458
+ this.client = new Client({ handler: createAuthHandler(this.baseUrl, token) });
515
459
  }
516
- }
517
-
518
- //#endregion
519
- //#region src/did-resolver.ts
520
- /**
521
- * DID resolution for Cloudflare Workers
522
- *
523
- * Uses @atcute/identity-resolver which is already Workers-compatible
524
- * (uses redirect: "manual" internally).
525
- */
526
- const PLC_DIRECTORY = "https://plc.directory";
527
- const TIMEOUT_MS = 3e3;
528
- /**
529
- * Wrapper that always uses globalThis.fetch so it can be mocked in tests.
530
- * @atcute resolvers capture the fetch reference at construction time,
531
- * so we need this indirection to allow test mocking.
532
- */
533
- const stubbableFetch = (input, init) => globalThis.fetch(input, init);
534
- var DidResolver = class {
535
- resolver;
536
- timeout;
537
- cache;
538
- constructor(opts = {}) {
539
- this.timeout = opts.timeout ?? TIMEOUT_MS;
540
- this.cache = opts.didCache;
541
- this.resolver = new CompositeDidDocumentResolver({ methods: {
542
- plc: new PlcDidDocumentResolver({
543
- apiUrl: opts.plcUrl ?? PLC_DIRECTORY,
544
- fetch: stubbableFetch
545
- }),
546
- web: new WebDidDocumentResolver({ fetch: stubbableFetch })
547
- } });
460
+ /**
461
+ * Create a session with identifier and password
462
+ */
463
+ async createSession(identifier, password) {
464
+ return ok(this.client.post("com.atproto.server.createSession", { input: {
465
+ identifier,
466
+ password
467
+ } }));
548
468
  }
549
- async resolve(did) {
550
- if (this.cache) {
551
- const cached = await this.cache.checkCache(did);
552
- if (cached && !cached.expired) {
553
- if (cached.stale) this.cache.refreshCache(did, () => this.resolveNoCache(did), cached);
554
- return cached.doc;
555
- }
556
- }
557
- const doc = await this.resolveNoCache(did);
558
- if (doc && this.cache) await this.cache.cacheDid(did, doc);
559
- else if (!doc && this.cache) await this.cache.clearEntry(did);
560
- return doc;
469
+ /**
470
+ * Get repository description including collections
471
+ */
472
+ async describeRepo(did) {
473
+ return ok(this.client.get("com.atproto.repo.describeRepo", { params: { repo: did } }));
561
474
  }
562
- async resolveNoCache(did) {
563
- const controller = new AbortController();
564
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
475
+ /**
476
+ * Get profile stats from AppView (posts, follows, followers counts)
477
+ */
478
+ async getProfileStats(did) {
565
479
  try {
566
- const doc = await this.resolver.resolve(did, { signal: controller.signal });
567
- if (doc.id !== did) return null;
568
- return doc;
480
+ const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`);
481
+ if (!res.ok) return null;
482
+ const profile = await res.json();
483
+ return {
484
+ postsCount: profile.postsCount ?? 0,
485
+ followsCount: profile.followsCount ?? 0,
486
+ followersCount: profile.followersCount ?? 0
487
+ };
569
488
  } catch {
570
489
  return null;
571
- } finally {
572
- clearTimeout(timeoutId);
573
490
  }
574
491
  }
575
- };
576
-
577
- //#endregion
578
- //#region src/cli/commands/init.ts
579
- /**
580
- * Interactive PDS setup wizard
581
- */
582
- /**
583
- * Slugify a handle to create a worker name
584
- * e.g., "example.com" -> "example-com-pds"
585
- */
586
- function slugifyHandle(handle) {
587
- return handle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-pds";
588
- }
589
- const defaultWorkerName = "my-pds";
590
- /**
591
- * Prompt for worker name with validation
592
- */
593
- async function promptWorkerName(handle, currentWorkerName) {
594
- const placeholder = currentWorkerName && currentWorkerName !== defaultWorkerName ? currentWorkerName : slugifyHandle(handle);
595
- return promptText({
596
- message: "Cloudflare Worker name:",
597
- placeholder,
598
- initialValue: placeholder,
599
- validate: (v) => {
600
- if (!v) return "Worker name is required";
601
- if (!/^[a-z0-9-]+$/.test(v)) return "Worker name can only contain lowercase letters, numbers, and hyphens";
602
- }
603
- });
604
- }
605
- /**
606
- * Ensure a Cloudflare account_id is configured.
607
- * If multiple accounts detected, prompts user to select one.
608
- */
609
- async function ensureAccountConfigured() {
610
- const spinner = p.spinner();
611
- spinner.start("Checking Cloudflare account...");
612
- const accounts = await detectCloudflareAccounts();
613
- if (accounts === null) {
614
- spinner.stop("Cloudflare account configured");
615
- return;
492
+ /**
493
+ * Export repository as CAR file
494
+ */
495
+ async getRepo(did) {
496
+ const response = await this.client.get("com.atproto.sync.getRepo", {
497
+ params: { did },
498
+ as: "bytes"
499
+ });
500
+ if (!response.ok) throw new ClientResponseError({
501
+ status: response.status,
502
+ headers: response.headers,
503
+ data: response.data
504
+ });
505
+ return response.data;
616
506
  }
617
- spinner.stop(`Found ${accounts.length} Cloudflare accounts`);
618
- const selectedId = await promptSelect({
619
- message: "Select your Cloudflare account:",
620
- options: accounts.map((acc) => ({
621
- value: acc.id,
622
- label: acc.name,
623
- hint: acc.id.slice(0, 8) + "..."
624
- }))
625
- });
626
- setAccountId(selectedId);
627
- const selectedName = accounts.find((a) => a.id === selectedId)?.name;
628
- p.log.success(`Account "${selectedName}" saved to wrangler.jsonc`);
629
- }
630
- /**
631
- * Run wrangler types to regenerate TypeScript types
632
- */
633
- function runWranglerTypes() {
634
- return new Promise((resolve$1, reject) => {
635
- const child = spawn("wrangler", ["types"], { stdio: "pipe" });
636
- let output = "";
637
- child.stdout?.on("data", (data) => {
638
- output += data.toString();
639
- });
640
- child.stderr?.on("data", (data) => {
641
- output += data.toString();
507
+ /**
508
+ * Get a blob by CID
509
+ */
510
+ async getBlob(did, cid) {
511
+ const response = await this.client.get("com.atproto.sync.getBlob", {
512
+ params: {
513
+ did,
514
+ cid
515
+ },
516
+ as: "bytes"
642
517
  });
643
- child.on("close", (code) => {
644
- if (code === 0) resolve$1();
645
- else {
646
- if (output) console.error(output);
647
- reject(/* @__PURE__ */ new Error(`wrangler types failed with code ${code}`));
648
- }
518
+ if (!response.ok) throw new ClientResponseError({
519
+ status: response.status,
520
+ headers: response.headers,
521
+ data: response.data
649
522
  });
650
- child.on("error", reject);
651
- });
652
- }
653
- const initCommand = defineCommand({
654
- meta: {
655
- name: "init",
656
- description: "Interactive PDS setup wizard"
657
- },
658
- args: { production: {
659
- type: "boolean",
660
- description: "Deploy secrets to Cloudflare?",
661
- default: false
662
- } },
663
- async run({ args }) {
664
- const pm = detectPackageManager();
665
- p.intro("🦋 PDS Setup");
666
- const isProduction = args.production;
667
- if (isProduction) p.log.info("Production mode: secrets will be deployed to Cloudflare");
668
- p.log.info("Let's set up your new home in the Atmosphere!");
669
- const wranglerVars = getVars();
670
- const devVars = readDevVars();
671
- const currentVars = {
672
- ...devVars,
673
- ...wranglerVars
523
+ return {
524
+ bytes: response.data,
525
+ mimeType: response.headers.get("content-type") ?? "application/octet-stream"
674
526
  };
675
- const isMigrating = await promptConfirm({
676
- message: "Are you migrating an existing Bluesky/ATProto account?",
677
- initialValue: false
527
+ }
528
+ /**
529
+ * List blobs in repository
530
+ */
531
+ async listBlobs(did, cursor) {
532
+ return ok(this.client.get("com.atproto.sync.listBlobs", { params: {
533
+ did,
534
+ ...cursor && { cursor }
535
+ } }));
536
+ }
537
+ /**
538
+ * Get user preferences
539
+ */
540
+ async getPreferences() {
541
+ return (await ok(this.client.get("app.bsky.actor.getPreferences", { params: {} }))).preferences;
542
+ }
543
+ /**
544
+ * Update user preferences
545
+ */
546
+ async putPreferences(preferences) {
547
+ const url = new URL("/xrpc/app.bsky.actor.putPreferences", this.baseUrl);
548
+ const headers = { "Content-Type": "application/json" };
549
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
550
+ const res = await fetch(url.toString(), {
551
+ method: "POST",
552
+ headers,
553
+ body: JSON.stringify({ preferences })
678
554
  });
679
- let did;
680
- let handle;
681
- let hostname;
682
- let workerName;
683
- let initialActive;
684
- const currentWorkerName = getWorkerName();
685
- if (isMigrating) {
686
- p.log.info("Time to pack your bags! 🧳");
687
- p.log.info("Your new account will be inactive until you're ready to go live.");
688
- let hostedDomains = [
689
- ".bsky.social",
690
- ".bsky.network",
691
- ".bsky.team"
692
- ];
693
- const isHostedHandle = (h) => hostedDomains.some((domain) => h?.endsWith(domain));
694
- let resolvedDid = null;
695
- let existingHandle = null;
696
- let attempts = 0;
697
- const MAX_ATTEMPTS = 3;
698
- while (!resolvedDid && attempts < MAX_ATTEMPTS) {
699
- attempts++;
700
- const currentHandle = await promptText({
701
- message: "Your current Bluesky/ATProto handle:",
702
- placeholder: "example.bsky.social",
703
- validate: (v) => !v ? "Handle is required" : void 0
704
- });
705
- existingHandle = currentHandle;
706
- const spinner$1 = p.spinner();
707
- spinner$1.start("Finding you in the Atmosphere...");
708
- resolvedDid = await resolveHandleToDid(currentHandle);
709
- if (!resolvedDid) {
710
- spinner$1.stop("Not found");
711
- p.log.error(`Failed to resolve handle "${currentHandle}"`);
712
- if (await promptSelect({
713
- message: "What would you like to do?",
714
- options: [{
715
- value: "retry",
716
- label: "Try a different handle"
717
- }, {
718
- value: "manual",
719
- label: "Enter DID manually"
720
- }]
721
- }) === "manual") resolvedDid = await promptText({
722
- message: "Enter your DID:",
723
- placeholder: "did:plc:...",
724
- validate: (v) => {
725
- if (!v) return "DID is required";
726
- if (!v.startsWith("did:")) return "DID must start with did:";
727
- }
728
- });
729
- } else {
730
- try {
731
- const pdsService = (await new DidResolver().resolve(resolvedDid))?.service?.find((s) => s.type === "AtprotoPersonalDataServer" || s.id === "#atproto_pds");
732
- if (pdsService?.serviceEndpoint) {
733
- const describeRes = await fetch(`${pdsService.serviceEndpoint}/xrpc/com.atproto.server.describeServer`);
734
- if (describeRes.ok) {
735
- const desc = await describeRes.json();
736
- if (desc.availableUserDomains?.length) hostedDomains = desc.availableUserDomains.map((d) => d.startsWith(".") ? d : `.${d}`);
737
- }
738
- }
739
- } catch {}
740
- spinner$1.stop(`Found you! ${resolvedDid}`);
741
- if (isHostedHandle(existingHandle)) {
742
- const theirDomain = hostedDomains.find((d) => existingHandle?.endsWith(d));
743
- const domainExample = theirDomain ? `*${theirDomain}` : "*.bsky.social";
744
- p.log.warn(`You'll need a custom domain for your new handle (not ${domainExample}). You can set this up after transferring your data.`);
745
- }
746
- if (attempts >= MAX_ATTEMPTS) {
747
- p.log.error("Unable to resolve handle after 3 attempts.");
748
- p.log.info("");
749
- p.log.info("You can:");
750
- p.log.info(" 1. Double-check your handle spelling");
751
- p.log.info(" 2. Provide your DID directly if you know it");
752
- p.log.info(" 3. Run 'pds init' again when ready");
753
- p.outro("Initialization cancelled.");
754
- process.exit(1);
755
- }
756
- }
757
- }
758
- did = resolvedDid;
759
- handle = await promptText({
760
- message: "New account handle (must be a domain you control):",
761
- placeholder: "example.com",
762
- initialValue: existingHandle && !isHostedHandle(existingHandle) ? existingHandle : currentVars.HANDLE || "",
763
- validate: (v) => {
764
- if (!v) return "Handle is required";
765
- if (isHostedHandle(v)) return "You need a custom domain - hosted handles like *.bsky.social won't work";
555
+ if (!res.ok) {
556
+ const errorBody = await res.json().catch(() => ({}));
557
+ throw new ClientResponseError({
558
+ status: res.status,
559
+ headers: res.headers,
560
+ data: {
561
+ error: errorBody.error ?? "Unknown",
562
+ message: errorBody.message
766
563
  }
767
564
  });
768
- hostname = await promptText({
769
- message: "Domain where you'll deploy your PDS:",
770
- placeholder: handle,
771
- initialValue: currentVars.PDS_HOSTNAME || handle,
772
- validate: (v) => !v ? "Hostname is required" : void 0
773
- });
774
- workerName = await promptWorkerName(handle, currentWorkerName);
775
- initialActive = "false";
776
- await ensureAccountConfigured();
777
- } else {
778
- p.log.info("A fresh start in the Atmosphere! ✨");
779
- hostname = await promptText({
780
- message: "Domain where you'll deploy your PDS:",
781
- placeholder: "pds.example.com",
782
- initialValue: currentVars.PDS_HOSTNAME || "",
783
- validate: (v) => !v ? "Hostname is required" : void 0
784
- });
785
- handle = await promptText({
786
- message: "Account handle:",
787
- placeholder: hostname,
788
- initialValue: currentVars.HANDLE || hostname,
789
- validate: (v) => !v ? "Handle is required" : void 0
790
- });
791
- const didDefault = "did:web:" + hostname;
792
- did = await promptText({
793
- message: "Account DID:",
794
- placeholder: didDefault,
795
- initialValue: currentVars.DID || didDefault,
796
- validate: (v) => {
797
- if (!v) return "DID is required";
798
- if (!v.startsWith("did:")) return "DID must start with 'did:'";
565
+ }
566
+ }
567
+ /**
568
+ * Get account status including migration progress
569
+ */
570
+ async getAccountStatus() {
571
+ const url = new URL("/xrpc/com.atproto.server.getAccountStatus", this.baseUrl);
572
+ const headers = {};
573
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
574
+ const res = await fetch(url.toString(), {
575
+ method: "GET",
576
+ headers
577
+ });
578
+ if (!res.ok) {
579
+ const errorBody = await res.json().catch(() => ({}));
580
+ throw new ClientResponseError({
581
+ status: res.status,
582
+ headers: res.headers,
583
+ data: {
584
+ error: errorBody.error ?? "Unknown",
585
+ message: errorBody.message
799
586
  }
800
587
  });
801
- workerName = await promptWorkerName(handle, currentWorkerName);
802
- initialActive = "true";
803
- await ensureAccountConfigured();
804
- if (handle === hostname) p.note([
805
- "Your handle matches your PDS hostname, so your PDS will",
806
- "automatically handle domain verification for you!",
807
- "",
808
- "For did:web, your PDS serves the DID document at:",
809
- ` https://${hostname}/.well-known/did.json`,
810
- "",
811
- "For handle verification, it serves:",
812
- ` https://${hostname}/.well-known/atproto-did`,
813
- "",
814
- "No additional DNS or hosting setup needed. Easy! 🎉"
815
- ].join("\n"), "Identity Setup 🪪");
816
- else p.note([
817
- "For did:web, your PDS will serve the DID document at:",
818
- ` https://${hostname}/.well-known/did.json`,
819
- "",
820
- "To verify your handle, create a DNS TXT record:",
821
- ` _atproto.${handle} TXT "did=${did}"`,
822
- "",
823
- "Or serve a file at:",
824
- ` https://${handle}/.well-known/atproto-did`,
825
- ` containing: ${did}`
826
- ].join("\n"), "Identity Setup 🪪");
827
- }
828
- const spinner = p.spinner();
829
- const authToken = await getOrGenerateSecret("AUTH_TOKEN", devVars, async () => {
830
- spinner.start("Generating auth token...");
831
- const token = generateAuthToken();
832
- spinner.stop("Auth token generated");
833
- return token;
834
- });
835
- const signingKey = await getOrGenerateSecret("SIGNING_KEY", devVars, async () => {
836
- spinner.start("Generating signing keypair...");
837
- const { privateKey } = await generateSigningKeypair();
838
- spinner.stop("Signing keypair generated");
839
- return privateKey;
840
- });
841
- const signingKeyPublic = await derivePublicKey(signingKey);
842
- const jwtSecret = await getOrGenerateSecret("JWT_SECRET", devVars, async () => {
843
- spinner.start("Generating JWT secret...");
844
- const secret = generateJwtSecret();
845
- spinner.stop("JWT secret generated");
846
- return secret;
847
- });
848
- const passwordHash = await getOrGenerateSecret("PASSWORD_HASH", devVars, async () => {
849
- const password = await promptPassword(handle);
850
- spinner.start("Hashing password...");
851
- const hash = await hashPassword(password);
852
- spinner.stop("Password hashed");
853
- return hash;
854
- });
855
- spinner.start("Updating wrangler.jsonc...");
856
- setWorkerName(workerName);
857
- setVars({
858
- PDS_HOSTNAME: hostname,
859
- DID: did,
860
- HANDLE: handle,
861
- SIGNING_KEY_PUBLIC: signingKeyPublic,
862
- INITIAL_ACTIVE: initialActive
863
- });
864
- setCustomDomains([hostname]);
865
- spinner.stop("wrangler.jsonc updated");
866
- const local = !isProduction;
867
- if (isProduction) spinner.start("Deploying secrets to Cloudflare...");
868
- else spinner.start("Writing secrets to .dev.vars...");
869
- await setSecretValue("AUTH_TOKEN", authToken, local);
870
- await setSecretValue("SIGNING_KEY", signingKey, local);
871
- await setSecretValue("JWT_SECRET", jwtSecret, local);
872
- await setSecretValue("PASSWORD_HASH", passwordHash, local);
873
- spinner.stop(isProduction ? "Secrets deployed" : "Secrets written to .dev.vars");
874
- spinner.start("Generating TypeScript types...");
875
- try {
876
- await runWranglerTypes();
877
- spinner.stop("TypeScript types generated");
878
- } catch {
879
- spinner.stop("Failed to generate types (wrangler types)");
880
- }
881
- p.note([
882
- " Worker name: " + workerName,
883
- " PDS hostname: " + hostname,
884
- " DID: " + did,
885
- " Handle: " + handle,
886
- " Public signing key: " + signingKeyPublic.slice(0, 20) + "...",
887
- "",
888
- isProduction ? "Secrets deployed to Cloudflare ☁️" : "Secrets saved to .dev.vars",
889
- "",
890
- "Auth token (save this!):",
891
- " " + authToken
892
- ].join("\n"), "Your New Home 🏠");
893
- let deployedSecrets = isProduction;
894
- if (!isProduction) {
895
- const deployNow = await p.confirm({
896
- message: "Push secrets to Cloudflare now?",
897
- initialValue: false
898
- });
899
- if (!p.isCancel(deployNow) && deployNow) {
900
- spinner.start("Deploying secrets to Cloudflare...");
901
- await setSecretValue("AUTH_TOKEN", authToken, false);
902
- await setSecretValue("SIGNING_KEY", signingKey, false);
903
- await setSecretValue("JWT_SECRET", jwtSecret, false);
904
- await setSecretValue("PASSWORD_HASH", passwordHash, false);
905
- spinner.stop("Secrets deployed to Cloudflare");
906
- deployedSecrets = true;
907
- }
908
- }
909
- if (isMigrating) p.note([
910
- deployedSecrets ? "Deploy your worker and run the migration:" : "Push secrets, deploy, and run the migration:",
911
- "",
912
- ...deployedSecrets ? [] : [` ${formatCommand(pm, "pds", "init", "--production")}`, ""],
913
- ` ${formatCommand(pm, "deploy")}`,
914
- ` ${formatCommand(pm, "pds", "migrate")}`,
915
- "",
916
- "To test locally first:",
917
- ` ${formatCommand(pm, "dev")} # in one terminal`,
918
- ` ${formatCommand(pm, "pds", "migrate", "--dev")} # in another`,
919
- "",
920
- "Then update your identity and flip the switch! 🦋",
921
- " https://atproto.com/guides/account-migration"
922
- ].join("\n"), "Next Steps 🧳");
923
- if (deployedSecrets) p.outro(`Run '${formatCommand(pm, "deploy")}' to launch your PDS! 🚀`);
924
- else p.outro(`Run '${formatCommand(pm, "dev")}' to start your PDS locally! 🦋`);
925
- }
926
- });
927
- /**
928
- * Helper to get a secret from .dev.vars or generate a new one
929
- */
930
- async function getOrGenerateSecret(name, devVars, generate) {
931
- if (devVars[name]) {
932
- if (await p.confirm({
933
- message: `Use ${name} from .dev.vars?`,
934
- initialValue: true
935
- }) === true) return devVars[name];
936
- }
937
- return generate();
938
- }
939
-
940
- //#endregion
941
- //#region src/cli/utils/pds-client.ts
942
- /**
943
- * HTTP client for AT Protocol PDS XRPC endpoints
944
- * Uses @atcute/client for type-safe XRPC calls
945
- */
946
- /**
947
- * Create a fetch handler that adds optional auth token
948
- */
949
- function createAuthHandler(baseUrl, token) {
950
- return async (pathname, init) => {
951
- const url = new URL(pathname, baseUrl);
952
- const headers = new Headers(init.headers);
953
- if (token) headers.set("Authorization", `Bearer ${token}`);
954
- return fetch(url, {
955
- ...init,
956
- headers
957
- });
958
- };
959
- }
960
- var PDSClient = class PDSClient {
961
- client;
962
- authToken;
963
- constructor(baseUrl, authToken) {
964
- this.baseUrl = baseUrl;
965
- this.authToken = authToken;
966
- this.client = new Client({ handler: createAuthHandler(baseUrl, authToken) });
967
- }
968
- /**
969
- * Set the auth token for subsequent requests
970
- */
971
- setAuthToken(token) {
972
- this.authToken = token;
973
- this.client = new Client({ handler: createAuthHandler(this.baseUrl, token) });
974
- }
975
- /**
976
- * Create a session with identifier and password
977
- */
978
- async createSession(identifier, password) {
979
- return ok(this.client.post("com.atproto.server.createSession", { input: {
980
- identifier,
981
- password
982
- } }));
983
- }
984
- /**
985
- * Get repository description including collections
986
- */
987
- async describeRepo(did) {
988
- return ok(this.client.get("com.atproto.repo.describeRepo", { params: { repo: did } }));
989
- }
990
- /**
991
- * Get profile stats from AppView (posts, follows, followers counts)
992
- */
993
- async getProfileStats(did) {
994
- try {
995
- const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`);
996
- if (!res.ok) return null;
997
- const profile = await res.json();
998
- return {
999
- postsCount: profile.postsCount ?? 0,
1000
- followsCount: profile.followsCount ?? 0,
1001
- followersCount: profile.followersCount ?? 0
1002
- };
1003
- } catch {
1004
- return null;
1005
588
  }
589
+ return res.json();
1006
590
  }
1007
591
  /**
1008
- * Export repository as CAR file
1009
- */
1010
- async getRepo(did) {
1011
- const response = await this.client.get("com.atproto.sync.getRepo", {
1012
- params: { did },
1013
- as: "bytes"
1014
- });
1015
- if (!response.ok) throw new ClientResponseError({
1016
- status: response.status,
1017
- headers: response.headers,
1018
- data: response.data
1019
- });
1020
- return response.data;
1021
- }
1022
- /**
1023
- * Get a blob by CID
1024
- */
1025
- async getBlob(did, cid) {
1026
- const response = await this.client.get("com.atproto.sync.getBlob", {
1027
- params: {
1028
- did,
1029
- cid
1030
- },
1031
- as: "bytes"
1032
- });
1033
- if (!response.ok) throw new ClientResponseError({
1034
- status: response.status,
1035
- headers: response.headers,
1036
- data: response.data
1037
- });
1038
- return {
1039
- bytes: response.data,
1040
- mimeType: response.headers.get("content-type") ?? "application/octet-stream"
1041
- };
1042
- }
1043
- /**
1044
- * List blobs in repository
1045
- */
1046
- async listBlobs(did, cursor) {
1047
- return ok(this.client.get("com.atproto.sync.listBlobs", { params: {
1048
- did,
1049
- ...cursor && { cursor }
1050
- } }));
1051
- }
1052
- /**
1053
- * Get user preferences
1054
- */
1055
- async getPreferences() {
1056
- return (await ok(this.client.get("app.bsky.actor.getPreferences", { params: {} }))).preferences;
1057
- }
1058
- /**
1059
- * Update user preferences
592
+ * Import repository from CAR file
1060
593
  */
1061
- async putPreferences(preferences) {
1062
- const url = new URL("/xrpc/app.bsky.actor.putPreferences", this.baseUrl);
1063
- const headers = { "Content-Type": "application/json" };
594
+ async importRepo(carBytes) {
595
+ const url = new URL("/xrpc/com.atproto.repo.importRepo", this.baseUrl);
596
+ const headers = { "Content-Type": "application/vnd.ipld.car" };
1064
597
  if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
1065
598
  const res = await fetch(url.toString(), {
1066
599
  method: "POST",
1067
600
  headers,
1068
- body: JSON.stringify({ preferences })
601
+ body: carBytes
1069
602
  });
1070
603
  if (!res.ok) {
1071
604
  const errorBody = await res.json().catch(() => ({}));
@@ -1078,58 +611,10 @@ var PDSClient = class PDSClient {
1078
611
  }
1079
612
  });
1080
613
  }
614
+ return res.json();
1081
615
  }
1082
616
  /**
1083
- * Get account status including migration progress
1084
- */
1085
- async getAccountStatus() {
1086
- const url = new URL("/xrpc/com.atproto.server.getAccountStatus", this.baseUrl);
1087
- const headers = {};
1088
- if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
1089
- const res = await fetch(url.toString(), {
1090
- method: "GET",
1091
- headers
1092
- });
1093
- if (!res.ok) {
1094
- const errorBody = await res.json().catch(() => ({}));
1095
- throw new ClientResponseError({
1096
- status: res.status,
1097
- headers: res.headers,
1098
- data: {
1099
- error: errorBody.error ?? "Unknown",
1100
- message: errorBody.message
1101
- }
1102
- });
1103
- }
1104
- return res.json();
1105
- }
1106
- /**
1107
- * Import repository from CAR file
1108
- */
1109
- async importRepo(carBytes) {
1110
- const url = new URL("/xrpc/com.atproto.repo.importRepo", this.baseUrl);
1111
- const headers = { "Content-Type": "application/vnd.ipld.car" };
1112
- if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
1113
- const res = await fetch(url.toString(), {
1114
- method: "POST",
1115
- headers,
1116
- body: carBytes
1117
- });
1118
- if (!res.ok) {
1119
- const errorBody = await res.json().catch(() => ({}));
1120
- throw new ClientResponseError({
1121
- status: res.status,
1122
- headers: res.headers,
1123
- data: {
1124
- error: errorBody.error ?? "Unknown",
1125
- message: errorBody.message
1126
- }
1127
- });
1128
- }
1129
- return res.json();
1130
- }
1131
- /**
1132
- * List blobs that are missing (referenced but not imported)
617
+ * List blobs that are missing (referenced but not imported)
1133
618
  */
1134
619
  async listMissingBlobs(limit, cursor) {
1135
620
  return ok(this.client.get("com.atproto.repo.listMissingBlobs", { params: {
@@ -1303,121 +788,1062 @@ var PDSClient = class PDSClient {
1303
788
  }
1304
789
  });
1305
790
  }
1306
- return res.json();
1307
- }
1308
- /**
1309
- * Check handle verification via HTTP well-known
1310
- */
1311
- async checkHandleViaHttp(handle) {
1312
- try {
1313
- const res = await fetch(`https://${handle}/.well-known/atproto-did`);
1314
- if (!res.ok) return null;
1315
- return (await res.text()).trim() || null;
1316
- } catch {
1317
- return null;
1318
- }
1319
- }
1320
- /**
1321
- * Check handle verification via DNS TXT record (using DNS-over-HTTPS)
1322
- */
1323
- async checkHandleViaDns(handle) {
1324
- try {
1325
- const res = await fetch(`https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, { headers: { Accept: "application/dns-json" } });
1326
- if (!res.ok) return null;
1327
- const txtRecord = (await res.json()).Answer?.find((a) => a.data?.includes("did="));
1328
- if (!txtRecord) return null;
1329
- return txtRecord.data.match(/did=([^\s"]+)/)?.[1] ?? null;
1330
- } catch {
1331
- return null;
1332
- }
1333
- }
1334
- /**
1335
- * Get a record from the repository
1336
- */
1337
- async getRecord(repo, collection, rkey) {
1338
- try {
1339
- return await ok(this.client.get("com.atproto.repo.getRecord", { params: {
1340
- repo,
1341
- collection,
1342
- rkey
1343
- } }));
1344
- } catch (err) {
1345
- if (err instanceof ClientResponseError && err.status === 404) return null;
1346
- throw err;
1347
- }
1348
- }
1349
- /**
1350
- * Create or update a record in the repository
1351
- */
1352
- async putRecord(repo, collection, rkey, record) {
1353
- return ok(this.client.post("com.atproto.repo.putRecord", { input: {
1354
- repo,
1355
- collection,
1356
- rkey,
1357
- record
1358
- } }));
1359
- }
1360
- /**
1361
- * Get the user's profile record
1362
- */
1363
- async getProfile(did) {
1364
- const record = await this.getRecord(did, "app.bsky.actor.profile", "self");
1365
- if (!record) return null;
1366
- return record.value;
1367
- }
1368
- /**
1369
- * Create or update the user's profile
1370
- */
1371
- async putProfile(did, profile) {
1372
- return this.putRecord(did, "app.bsky.actor.profile", "self", {
1373
- $type: "app.bsky.actor.profile",
1374
- ...profile
791
+ return res.json();
792
+ }
793
+ /**
794
+ * Check handle verification via HTTP well-known
795
+ */
796
+ async checkHandleViaHttp(handle) {
797
+ try {
798
+ const res = await fetch(`https://${handle}/.well-known/atproto-did`);
799
+ if (!res.ok) return null;
800
+ return (await res.text()).trim() || null;
801
+ } catch {
802
+ return null;
803
+ }
804
+ }
805
+ /**
806
+ * Check handle verification via DNS TXT record (using DNS-over-HTTPS)
807
+ */
808
+ async checkHandleViaDns(handle) {
809
+ try {
810
+ const res = await fetch(`https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, { headers: { Accept: "application/dns-json" } });
811
+ if (!res.ok) return null;
812
+ const txtRecord = (await res.json()).Answer?.find((a) => a.data?.includes("did="));
813
+ if (!txtRecord) return null;
814
+ return txtRecord.data.match(/did=([^\s"]+)/)?.[1] ?? null;
815
+ } catch {
816
+ return null;
817
+ }
818
+ }
819
+ /**
820
+ * Get a record from the repository
821
+ */
822
+ async getRecord(repo, collection, rkey) {
823
+ try {
824
+ return await ok(this.client.get("com.atproto.repo.getRecord", { params: {
825
+ repo,
826
+ collection,
827
+ rkey
828
+ } }));
829
+ } catch (err) {
830
+ if (err instanceof ClientResponseError && err.status === 404) return null;
831
+ throw err;
832
+ }
833
+ }
834
+ /**
835
+ * Create or update a record in the repository
836
+ */
837
+ async putRecord(repo, collection, rkey, record) {
838
+ return ok(this.client.post("com.atproto.repo.putRecord", { input: {
839
+ repo,
840
+ collection,
841
+ rkey,
842
+ record
843
+ } }));
844
+ }
845
+ /**
846
+ * Get the user's profile record
847
+ */
848
+ async getProfile(did) {
849
+ const record = await this.getRecord(did, "app.bsky.actor.profile", "self");
850
+ if (!record) return null;
851
+ return record.value;
852
+ }
853
+ /**
854
+ * Create or update the user's profile
855
+ */
856
+ async putProfile(did, profile) {
857
+ return this.putRecord(did, "app.bsky.actor.profile", "self", {
858
+ $type: "app.bsky.actor.profile",
859
+ ...profile
860
+ });
861
+ }
862
+ /**
863
+ * Initialize passkey registration
864
+ * Returns a URL for the user to visit on their device
865
+ */
866
+ async initPasskeyRegistration(name) {
867
+ const url = new URL("/passkey/init", this.baseUrl);
868
+ const headers = { "Content-Type": "application/json" };
869
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
870
+ const res = await fetch(url.toString(), {
871
+ method: "POST",
872
+ headers,
873
+ body: JSON.stringify({ name })
874
+ });
875
+ if (!res.ok) {
876
+ const errorBody = await res.json().catch(() => ({}));
877
+ throw new ClientResponseError({
878
+ status: res.status,
879
+ headers: res.headers,
880
+ data: {
881
+ error: errorBody.error ?? "Unknown",
882
+ message: errorBody.message
883
+ }
884
+ });
885
+ }
886
+ return res.json();
887
+ }
888
+ /**
889
+ * List all registered passkeys
890
+ */
891
+ async listPasskeys() {
892
+ const url = new URL("/passkey/list", this.baseUrl);
893
+ const headers = {};
894
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
895
+ const res = await fetch(url.toString(), {
896
+ method: "GET",
897
+ headers
898
+ });
899
+ if (!res.ok) {
900
+ const errorBody = await res.json().catch(() => ({}));
901
+ throw new ClientResponseError({
902
+ status: res.status,
903
+ headers: res.headers,
904
+ data: {
905
+ error: errorBody.error ?? "Unknown",
906
+ message: errorBody.message
907
+ }
908
+ });
909
+ }
910
+ return res.json();
911
+ }
912
+ /**
913
+ * Delete a passkey by credential ID
914
+ */
915
+ async deletePasskey(credentialId) {
916
+ const url = new URL("/passkey/delete", this.baseUrl);
917
+ const headers = { "Content-Type": "application/json" };
918
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
919
+ const res = await fetch(url.toString(), {
920
+ method: "POST",
921
+ headers,
922
+ body: JSON.stringify({ id: credentialId })
923
+ });
924
+ if (!res.ok) {
925
+ const errorBody = await res.json().catch(() => ({}));
926
+ throw new ClientResponseError({
927
+ status: res.status,
928
+ headers: res.headers,
929
+ data: {
930
+ error: errorBody.error ?? "Unknown",
931
+ message: errorBody.message
932
+ }
933
+ });
934
+ }
935
+ return res.json();
936
+ }
937
+ static RELAY_URLS = ["https://relay1.us-west.bsky.network", "https://relay1.us-east.bsky.network"];
938
+ /**
939
+ * Get relay's view of this PDS host status from a single relay.
940
+ * Calls com.atproto.sync.getHostStatus on the relay.
941
+ */
942
+ async getRelayHostStatus(pdsHostname, relayUrl) {
943
+ try {
944
+ const url = new URL("/xrpc/com.atproto.sync.getHostStatus", relayUrl);
945
+ url.searchParams.set("hostname", pdsHostname);
946
+ const res = await fetch(url.toString());
947
+ if (!res.ok) return null;
948
+ return {
949
+ ...await res.json(),
950
+ relay: relayUrl
951
+ };
952
+ } catch {
953
+ return null;
954
+ }
955
+ }
956
+ /**
957
+ * Get relay status from all known relays.
958
+ * Returns results from each relay that responds.
959
+ */
960
+ async getAllRelayHostStatus(pdsHostname) {
961
+ return (await Promise.all(PDSClient.RELAY_URLS.map((url) => this.getRelayHostStatus(pdsHostname, url)))).filter((r) => r !== null);
962
+ }
963
+ /**
964
+ * Request the relay to crawl this PDS.
965
+ * This notifies the Bluesky relay that the PDS is active and ready for federation.
966
+ * Uses bsky.network by default (the main relay endpoint).
967
+ */
968
+ async requestCrawl(pdsHostname, relayUrl = "https://bsky.network") {
969
+ try {
970
+ const url = new URL("/xrpc/com.atproto.sync.requestCrawl", relayUrl);
971
+ return (await fetch(url.toString(), {
972
+ method: "POST",
973
+ headers: { "Content-Type": "application/json" },
974
+ body: JSON.stringify({ hostname: pdsHostname })
975
+ })).ok;
976
+ } catch {
977
+ return false;
978
+ }
979
+ }
980
+ };
981
+
982
+ //#endregion
983
+ //#region src/cli/utils/cli-helpers.ts
984
+ /**
985
+ * Shared CLI utilities for PDS commands
986
+ */
987
+ /**
988
+ * Prompt for text input, exiting on cancel
989
+ */
990
+ async function promptText(options) {
991
+ const result = await p.text(options);
992
+ if (p.isCancel(result)) {
993
+ p.cancel("Cancelled");
994
+ process.exit(0);
995
+ }
996
+ return result;
997
+ }
998
+ /**
999
+ * Prompt for confirmation, exiting on cancel
1000
+ */
1001
+ async function promptConfirm(options) {
1002
+ const result = await p.confirm(options);
1003
+ if (p.isCancel(result)) {
1004
+ p.cancel("Cancelled");
1005
+ process.exit(0);
1006
+ }
1007
+ return result;
1008
+ }
1009
+ /**
1010
+ * Prompt for selection, exiting on cancel
1011
+ */
1012
+ async function promptSelect(options) {
1013
+ const result = await p.select(options);
1014
+ if (p.isCancel(result)) {
1015
+ p.cancel("Cancelled");
1016
+ process.exit(0);
1017
+ }
1018
+ return result;
1019
+ }
1020
+ /**
1021
+ * Get target PDS URL based on mode
1022
+ */
1023
+ function getTargetUrl(isDev, pdsHostname) {
1024
+ if (isDev) return `http://localhost:${process.env.PORT ? parseInt(process.env.PORT) ?? "5173" : "5173"}`;
1025
+ if (!pdsHostname) throw new Error("PDS_HOSTNAME not configured in wrangler.jsonc");
1026
+ return `https://${pdsHostname}`;
1027
+ }
1028
+ /**
1029
+ * Extract domain from URL
1030
+ */
1031
+ function getDomain(url) {
1032
+ try {
1033
+ return new URL(url).hostname;
1034
+ } catch {
1035
+ return url;
1036
+ }
1037
+ }
1038
+ /**
1039
+ * Detect which package manager is being used based on npm_config_user_agent
1040
+ */
1041
+ function detectPackageManager() {
1042
+ const userAgent = process.env.npm_config_user_agent || "";
1043
+ if (userAgent.startsWith("yarn")) return "yarn";
1044
+ if (userAgent.startsWith("pnpm")) return "pnpm";
1045
+ if (userAgent.startsWith("bun")) return "bun";
1046
+ return "npm";
1047
+ }
1048
+ /**
1049
+ * Format a command for the detected package manager
1050
+ * npm always needs "run" for scripts, pnpm/yarn/bun can use shorthand
1051
+ * except for "deploy" which conflicts with pnpm's built-in deploy command
1052
+ */
1053
+ function formatCommand(pm, ...args) {
1054
+ if (pm === "npm" || args[0] === "deploy") return `${pm} run ${args.join(" ")}`;
1055
+ return `${pm} ${args.join(" ")}`;
1056
+ }
1057
+
1058
+ //#endregion
1059
+ //#region src/cli/commands/passkey/add.ts
1060
+ /**
1061
+ * Add passkey command
1062
+ *
1063
+ * Generates a registration URL for the user to visit on their device.
1064
+ */
1065
+ const addCommand = defineCommand({
1066
+ meta: {
1067
+ name: "add",
1068
+ description: "Add a new passkey to your account"
1069
+ },
1070
+ args: {
1071
+ dev: {
1072
+ type: "boolean",
1073
+ description: "Target local development server instead of production",
1074
+ default: false
1075
+ },
1076
+ name: {
1077
+ type: "string",
1078
+ alias: "n",
1079
+ description: "Name for this passkey (e.g., 'iPhone', 'MacBook')"
1080
+ }
1081
+ },
1082
+ async run({ args }) {
1083
+ const isDev = args.dev;
1084
+ p.intro("🔐 Add Passkey");
1085
+ const vars = getVars();
1086
+ let targetUrl;
1087
+ try {
1088
+ targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME);
1089
+ } catch (err) {
1090
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
1091
+ p.log.info("Run 'pds init' first to configure your PDS.");
1092
+ process.exit(1);
1093
+ }
1094
+ const targetDomain = getDomain(targetUrl);
1095
+ const wranglerVars = getVars();
1096
+ const authToken = {
1097
+ ...readDevVars(),
1098
+ ...wranglerVars
1099
+ }.AUTH_TOKEN;
1100
+ if (!authToken) {
1101
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
1102
+ p.outro("Cancelled.");
1103
+ process.exit(1);
1104
+ }
1105
+ let passkeyName = args.name;
1106
+ if (!passkeyName) passkeyName = await promptText({
1107
+ message: "Name for this passkey (optional):",
1108
+ placeholder: "iPhone, MacBook, etc."
1109
+ }) || void 0;
1110
+ const client = new PDSClient(targetUrl, authToken);
1111
+ const spinner = p.spinner();
1112
+ spinner.start(`Checking PDS at ${targetDomain}...`);
1113
+ if (!await client.healthCheck()) {
1114
+ spinner.stop(`PDS not responding at ${targetDomain}`);
1115
+ p.log.error(`Your PDS isn't responding at ${targetUrl}`);
1116
+ p.outro("Cancelled.");
1117
+ process.exit(1);
1118
+ }
1119
+ spinner.stop(`Connected to ${targetDomain}`);
1120
+ spinner.start("Generating registration link...");
1121
+ let registration;
1122
+ try {
1123
+ registration = await client.initPasskeyRegistration(passkeyName);
1124
+ spinner.stop("Registration link ready");
1125
+ } catch (err) {
1126
+ spinner.stop("Failed to generate registration link");
1127
+ let errorMessage = "Could not generate registration link";
1128
+ if (err instanceof Error) errorMessage = err.message;
1129
+ const errObj = err;
1130
+ if (errObj?.data?.message) errorMessage = errObj.data.message;
1131
+ else if (errObj?.data?.error) errorMessage = errObj.data.error;
1132
+ p.log.error(errorMessage);
1133
+ p.outro("Cancelled.");
1134
+ process.exit(1);
1135
+ }
1136
+ const expiresIn = Math.round((registration.expiresAt - Date.now()) / 1e3 / 60);
1137
+ p.log.info("");
1138
+ p.log.info(pc.bold("Scan this QR code on your phone, or open the URL:"));
1139
+ p.log.info("");
1140
+ const qrString = await QRCode.toString(registration.url, {
1141
+ type: "terminal",
1142
+ small: true
1143
+ });
1144
+ console.log(qrString);
1145
+ p.log.info(` ${pc.cyan(registration.url)}`);
1146
+ p.log.info("");
1147
+ p.log.info(pc.dim(`This link expires in ${expiresIn} minutes.`));
1148
+ p.log.info("");
1149
+ const done = await p.text({
1150
+ message: "Press Enter when you've completed registration on your device",
1151
+ placeholder: "(or Ctrl+C to cancel)",
1152
+ defaultValue: ""
1153
+ });
1154
+ if (p.isCancel(done)) {
1155
+ p.cancel("Cancelled.");
1156
+ process.exit(0);
1157
+ }
1158
+ spinner.start("Verifying registration...");
1159
+ try {
1160
+ const result = await client.listPasskeys();
1161
+ const twoMinutesAgo = Date.now() - 120 * 1e3;
1162
+ if (result.passkeys.some((pk) => {
1163
+ return (/* @__PURE__ */ new Date(pk.createdAt.replace(" ", "T") + "Z")).getTime() > twoMinutesAgo;
1164
+ })) {
1165
+ spinner.stop("Passkey registered successfully!");
1166
+ p.log.success("Your passkey is ready to use.");
1167
+ } else {
1168
+ spinner.stop("Registration not detected");
1169
+ p.log.warn("Could not verify registration. Check 'pds passkey list' to see your passkeys.");
1170
+ }
1171
+ } catch {
1172
+ spinner.stop("Could not verify registration");
1173
+ p.log.warn("Check 'pds passkey list' to see your passkeys.");
1174
+ }
1175
+ p.outro("Done!");
1176
+ }
1177
+ });
1178
+
1179
+ //#endregion
1180
+ //#region src/cli/commands/passkey/list.ts
1181
+ /**
1182
+ * List passkeys command
1183
+ */
1184
+ /**
1185
+ * Format a date as yyyy-mm-dd hh:mm
1186
+ */
1187
+ function formatDateTime(isoString) {
1188
+ const d = new Date(isoString);
1189
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1190
+ }
1191
+ const listCommand = defineCommand({
1192
+ meta: {
1193
+ name: "list",
1194
+ description: "List all registered passkeys"
1195
+ },
1196
+ args: { dev: {
1197
+ type: "boolean",
1198
+ description: "Target local development server instead of production",
1199
+ default: false
1200
+ } },
1201
+ async run({ args }) {
1202
+ const isDev = args.dev;
1203
+ p.intro("🔐 Passkeys");
1204
+ const vars = getVars();
1205
+ let targetUrl;
1206
+ try {
1207
+ targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME);
1208
+ } catch (err) {
1209
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
1210
+ p.log.info("Run 'pds init' first to configure your PDS.");
1211
+ process.exit(1);
1212
+ }
1213
+ const targetDomain = getDomain(targetUrl);
1214
+ const wranglerVars = getVars();
1215
+ const authToken = {
1216
+ ...readDevVars(),
1217
+ ...wranglerVars
1218
+ }.AUTH_TOKEN;
1219
+ if (!authToken) {
1220
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
1221
+ p.outro("Cancelled.");
1222
+ process.exit(1);
1223
+ }
1224
+ const client = new PDSClient(targetUrl, authToken);
1225
+ const spinner = p.spinner();
1226
+ spinner.start(`Checking PDS at ${targetDomain}...`);
1227
+ if (!await client.healthCheck()) {
1228
+ spinner.stop(`PDS not responding at ${targetDomain}`);
1229
+ p.log.error(`Your PDS isn't responding at ${targetUrl}`);
1230
+ p.outro("Cancelled.");
1231
+ process.exit(1);
1232
+ }
1233
+ spinner.stop(`Connected to ${targetDomain}`);
1234
+ spinner.start("Fetching passkeys...");
1235
+ let result;
1236
+ try {
1237
+ result = await client.listPasskeys();
1238
+ spinner.stop("Passkeys retrieved");
1239
+ } catch (err) {
1240
+ spinner.stop("Failed to fetch passkeys");
1241
+ p.log.error(err instanceof Error ? err.message : "Could not fetch passkeys");
1242
+ p.outro("Failed.");
1243
+ process.exit(1);
1244
+ }
1245
+ if (result.passkeys.length === 0) {
1246
+ p.log.info("No passkeys registered.");
1247
+ p.log.info(`Run ${pc.cyan("pds passkey add")} to register a passkey.`);
1248
+ } else {
1249
+ p.log.info("");
1250
+ p.log.info(`${pc.bold("Registered passkeys:")}`);
1251
+ p.log.info("");
1252
+ for (const pk of result.passkeys) {
1253
+ const name = pk.name || pc.dim("(unnamed)");
1254
+ const created = formatDateTime(pk.createdAt);
1255
+ const lastUsed = pk.lastUsedAt ? formatDateTime(pk.lastUsedAt) : pc.dim("never");
1256
+ const idPreview = pk.id.slice(0, 16) + "...";
1257
+ console.log(` ${pc.green("●")} ${pc.bold(name)}`);
1258
+ console.log(` ${pc.dim("ID:")} ${idPreview}`);
1259
+ console.log(` ${pc.dim("Created:")} ${created} ${pc.dim("Last used:")} ${lastUsed}`);
1260
+ console.log("");
1261
+ }
1262
+ p.log.info(pc.dim(`Total: ${result.passkeys.length} passkey(s)`));
1263
+ }
1264
+ p.outro("Done!");
1265
+ }
1266
+ });
1267
+
1268
+ //#endregion
1269
+ //#region src/cli/commands/passkey/remove.ts
1270
+ /**
1271
+ * Remove passkey command
1272
+ */
1273
+ const removeCommand = defineCommand({
1274
+ meta: {
1275
+ name: "remove",
1276
+ description: "Remove a passkey from your account"
1277
+ },
1278
+ args: {
1279
+ dev: {
1280
+ type: "boolean",
1281
+ description: "Target local development server instead of production",
1282
+ default: false
1283
+ },
1284
+ id: {
1285
+ type: "string",
1286
+ description: "Credential ID of the passkey to remove"
1287
+ },
1288
+ yes: {
1289
+ type: "boolean",
1290
+ alias: "y",
1291
+ description: "Skip confirmation",
1292
+ default: false
1293
+ }
1294
+ },
1295
+ async run({ args }) {
1296
+ const isDev = args.dev;
1297
+ const skipConfirm = args.yes;
1298
+ p.intro("🔐 Remove Passkey");
1299
+ const vars = getVars();
1300
+ let targetUrl;
1301
+ try {
1302
+ targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME);
1303
+ } catch (err) {
1304
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
1305
+ p.log.info("Run 'pds init' first to configure your PDS.");
1306
+ process.exit(1);
1307
+ }
1308
+ const targetDomain = getDomain(targetUrl);
1309
+ const wranglerVars = getVars();
1310
+ const authToken = {
1311
+ ...readDevVars(),
1312
+ ...wranglerVars
1313
+ }.AUTH_TOKEN;
1314
+ if (!authToken) {
1315
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
1316
+ p.outro("Cancelled.");
1317
+ process.exit(1);
1318
+ }
1319
+ const client = new PDSClient(targetUrl, authToken);
1320
+ const spinner = p.spinner();
1321
+ spinner.start(`Checking PDS at ${targetDomain}...`);
1322
+ if (!await client.healthCheck()) {
1323
+ spinner.stop(`PDS not responding at ${targetDomain}`);
1324
+ p.log.error(`Your PDS isn't responding at ${targetUrl}`);
1325
+ p.outro("Cancelled.");
1326
+ process.exit(1);
1327
+ }
1328
+ spinner.stop(`Connected to ${targetDomain}`);
1329
+ let credentialId = args.id;
1330
+ if (!credentialId) {
1331
+ spinner.start("Fetching passkeys...");
1332
+ let result;
1333
+ try {
1334
+ result = await client.listPasskeys();
1335
+ spinner.stop("Passkeys retrieved");
1336
+ } catch (err) {
1337
+ spinner.stop("Failed to fetch passkeys");
1338
+ p.log.error(err instanceof Error ? err.message : "Could not fetch passkeys");
1339
+ p.outro("Failed.");
1340
+ process.exit(1);
1341
+ }
1342
+ if (result.passkeys.length === 0) {
1343
+ p.log.info("No passkeys to remove.");
1344
+ p.outro("Done!");
1345
+ return;
1346
+ }
1347
+ const options = result.passkeys.map((pk) => ({
1348
+ value: pk.id,
1349
+ label: pk.name || "(unnamed)",
1350
+ hint: `Created ${new Date(pk.createdAt).toLocaleDateString()}`
1351
+ }));
1352
+ const selected = await p.select({
1353
+ message: "Select passkey to remove:",
1354
+ options
1355
+ });
1356
+ if (p.isCancel(selected)) {
1357
+ p.cancel("Cancelled.");
1358
+ process.exit(0);
1359
+ }
1360
+ credentialId = selected;
1361
+ }
1362
+ if (!skipConfirm) {
1363
+ const confirm = await p.confirm({
1364
+ message: `Remove this passkey? This cannot be undone.`,
1365
+ initialValue: false
1366
+ });
1367
+ if (p.isCancel(confirm) || !confirm) {
1368
+ p.cancel("Cancelled.");
1369
+ process.exit(0);
1370
+ }
1371
+ }
1372
+ spinner.start("Removing passkey...");
1373
+ try {
1374
+ if ((await client.deletePasskey(credentialId)).success) {
1375
+ spinner.stop("Passkey removed");
1376
+ p.log.success("Passkey has been removed from your account.");
1377
+ } else {
1378
+ spinner.stop("Failed to remove passkey");
1379
+ p.log.error("Passkey not found or could not be removed.");
1380
+ }
1381
+ } catch (err) {
1382
+ spinner.stop("Failed to remove passkey");
1383
+ p.log.error(err instanceof Error ? err.message : "Could not remove passkey");
1384
+ p.outro("Failed.");
1385
+ process.exit(1);
1386
+ }
1387
+ p.outro("Done!");
1388
+ }
1389
+ });
1390
+
1391
+ //#endregion
1392
+ //#region src/cli/commands/passkey/index.ts
1393
+ /**
1394
+ * Passkey management commands
1395
+ */
1396
+ const passkeyCommand = defineCommand({
1397
+ meta: {
1398
+ name: "passkey",
1399
+ description: "Manage passkeys for passwordless authentication"
1400
+ },
1401
+ subCommands: {
1402
+ add: addCommand,
1403
+ list: listCommand,
1404
+ remove: removeCommand
1405
+ }
1406
+ });
1407
+
1408
+ //#endregion
1409
+ //#region src/cli/utils/handle-resolver.ts
1410
+ /**
1411
+ * Utilities for resolving AT Protocol handles to DIDs
1412
+ */
1413
+ const resolver = new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" });
1414
+ /**
1415
+ * Resolve a handle to a DID using the AT Protocol handle resolution methods.
1416
+ * Uses DNS-over-HTTPS via Cloudflare for DNS resolution.
1417
+ */
1418
+ async function resolveHandleToDid(handle) {
1419
+ try {
1420
+ return await resolver.resolve(handle, { signal: AbortSignal.timeout(1e4) });
1421
+ } catch {
1422
+ return null;
1423
+ }
1424
+ }
1425
+
1426
+ //#endregion
1427
+ //#region src/did-resolver.ts
1428
+ /**
1429
+ * DID resolution for Cloudflare Workers
1430
+ *
1431
+ * Uses @atcute/identity-resolver which is already Workers-compatible
1432
+ * (uses redirect: "manual" internally).
1433
+ */
1434
+ const PLC_DIRECTORY = "https://plc.directory";
1435
+ const TIMEOUT_MS = 3e3;
1436
+ /**
1437
+ * Wrapper that always uses globalThis.fetch so it can be mocked in tests.
1438
+ * @atcute resolvers capture the fetch reference at construction time,
1439
+ * so we need this indirection to allow test mocking.
1440
+ */
1441
+ const stubbableFetch = (input, init) => globalThis.fetch(input, init);
1442
+ var DidResolver = class {
1443
+ resolver;
1444
+ timeout;
1445
+ cache;
1446
+ constructor(opts = {}) {
1447
+ this.timeout = opts.timeout ?? TIMEOUT_MS;
1448
+ this.cache = opts.didCache;
1449
+ this.resolver = new CompositeDidDocumentResolver({ methods: {
1450
+ plc: new PlcDidDocumentResolver({
1451
+ apiUrl: opts.plcUrl ?? PLC_DIRECTORY,
1452
+ fetch: stubbableFetch
1453
+ }),
1454
+ web: new WebDidDocumentResolver({ fetch: stubbableFetch })
1455
+ } });
1456
+ }
1457
+ async resolve(did) {
1458
+ if (this.cache) {
1459
+ const cached = await this.cache.checkCache(did);
1460
+ if (cached && !cached.expired) {
1461
+ if (cached.stale) this.cache.refreshCache(did, () => this.resolveNoCache(did), cached);
1462
+ return cached.doc;
1463
+ }
1464
+ }
1465
+ const doc = await this.resolveNoCache(did);
1466
+ if (doc && this.cache) await this.cache.cacheDid(did, doc);
1467
+ else if (!doc && this.cache) await this.cache.clearEntry(did);
1468
+ return doc;
1469
+ }
1470
+ async resolveNoCache(did) {
1471
+ const controller = new AbortController();
1472
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
1473
+ try {
1474
+ const doc = await this.resolver.resolve(did, { signal: controller.signal });
1475
+ if (doc.id !== did) return null;
1476
+ return doc;
1477
+ } catch {
1478
+ return null;
1479
+ } finally {
1480
+ clearTimeout(timeoutId);
1481
+ }
1482
+ }
1483
+ };
1484
+
1485
+ //#endregion
1486
+ //#region src/cli/commands/init.ts
1487
+ /**
1488
+ * Interactive PDS setup wizard
1489
+ */
1490
+ /**
1491
+ * Slugify a handle to create a worker name
1492
+ * e.g., "example.com" -> "example-com-pds"
1493
+ */
1494
+ function slugifyHandle(handle) {
1495
+ return handle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-pds";
1496
+ }
1497
+ const defaultWorkerName = "my-pds";
1498
+ /**
1499
+ * Prompt for worker name with validation
1500
+ */
1501
+ async function promptWorkerName(handle, currentWorkerName) {
1502
+ const placeholder = currentWorkerName && currentWorkerName !== defaultWorkerName ? currentWorkerName : slugifyHandle(handle);
1503
+ return promptText({
1504
+ message: "Cloudflare Worker name:",
1505
+ placeholder,
1506
+ initialValue: placeholder,
1507
+ validate: (v) => {
1508
+ if (!v) return "Worker name is required";
1509
+ if (!/^[a-z0-9-]+$/.test(v)) return "Worker name can only contain lowercase letters, numbers, and hyphens";
1510
+ }
1511
+ });
1512
+ }
1513
+ /**
1514
+ * Ensure a Cloudflare account_id is configured.
1515
+ * If multiple accounts detected, prompts user to select one.
1516
+ */
1517
+ async function ensureAccountConfigured() {
1518
+ const spinner = p.spinner();
1519
+ spinner.start("Checking Cloudflare account...");
1520
+ const accounts = await detectCloudflareAccounts();
1521
+ if (accounts === null) {
1522
+ spinner.stop("Cloudflare account configured");
1523
+ return;
1524
+ }
1525
+ spinner.stop(`Found ${accounts.length} Cloudflare accounts`);
1526
+ const selectedId = await promptSelect({
1527
+ message: "Select your Cloudflare account:",
1528
+ options: accounts.map((acc) => ({
1529
+ value: acc.id,
1530
+ label: acc.name,
1531
+ hint: acc.id.slice(0, 8) + "..."
1532
+ }))
1533
+ });
1534
+ setAccountId(selectedId);
1535
+ const selectedName = accounts.find((a) => a.id === selectedId)?.name;
1536
+ p.log.success(`Account "${selectedName}" saved to wrangler.jsonc`);
1537
+ }
1538
+ /**
1539
+ * Run wrangler types to regenerate TypeScript types
1540
+ */
1541
+ function runWranglerTypes() {
1542
+ return new Promise((resolve$1, reject) => {
1543
+ const child = spawn("wrangler", ["types"], { stdio: "pipe" });
1544
+ let output = "";
1545
+ child.stdout?.on("data", (data) => {
1546
+ output += data.toString();
1547
+ });
1548
+ child.stderr?.on("data", (data) => {
1549
+ output += data.toString();
1550
+ });
1551
+ child.on("close", (code) => {
1552
+ if (code === 0) resolve$1();
1553
+ else {
1554
+ if (output) console.error(output);
1555
+ reject(/* @__PURE__ */ new Error(`wrangler types failed with code ${code}`));
1556
+ }
1557
+ });
1558
+ child.on("error", reject);
1559
+ });
1560
+ }
1561
+ const initCommand = defineCommand({
1562
+ meta: {
1563
+ name: "init",
1564
+ description: "Interactive PDS setup wizard"
1565
+ },
1566
+ args: { production: {
1567
+ type: "boolean",
1568
+ description: "Deploy secrets to Cloudflare?",
1569
+ default: false
1570
+ } },
1571
+ async run({ args }) {
1572
+ const pm = detectPackageManager();
1573
+ p.intro("🦋 PDS Setup");
1574
+ const isProduction = args.production;
1575
+ if (isProduction) p.log.info("Production mode: secrets will be deployed to Cloudflare");
1576
+ p.log.info("Let's set up your new home in the Atmosphere!");
1577
+ const wranglerVars = getVars();
1578
+ const devVars = readDevVars();
1579
+ const currentVars = {
1580
+ ...devVars,
1581
+ ...wranglerVars
1582
+ };
1583
+ const isMigrating = await promptConfirm({
1584
+ message: "Are you migrating an existing Bluesky/ATProto account?",
1585
+ initialValue: false
1586
+ });
1587
+ let did;
1588
+ let handle;
1589
+ let hostname;
1590
+ let workerName;
1591
+ let initialActive;
1592
+ const currentWorkerName = getWorkerName();
1593
+ if (isMigrating) {
1594
+ p.log.info("Time to pack your bags! 🧳");
1595
+ p.log.info("Your new account will be inactive until you're ready to go live.");
1596
+ let hostedDomains = [
1597
+ ".bsky.social",
1598
+ ".bsky.network",
1599
+ ".bsky.team"
1600
+ ];
1601
+ const isHostedHandle = (h) => hostedDomains.some((domain) => h?.endsWith(domain));
1602
+ let resolvedDid = null;
1603
+ let existingHandle = null;
1604
+ let attempts = 0;
1605
+ const MAX_ATTEMPTS = 3;
1606
+ while (!resolvedDid && attempts < MAX_ATTEMPTS) {
1607
+ attempts++;
1608
+ const currentHandle = await promptText({
1609
+ message: "Your current Bluesky/ATProto handle:",
1610
+ placeholder: "example.bsky.social",
1611
+ validate: (v) => !v ? "Handle is required" : void 0
1612
+ });
1613
+ existingHandle = currentHandle;
1614
+ const spinner$1 = p.spinner();
1615
+ spinner$1.start("Finding you in the Atmosphere...");
1616
+ resolvedDid = await resolveHandleToDid(currentHandle);
1617
+ if (!resolvedDid) {
1618
+ spinner$1.stop("Not found");
1619
+ p.log.error(`Failed to resolve handle "${currentHandle}"`);
1620
+ if (await promptSelect({
1621
+ message: "What would you like to do?",
1622
+ options: [{
1623
+ value: "retry",
1624
+ label: "Try a different handle"
1625
+ }, {
1626
+ value: "manual",
1627
+ label: "Enter DID manually"
1628
+ }]
1629
+ }) === "manual") resolvedDid = await promptText({
1630
+ message: "Enter your DID:",
1631
+ placeholder: "did:plc:...",
1632
+ validate: (v) => {
1633
+ if (!v) return "DID is required";
1634
+ if (!v.startsWith("did:")) return "DID must start with did:";
1635
+ }
1636
+ });
1637
+ } else {
1638
+ try {
1639
+ const pdsService = (await new DidResolver().resolve(resolvedDid))?.service?.find((s) => s.type === "AtprotoPersonalDataServer" || s.id === "#atproto_pds");
1640
+ if (pdsService?.serviceEndpoint) {
1641
+ const describeRes = await fetch(`${pdsService.serviceEndpoint}/xrpc/com.atproto.server.describeServer`);
1642
+ if (describeRes.ok) {
1643
+ const desc = await describeRes.json();
1644
+ if (desc.availableUserDomains?.length) hostedDomains = desc.availableUserDomains.map((d) => d.startsWith(".") ? d : `.${d}`);
1645
+ }
1646
+ }
1647
+ } catch {}
1648
+ spinner$1.stop(`Found you! ${resolvedDid}`);
1649
+ if (isHostedHandle(existingHandle)) {
1650
+ const theirDomain = hostedDomains.find((d) => existingHandle?.endsWith(d));
1651
+ const domainExample = theirDomain ? `*${theirDomain}` : "*.bsky.social";
1652
+ p.log.warn(`You'll need a custom domain for your new handle (not ${domainExample}). You can set this up after transferring your data.`);
1653
+ }
1654
+ if (attempts >= MAX_ATTEMPTS) {
1655
+ p.log.error("Unable to resolve handle after 3 attempts.");
1656
+ p.log.info("");
1657
+ p.log.info("You can:");
1658
+ p.log.info(" 1. Double-check your handle spelling");
1659
+ p.log.info(" 2. Provide your DID directly if you know it");
1660
+ p.log.info(" 3. Run 'pds init' again when ready");
1661
+ p.outro("Initialization cancelled.");
1662
+ process.exit(1);
1663
+ }
1664
+ }
1665
+ }
1666
+ did = resolvedDid;
1667
+ handle = await promptText({
1668
+ message: "New account handle (must be a domain you control):",
1669
+ placeholder: "example.com",
1670
+ initialValue: existingHandle && !isHostedHandle(existingHandle) ? existingHandle : currentVars.HANDLE || "",
1671
+ validate: (v) => {
1672
+ if (!v) return "Handle is required";
1673
+ if (isHostedHandle(v)) return "You need a custom domain - hosted handles like *.bsky.social won't work";
1674
+ }
1675
+ });
1676
+ hostname = await promptText({
1677
+ message: "Domain where you'll deploy your PDS:",
1678
+ placeholder: handle,
1679
+ initialValue: currentVars.PDS_HOSTNAME || handle,
1680
+ validate: (v) => !v ? "Hostname is required" : void 0
1681
+ });
1682
+ workerName = await promptWorkerName(handle, currentWorkerName);
1683
+ initialActive = "false";
1684
+ await ensureAccountConfigured();
1685
+ } else {
1686
+ p.log.info("A fresh start in the Atmosphere! ✨");
1687
+ hostname = await promptText({
1688
+ message: "Domain where you'll deploy your PDS:",
1689
+ placeholder: "pds.example.com",
1690
+ initialValue: currentVars.PDS_HOSTNAME || "",
1691
+ validate: (v) => !v ? "Hostname is required" : void 0
1692
+ });
1693
+ handle = await promptText({
1694
+ message: "Account handle:",
1695
+ placeholder: hostname,
1696
+ initialValue: currentVars.HANDLE || hostname,
1697
+ validate: (v) => !v ? "Handle is required" : void 0
1698
+ });
1699
+ const didDefault = "did:web:" + hostname;
1700
+ did = await promptText({
1701
+ message: "Account DID:",
1702
+ placeholder: didDefault,
1703
+ initialValue: currentVars.DID || didDefault,
1704
+ validate: (v) => {
1705
+ if (!v) return "DID is required";
1706
+ if (!v.startsWith("did:")) return "DID must start with 'did:'";
1707
+ }
1708
+ });
1709
+ workerName = await promptWorkerName(handle, currentWorkerName);
1710
+ initialActive = "true";
1711
+ await ensureAccountConfigured();
1712
+ if (handle === hostname) p.note([
1713
+ "Your handle matches your PDS hostname, so your PDS will",
1714
+ "automatically handle domain verification for you!",
1715
+ "",
1716
+ "For did:web, your PDS serves the DID document at:",
1717
+ ` https://${hostname}/.well-known/did.json`,
1718
+ "",
1719
+ "For handle verification, it serves:",
1720
+ ` https://${hostname}/.well-known/atproto-did`,
1721
+ "",
1722
+ "No additional DNS or hosting setup needed. Easy! 🎉"
1723
+ ].join("\n"), "Identity Setup 🪪");
1724
+ else p.note([
1725
+ "For did:web, your PDS will serve the DID document at:",
1726
+ ` https://${hostname}/.well-known/did.json`,
1727
+ "",
1728
+ "To verify your handle, create a DNS TXT record:",
1729
+ ` _atproto.${handle} TXT "did=${did}"`,
1730
+ "",
1731
+ "Or serve a file at:",
1732
+ ` https://${handle}/.well-known/atproto-did`,
1733
+ ` containing: ${did}`
1734
+ ].join("\n"), "Identity Setup 🪪");
1735
+ }
1736
+ const spinner = p.spinner();
1737
+ const authToken = await getOrGenerateSecret("AUTH_TOKEN", devVars, async () => {
1738
+ spinner.start("Generating auth token...");
1739
+ const token = generateAuthToken();
1740
+ spinner.stop("Auth token generated");
1741
+ return token;
1375
1742
  });
1376
- }
1377
- static RELAY_URLS = ["https://relay1.us-west.bsky.network", "https://relay1.us-east.bsky.network"];
1378
- /**
1379
- * Get relay's view of this PDS host status from a single relay.
1380
- * Calls com.atproto.sync.getHostStatus on the relay.
1381
- */
1382
- async getRelayHostStatus(pdsHostname, relayUrl) {
1743
+ const signingKey = await getOrGenerateSecret("SIGNING_KEY", devVars, async () => {
1744
+ spinner.start("Generating signing keypair...");
1745
+ const { privateKey } = await generateSigningKeypair();
1746
+ spinner.stop("Signing keypair generated");
1747
+ return privateKey;
1748
+ });
1749
+ const signingKeyPublic = await derivePublicKey(signingKey);
1750
+ const jwtSecret = await getOrGenerateSecret("JWT_SECRET", devVars, async () => {
1751
+ spinner.start("Generating JWT secret...");
1752
+ const secret = generateJwtSecret();
1753
+ spinner.stop("JWT secret generated");
1754
+ return secret;
1755
+ });
1756
+ const passwordHash = await getOrGenerateSecret("PASSWORD_HASH", devVars, async () => {
1757
+ const password = await promptPassword(handle);
1758
+ spinner.start("Hashing password...");
1759
+ const hash = await hashPassword(password);
1760
+ spinner.stop("Password hashed");
1761
+ return hash;
1762
+ });
1763
+ spinner.start("Updating wrangler.jsonc...");
1764
+ setWorkerName(workerName);
1765
+ setVars({
1766
+ PDS_HOSTNAME: hostname,
1767
+ DID: did,
1768
+ HANDLE: handle,
1769
+ SIGNING_KEY_PUBLIC: signingKeyPublic,
1770
+ INITIAL_ACTIVE: initialActive
1771
+ });
1772
+ setCustomDomains([hostname]);
1773
+ spinner.stop("wrangler.jsonc updated");
1774
+ const local = !isProduction;
1775
+ if (isProduction) spinner.start("Deploying secrets to Cloudflare...");
1776
+ else spinner.start("Writing secrets to .dev.vars...");
1777
+ await setSecretValue("AUTH_TOKEN", authToken, local);
1778
+ await setSecretValue("SIGNING_KEY", signingKey, local);
1779
+ await setSecretValue("JWT_SECRET", jwtSecret, local);
1780
+ await setSecretValue("PASSWORD_HASH", passwordHash, local);
1781
+ spinner.stop(isProduction ? "Secrets deployed" : "Secrets written to .dev.vars");
1782
+ spinner.start("Generating TypeScript types...");
1383
1783
  try {
1384
- const url = new URL("/xrpc/com.atproto.sync.getHostStatus", relayUrl);
1385
- url.searchParams.set("hostname", pdsHostname);
1386
- const res = await fetch(url.toString());
1387
- if (!res.ok) return null;
1388
- return {
1389
- ...await res.json(),
1390
- relay: relayUrl
1391
- };
1784
+ await runWranglerTypes();
1785
+ spinner.stop("TypeScript types generated");
1392
1786
  } catch {
1393
- return null;
1787
+ spinner.stop("Failed to generate types (wrangler types)");
1394
1788
  }
1395
- }
1396
- /**
1397
- * Get relay status from all known relays.
1398
- * Returns results from each relay that responds.
1399
- */
1400
- async getAllRelayHostStatus(pdsHostname) {
1401
- return (await Promise.all(PDSClient.RELAY_URLS.map((url) => this.getRelayHostStatus(pdsHostname, url)))).filter((r) => r !== null);
1402
- }
1403
- /**
1404
- * Request the relay to crawl this PDS.
1405
- * This notifies the Bluesky relay that the PDS is active and ready for federation.
1406
- * Uses bsky.network by default (the main relay endpoint).
1407
- */
1408
- async requestCrawl(pdsHostname, relayUrl = "https://bsky.network") {
1409
- try {
1410
- const url = new URL("/xrpc/com.atproto.sync.requestCrawl", relayUrl);
1411
- return (await fetch(url.toString(), {
1412
- method: "POST",
1413
- headers: { "Content-Type": "application/json" },
1414
- body: JSON.stringify({ hostname: pdsHostname })
1415
- })).ok;
1416
- } catch {
1417
- return false;
1789
+ p.note([
1790
+ " Worker name: " + workerName,
1791
+ " PDS hostname: " + hostname,
1792
+ " DID: " + did,
1793
+ " Handle: " + handle,
1794
+ " Public signing key: " + signingKeyPublic.slice(0, 20) + "...",
1795
+ "",
1796
+ isProduction ? "Secrets deployed to Cloudflare ☁️" : "Secrets saved to .dev.vars",
1797
+ "",
1798
+ "Auth token (save this!):",
1799
+ " " + authToken
1800
+ ].join("\n"), "Your New Home 🏠");
1801
+ let deployedSecrets = isProduction;
1802
+ if (!isProduction) {
1803
+ const deployNow = await p.confirm({
1804
+ message: "Push secrets to Cloudflare now?",
1805
+ initialValue: false
1806
+ });
1807
+ if (!p.isCancel(deployNow) && deployNow) {
1808
+ spinner.start("Deploying secrets to Cloudflare...");
1809
+ await setSecretValue("AUTH_TOKEN", authToken, false);
1810
+ await setSecretValue("SIGNING_KEY", signingKey, false);
1811
+ await setSecretValue("JWT_SECRET", jwtSecret, false);
1812
+ await setSecretValue("PASSWORD_HASH", passwordHash, false);
1813
+ spinner.stop("Secrets deployed to Cloudflare");
1814
+ deployedSecrets = true;
1815
+ }
1418
1816
  }
1817
+ if (isMigrating) p.note([
1818
+ deployedSecrets ? "Deploy your worker and run the migration:" : "Push secrets, deploy, and run the migration:",
1819
+ "",
1820
+ ...deployedSecrets ? [] : [` ${formatCommand(pm, "pds", "init", "--production")}`, ""],
1821
+ ` ${formatCommand(pm, "deploy")}`,
1822
+ ` ${formatCommand(pm, "pds", "migrate")}`,
1823
+ "",
1824
+ "To test locally first:",
1825
+ ` ${formatCommand(pm, "dev")} # in one terminal`,
1826
+ ` ${formatCommand(pm, "pds", "migrate", "--dev")} # in another`,
1827
+ "",
1828
+ "Then update your identity and flip the switch! 🦋",
1829
+ " https://atproto.com/guides/account-migration"
1830
+ ].join("\n"), "Next Steps 🧳");
1831
+ if (deployedSecrets) p.outro(`Run '${formatCommand(pm, "deploy")}' to launch your PDS! 🚀`);
1832
+ else p.outro(`Run '${formatCommand(pm, "dev")}' to start your PDS locally! 🦋`);
1419
1833
  }
1420
- };
1834
+ });
1835
+ /**
1836
+ * Helper to get a secret from .dev.vars or generate a new one
1837
+ */
1838
+ async function getOrGenerateSecret(name, devVars, generate) {
1839
+ if (devVars[name]) {
1840
+ if (await p.confirm({
1841
+ message: `Use ${name} from .dev.vars?`,
1842
+ initialValue: true
1843
+ }) === true) return devVars[name];
1844
+ }
1845
+ return generate();
1846
+ }
1421
1847
 
1422
1848
  //#endregion
1423
1849
  //#region src/cli/commands/migrate.ts
@@ -2600,6 +3026,7 @@ runMain(defineCommand({
2600
3026
  subCommands: {
2601
3027
  init: initCommand,
2602
3028
  secret: secretCommand,
3029
+ passkey: passkeyCommand,
2603
3030
  migrate: migrateCommand,
2604
3031
  activate: activateCommand,
2605
3032
  deactivate: deactivateCommand,