@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/README.md +28 -0
- package/dist/cli.js +1200 -773
- package/dist/index.d.ts +104 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +872 -2
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
432
|
+
* Create a fetch handler that adds optional auth token
|
|
464
433
|
*/
|
|
465
|
-
function
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
475
|
+
/**
|
|
476
|
+
* Get profile stats from AppView (posts, follows, followers counts)
|
|
477
|
+
*/
|
|
478
|
+
async getProfileStats(did) {
|
|
565
479
|
try {
|
|
566
|
-
const
|
|
567
|
-
if (
|
|
568
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
*
|
|
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
|
|
1062
|
-
const url = new URL("/xrpc/
|
|
1063
|
-
const headers = { "Content-Type": "application/
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1787
|
+
spinner.stop("Failed to generate types (wrangler types)");
|
|
1394
1788
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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,
|