@getcirrus/pds 0.6.0 → 0.7.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 CHANGED
@@ -8,9 +8,9 @@ Cirrus is a single-user [AT Protocol](https://atproto.com) Personal Data Server
8
8
 
9
9
  Host your own Bluesky identity with minimal infrastructure.
10
10
 
11
- > **⚠️ Experimental Software**
11
+ > **⚠️ Beta Software**
12
12
  >
13
- > This is an early-stage project under active development. **Do not migrate your main Bluesky account to this PDS yet.** Use a test account or create a new identity for experimentation. Data loss, breaking changes, and missing features are expected.
13
+ > This is under active development. Account migration has been tested and works, but breaking changes may still occur. Consider backing up important data before migrating a primary account.
14
14
 
15
15
  ## What is a PDS?
16
16
 
@@ -89,8 +89,7 @@ The package includes a CLI for setup, migration, and secret management.
89
89
  Interactive setup wizard for configuring the PDS.
90
90
 
91
91
  ```bash
92
- pds init # Configure for local development
93
- pds init --production # Deploy secrets to Cloudflare
92
+ pds init # Configure the PDS (prompts for Cloudflare deploy)
94
93
  ```
95
94
 
96
95
  **What it does:**
@@ -129,6 +128,26 @@ The migration is resumable. If interrupted, run `pds migrate` again to continue.
129
128
  - `--dev` – Target the local development server instead of production
130
129
  - `--clean` – Delete any existing imported data and start fresh (only works on deactivated accounts)
131
130
 
131
+ ### `pds identity`
132
+
133
+ Updates your DID document to point to your new PDS. This is the critical step that tells the network where to find you.
134
+
135
+ ```bash
136
+ pds identity # Update identity for production
137
+ pds identity --dev # Update identity for local dev
138
+ pds identity --token XXX # Skip email step if you have a token
139
+ ```
140
+
141
+ The command:
142
+
143
+ 1. Resolves your current DID to find the source PDS
144
+ 2. Authenticates with your source PDS (requires your password)
145
+ 3. Requests an email confirmation token
146
+ 4. Gets the source PDS to sign a PLC operation with the new endpoint
147
+ 5. Submits the signed operation to the PLC directory
148
+
149
+ **Note:** Only `did:plc` identities are supported. `did:web` identities don't use PLC operations.
150
+
132
151
  ### `pds activate`
133
152
 
134
153
  Enables writes on the account after migration.
@@ -401,10 +420,10 @@ See the [@getcirrus/oauth-provider](../oauth-provider/) package for implementati
401
420
 
402
421
  1. **Enable R2** in your [Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/r2/overview). The bucket will be created automatically on first deploy.
403
422
 
404
- 2. **Run the production setup** to deploy secrets:
423
+ 2. **Run the setup wizard** and answer "Yes" when asked if you want to deploy to Cloudflare:
405
424
 
406
425
  ```bash
407
- npx pds init --production
426
+ npx pds init
408
427
  ```
409
428
 
410
429
  3. **Deploy your worker:**
@@ -419,30 +438,84 @@ wrangler deploy
419
438
 
420
439
  Moving an existing Bluesky account to your own PDS:
421
440
 
422
- ### 1. Configure for migration
441
+ ### Step 1: Configure for migration
423
442
 
424
443
  ```bash
425
444
  npx pds init
426
445
  # Answer "Yes" when asked about migrating an existing account
427
446
  ```
428
447
 
429
- ### 2. Deploy and migrate data
448
+ This detects your existing account, generates new signing keys, and configures the PDS in deactivated mode (ready for data import).
449
+
450
+ ### Step 2: Deploy and transfer data
430
451
 
431
452
  ```bash
432
453
  wrangler deploy
433
454
  npx pds migrate
434
455
  ```
435
456
 
436
- ### 3. Update your identity
457
+ The migrate command:
458
+ - Resolves your DID to find the current PDS
459
+ - Authenticates with your source PDS
460
+ - Downloads the repository (posts, follows, likes, etc.)
461
+ - Transfers all blobs (images, videos)
462
+ - Copies user preferences
463
+
464
+ If interrupted, run `pds migrate` again to resume.
465
+
466
+ ### Step 3: Update your identity
467
+
468
+ ```bash
469
+ npx pds identity
470
+ ```
471
+
472
+ This updates your DID document to point to your new PDS. The command:
473
+ 1. Authenticates with your source PDS (requires password)
474
+ 2. Requests an email confirmation token
475
+ 3. Gets the source PDS to sign a PLC operation with your new endpoint
476
+ 4. Submits the signed operation to the PLC directory
437
477
 
438
- Follow the [AT Protocol account migration guide](https://atproto.com/guides/account-migration) to update your DID document. This typically requires email verification from your current PDS.
478
+ You'll receive an email with a confirmation token enter it when prompted.
439
479
 
440
- ### 4. Go live
480
+ ### Step 4: Activate the account
441
481
 
442
482
  ```bash
443
483
  npx pds activate
444
484
  ```
445
485
 
486
+ This enables writes on your new PDS. Your account is now live.
487
+
488
+ ### Step 5: Verify the migration
489
+
490
+ ```bash
491
+ npx pds status
492
+ ```
493
+
494
+ Check that:
495
+ - The account is active
496
+ - The repository has the expected number of records
497
+ - Your handle resolves correctly
498
+
499
+ ### Full command sequence
500
+
501
+ ```bash
502
+ # 1. Configure (answer "Yes" to deploy secrets to Cloudflare)
503
+ npx pds init # Configure for migration + deploy secrets
504
+
505
+ # 2. Deploy and migrate
506
+ wrangler deploy # Deploy the worker
507
+ npx pds migrate # Transfer data from source PDS
508
+
509
+ # 3. Update identity
510
+ npx pds identity # Update DID document (requires email)
511
+
512
+ # 4. Go live
513
+ npx pds activate # Enable writes
514
+
515
+ # 5. Verify
516
+ npx pds status # Check everything is working
517
+ ```
518
+
446
519
  ## Validation
447
520
 
448
521
  Records are validated against AT Protocol lexicon schemas before being stored. The PDS uses optimistic validation:
package/dist/cli.js CHANGED
@@ -7,12 +7,13 @@ import bcrypt from "bcryptjs";
7
7
  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
- import { resolve } from "node:path";
10
+ import { join, resolve } from "node:path";
11
11
  import pc from "picocolors";
12
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 { writeFile } from "node:fs/promises";
16
17
  import { CompositeDidDocumentResolver, DohJsonHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from "@atcute/identity-resolver";
17
18
  import { getPdsEndpoint } from "@atcute/identity";
18
19
 
@@ -21,6 +22,62 @@ import { getPdsEndpoint } from "@atcute/identity";
21
22
  * Wrangler integration utilities for setting vars and secrets
22
23
  */
23
24
  /**
25
+ * Run a wrangler command and capture output.
26
+ * This is the single point of entry for all wrangler CLI invocations.
27
+ *
28
+ * @example
29
+ * // Basic command
30
+ * const { stdout } = await runWrangler(["whoami"]);
31
+ *
32
+ * @example
33
+ * // Command with stdin (for secrets)
34
+ * await runWrangler(["secret", "put", "MY_SECRET"], { stdin: secretValue, throwOnError: true });
35
+ *
36
+ * @example
37
+ * // Command that should throw on failure
38
+ * await runWrangler(["types"], { throwOnError: true });
39
+ */
40
+ function runWrangler(args, options = {}) {
41
+ const { stdin, throwOnError = false } = options;
42
+ return new Promise((resolve$1, reject) => {
43
+ const child = spawn("wrangler", args, { stdio: [
44
+ "pipe",
45
+ "pipe",
46
+ "pipe"
47
+ ] });
48
+ let stdout = "";
49
+ let stderr = "";
50
+ child.stdout?.on("data", (data) => {
51
+ stdout += data.toString();
52
+ });
53
+ child.stderr?.on("data", (data) => {
54
+ stderr += data.toString();
55
+ });
56
+ if (stdin !== void 0) {
57
+ child.stdin.write(stdin);
58
+ child.stdin.end();
59
+ }
60
+ child.on("close", (code) => {
61
+ if (throwOnError && code !== 0) {
62
+ const cmd = `wrangler ${args.join(" ")}`;
63
+ reject(/* @__PURE__ */ new Error(`${cmd} failed with code ${code}\n${stderr}`));
64
+ } else resolve$1({
65
+ stdout,
66
+ stderr,
67
+ code
68
+ });
69
+ });
70
+ child.on("error", (err) => {
71
+ if (throwOnError) reject(err);
72
+ else resolve$1({
73
+ stdout,
74
+ stderr,
75
+ code: null
76
+ });
77
+ });
78
+ });
79
+ }
80
+ /**
24
81
  * Set a var in wrangler.jsonc using experimental_patchConfig
25
82
  */
26
83
  function setVar(name, value) {
@@ -62,23 +119,13 @@ function setWorkerName(name) {
62
119
  * Set a secret using wrangler secret put
63
120
  */
64
121
  async function setSecret(name, value) {
65
- return new Promise((resolve$1, reject) => {
66
- const child = spawn("wrangler", [
67
- "secret",
68
- "put",
69
- name
70
- ], { stdio: [
71
- "pipe",
72
- "inherit",
73
- "inherit"
74
- ] });
75
- child.stdin.write(value);
76
- child.stdin.end();
77
- child.on("close", (code) => {
78
- if (code === 0) resolve$1();
79
- else reject(/* @__PURE__ */ new Error(`wrangler secret put ${name} failed with code ${code}`));
80
- });
81
- child.on("error", reject);
122
+ await runWrangler([
123
+ "secret",
124
+ "put",
125
+ name
126
+ ], {
127
+ stdin: value,
128
+ throwOnError: true
82
129
  });
83
130
  }
84
131
  /**
@@ -113,7 +160,7 @@ function setCustomDomains(domains) {
113
160
  */
114
161
  async function detectCloudflareAccounts() {
115
162
  if (getAccountId()) return null;
116
- const { stdout, stderr } = await runWranglerWithOutput(["whoami"]);
163
+ const { stdout, stderr } = await runWrangler(["whoami"]);
117
164
  const output = stdout + stderr;
118
165
  const accounts = [];
119
166
  const regex = /│\s*([^│]+?)\s*│\s*([a-f0-9]{32})\s*│/g;
@@ -129,36 +176,32 @@ async function detectCloudflareAccounts() {
129
176
  return accounts.length > 1 ? accounts : null;
130
177
  }
131
178
  /**
132
- * Run a wrangler command and capture output
179
+ * Parse wrangler secret list output (JSON or table format)
180
+ * Exported for testing
133
181
  */
134
- function runWranglerWithOutput(args) {
135
- return new Promise((resolve$1) => {
136
- const child = spawn("wrangler", args, { stdio: [
137
- "pipe",
138
- "pipe",
139
- "pipe"
140
- ] });
141
- let stdout = "";
142
- let stderr = "";
143
- child.stdout?.on("data", (data) => {
144
- stdout += data.toString();
145
- });
146
- child.stderr?.on("data", (data) => {
147
- stderr += data.toString();
148
- });
149
- child.on("close", () => {
150
- resolve$1({
151
- stdout,
152
- stderr
153
- });
154
- });
155
- child.on("error", () => {
156
- resolve$1({
157
- stdout,
158
- stderr
159
- });
160
- });
161
- });
182
+ function parseSecretListOutput(output) {
183
+ try {
184
+ const secrets = JSON.parse(output);
185
+ if (Array.isArray(secrets)) return secrets.map((s) => s.name);
186
+ } catch {
187
+ const names = [];
188
+ const regex = /│\s*(\w+)\s*│\s*secret_text\s*│/g;
189
+ let match;
190
+ while ((match = regex.exec(output)) !== null) {
191
+ const name = match[1];
192
+ if (name && name !== "Name") names.push(name);
193
+ }
194
+ return names;
195
+ }
196
+ return [];
197
+ }
198
+ /**
199
+ * List secret names currently deployed to Cloudflare
200
+ * (Values cannot be retrieved - only names)
201
+ */
202
+ async function listSecrets() {
203
+ const { stdout } = await runWrangler(["secret", "list"]);
204
+ return parseSecretListOutput(stdout);
162
205
  }
163
206
 
164
207
  //#endregion
@@ -431,7 +474,7 @@ const secretCommand = defineCommand({
431
474
  /**
432
475
  * Create a fetch handler that adds optional auth token
433
476
  */
434
- function createAuthHandler(baseUrl, token) {
477
+ function createAuthHandler$1(baseUrl, token) {
435
478
  return async (pathname, init) => {
436
479
  const url = new URL(pathname, baseUrl);
437
480
  const headers = new Headers(init.headers);
@@ -448,14 +491,14 @@ var PDSClient = class PDSClient {
448
491
  constructor(baseUrl, authToken) {
449
492
  this.baseUrl = baseUrl;
450
493
  this.authToken = authToken;
451
- this.client = new Client({ handler: createAuthHandler(baseUrl, authToken) });
494
+ this.client = new Client({ handler: createAuthHandler$1(baseUrl, authToken) });
452
495
  }
453
496
  /**
454
497
  * Set the auth token for subsequent requests
455
498
  */
456
499
  setAuthToken(token) {
457
500
  this.authToken = token;
458
- this.client = new Client({ handler: createAuthHandler(this.baseUrl, token) });
501
+ this.client = new Client({ handler: createAuthHandler$1(this.baseUrl, token) });
459
502
  }
460
503
  /**
461
504
  * Create a session with identifier and password
@@ -1054,6 +1097,151 @@ function formatCommand(pm, ...args) {
1054
1097
  if (pm === "npm" || args[0] === "deploy") return `${pm} run ${args.join(" ")}`;
1055
1098
  return `${pm} ${args.join(" ")}`;
1056
1099
  }
1100
+ /**
1101
+ * Copy text to clipboard using platform-specific command
1102
+ * Falls back gracefully if clipboard is unavailable
1103
+ */
1104
+ async function copyToClipboard(text) {
1105
+ const platform = process.platform;
1106
+ let cmd;
1107
+ let args;
1108
+ if (platform === "darwin") {
1109
+ cmd = "pbcopy";
1110
+ args = [];
1111
+ } else if (platform === "linux") {
1112
+ cmd = "xclip";
1113
+ args = ["-selection", "clipboard"];
1114
+ } else if (platform === "win32") {
1115
+ cmd = "clip";
1116
+ args = [];
1117
+ } else return false;
1118
+ return new Promise((resolve$1) => {
1119
+ const child = spawn(cmd, args, { stdio: [
1120
+ "pipe",
1121
+ "ignore",
1122
+ "ignore"
1123
+ ] });
1124
+ child.on("error", () => resolve$1(false));
1125
+ child.on("close", (code) => resolve$1(code === 0));
1126
+ child.stdin?.write(text);
1127
+ child.stdin?.end();
1128
+ });
1129
+ }
1130
+ /**
1131
+ * Check if 1Password CLI (op) is available
1132
+ * Only checks on POSIX systems (macOS, Linux)
1133
+ */
1134
+ async function is1PasswordAvailable() {
1135
+ if (process.platform === "win32") return false;
1136
+ return new Promise((resolve$1) => {
1137
+ const child = spawn("which", ["op"], { stdio: [
1138
+ "ignore",
1139
+ "pipe",
1140
+ "ignore"
1141
+ ] });
1142
+ child.on("error", () => resolve$1(false));
1143
+ child.on("close", (code) => resolve$1(code === 0));
1144
+ });
1145
+ }
1146
+ /**
1147
+ * Save a key to 1Password using the CLI
1148
+ * Creates a secure note with the signing key
1149
+ */
1150
+ async function saveTo1Password(key, handle) {
1151
+ const itemName = `Cirrus PDS Signing Key - ${handle}`;
1152
+ return new Promise((resolve$1) => {
1153
+ const child = spawn("op", [
1154
+ "item",
1155
+ "create",
1156
+ "--category",
1157
+ "Secure Note",
1158
+ "--title",
1159
+ itemName,
1160
+ `notesPlain=CIRRUS PDS SIGNING KEY\n\nHandle: ${handle}\nCreated: ${(/* @__PURE__ */ new Date()).toISOString()}\n\nWARNING: This key controls your identity!\n\nSIGNING KEY:\n${key}`,
1161
+ "--tags",
1162
+ "cirrus,pds,signing-key"
1163
+ ], { stdio: [
1164
+ "ignore",
1165
+ "pipe",
1166
+ "pipe"
1167
+ ] });
1168
+ let stderr = "";
1169
+ child.stderr?.on("data", (data) => {
1170
+ stderr += data.toString();
1171
+ });
1172
+ child.on("error", (err) => {
1173
+ resolve$1({
1174
+ success: false,
1175
+ error: err.message
1176
+ });
1177
+ });
1178
+ child.on("close", (code) => {
1179
+ if (code === 0) resolve$1({
1180
+ success: true,
1181
+ itemName
1182
+ });
1183
+ else resolve$1({
1184
+ success: false,
1185
+ error: stderr || `1Password CLI exited with code ${code}`
1186
+ });
1187
+ });
1188
+ });
1189
+ }
1190
+ /**
1191
+ * Run a shell command and return a promise.
1192
+ * Captures output and throws on non-zero exit code.
1193
+ * Use this for running npm/pnpm/yarn scripts etc.
1194
+ */
1195
+ function runCommand(cmd, args) {
1196
+ return new Promise((resolve$1, reject) => {
1197
+ const child = spawn(cmd, args, { stdio: "pipe" });
1198
+ let output = "";
1199
+ child.stdout?.on("data", (data) => {
1200
+ output += data.toString();
1201
+ });
1202
+ child.stderr?.on("data", (data) => {
1203
+ output += data.toString();
1204
+ });
1205
+ child.on("close", (code) => {
1206
+ if (code === 0) resolve$1();
1207
+ else {
1208
+ if (output) console.error(output);
1209
+ reject(/* @__PURE__ */ new Error(`${cmd} ${args.join(" ")} failed with code ${code}`));
1210
+ }
1211
+ });
1212
+ child.on("error", reject);
1213
+ });
1214
+ }
1215
+ /**
1216
+ * Save a key backup file with appropriate warnings
1217
+ */
1218
+ async function saveKeyBackup(key, handle) {
1219
+ const filename = `signing-key-backup-${handle.replace(/[^a-z0-9]/gi, "-")}.txt`;
1220
+ const filepath = join(process.cwd(), filename);
1221
+ await writeFile(filepath, [
1222
+ "=".repeat(60),
1223
+ "CIRRUS PDS SIGNING KEY BACKUP",
1224
+ "=".repeat(60),
1225
+ "",
1226
+ `Handle: ${handle}`,
1227
+ `Created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1228
+ "",
1229
+ "WARNING: This key controls your identity!",
1230
+ "- Store this file in a secure location (password manager, encrypted drive)",
1231
+ "- Delete this file from your local disk after backing up",
1232
+ "- Never share this key with anyone",
1233
+ "- If compromised, your identity can be stolen",
1234
+ "",
1235
+ "=".repeat(60),
1236
+ "SIGNING KEY (hex-encoded secp256k1 private key)",
1237
+ "=".repeat(60),
1238
+ "",
1239
+ key,
1240
+ "",
1241
+ "=".repeat(60)
1242
+ ].join("\n"), { mode: 384 });
1243
+ return filepath;
1244
+ }
1057
1245
 
1058
1246
  //#endregion
1059
1247
  //#region src/cli/commands/passkey/add.ts
@@ -1431,7 +1619,7 @@ async function resolveHandleToDid(handle) {
1431
1619
  * Uses @atcute/identity-resolver which is already Workers-compatible
1432
1620
  * (uses redirect: "manual" internally).
1433
1621
  */
1434
- const PLC_DIRECTORY = "https://plc.directory";
1622
+ const PLC_DIRECTORY$1 = "https://plc.directory";
1435
1623
  const TIMEOUT_MS = 3e3;
1436
1624
  /**
1437
1625
  * Wrapper that always uses globalThis.fetch so it can be mocked in tests.
@@ -1448,7 +1636,7 @@ var DidResolver = class {
1448
1636
  this.cache = opts.didCache;
1449
1637
  this.resolver = new CompositeDidDocumentResolver({ methods: {
1450
1638
  plc: new PlcDidDocumentResolver({
1451
- apiUrl: opts.plcUrl ?? PLC_DIRECTORY,
1639
+ apiUrl: opts.plcUrl ?? PLC_DIRECTORY$1,
1452
1640
  fetch: stubbableFetch
1453
1641
  }),
1454
1642
  web: new WebDidDocumentResolver({ fetch: stubbableFetch })
@@ -1535,29 +1723,6 @@ async function ensureAccountConfigured() {
1535
1723
  const selectedName = accounts.find((a) => a.id === selectedId)?.name;
1536
1724
  p.log.success(`Account "${selectedName}" saved to wrangler.jsonc`);
1537
1725
  }
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
1726
  const initCommand = defineCommand({
1562
1727
  meta: {
1563
1728
  name: "init",
@@ -1576,6 +1741,33 @@ const initCommand = defineCommand({
1576
1741
  p.log.info("Let's set up your new home in the Atmosphere!");
1577
1742
  const wranglerVars = getVars();
1578
1743
  const devVars = readDevVars();
1744
+ let cfSecrets = [];
1745
+ try {
1746
+ cfSecrets = await listSecrets();
1747
+ } catch {}
1748
+ if (cfSecrets.includes("SIGNING_KEY") && !devVars.SIGNING_KEY) {
1749
+ p.log.error("⚠️ Signing key exists in Cloudflare but not locally!");
1750
+ p.note([
1751
+ "Your PDS has a signing key deployed to Cloudflare, but you don't have",
1752
+ "a local copy in .dev.vars. This usually happens after cloning to a new",
1753
+ "machine without restoring your key backup.",
1754
+ "",
1755
+ "Cloudflare secrets CANNOT be retrieved once set.",
1756
+ "",
1757
+ "To continue, you need to:",
1758
+ " 1. Restore your signing key from your backup (password manager, etc.)",
1759
+ " 2. Add it to .dev.vars as: SIGNING_KEY=your-key-here",
1760
+ " 3. Run 'pds init' again",
1761
+ "",
1762
+ "If you've lost your key backup, your options are limited:",
1763
+ " • For did:web: Generate a new key (old signatures become unverifiable)",
1764
+ " • For did:plc: Use a recovery key if you have one",
1765
+ "",
1766
+ "See: https://github.com/ascorbic/cirrus#key-recovery"
1767
+ ].join("\n"), "Key Recovery Required");
1768
+ p.outro("Initialization cancelled.");
1769
+ process.exit(1);
1770
+ }
1579
1771
  const currentVars = {
1580
1772
  ...devVars,
1581
1773
  ...wranglerVars
@@ -1740,13 +1932,101 @@ const initCommand = defineCommand({
1740
1932
  spinner.stop("Auth token generated");
1741
1933
  return token;
1742
1934
  });
1743
- const signingKey = await getOrGenerateSecret("SIGNING_KEY", devVars, async () => {
1935
+ let signingKey;
1936
+ let signingKeyIsNew = false;
1937
+ if (devVars.SIGNING_KEY) {
1938
+ p.log.success("Using existing signing key from .dev.vars");
1939
+ signingKey = devVars.SIGNING_KEY;
1940
+ } else {
1744
1941
  spinner.start("Generating signing keypair...");
1745
1942
  const { privateKey } = await generateSigningKeypair();
1746
1943
  spinner.stop("Signing keypair generated");
1747
- return privateKey;
1748
- });
1944
+ signingKey = privateKey;
1945
+ signingKeyIsNew = true;
1946
+ }
1749
1947
  const signingKeyPublic = await derivePublicKey(signingKey);
1948
+ if (signingKeyIsNew) {
1949
+ p.log.warn("⚠️ Your signing key controls your identity!");
1950
+ p.note([
1951
+ "This key signs all your posts and controls your account.",
1952
+ "If you lose it, you lose your identity forever.",
1953
+ "",
1954
+ "Cloudflare secrets CANNOT be retrieved after being set.",
1955
+ "Your only copy will be in .dev.vars (this directory).",
1956
+ "",
1957
+ "We strongly recommend backing it up now."
1958
+ ].join("\n"), "Critical: Back Up Your Signing Key");
1959
+ const has1Password = await is1PasswordAvailable();
1960
+ const backupOptions = [];
1961
+ if (has1Password) backupOptions.push({
1962
+ value: "1password",
1963
+ label: "Save to 1Password",
1964
+ hint: "recommended - uses op CLI"
1965
+ });
1966
+ backupOptions.push({
1967
+ value: "copy",
1968
+ label: "Copy to clipboard",
1969
+ hint: "paste into password manager"
1970
+ }, {
1971
+ value: "file",
1972
+ label: "Save to file",
1973
+ hint: "signing-key-backup.txt"
1974
+ }, {
1975
+ value: "show",
1976
+ label: "Display it (I'll copy manually)",
1977
+ hint: "shown in terminal"
1978
+ }, {
1979
+ value: "skip",
1980
+ label: "Skip (I understand the risk)",
1981
+ hint: "not recommended"
1982
+ });
1983
+ const backupChoice = await promptSelect({
1984
+ message: "How would you like to back up your signing key?",
1985
+ options: backupOptions
1986
+ });
1987
+ if (backupChoice === "1password") {
1988
+ spinner.start("Saving to 1Password...");
1989
+ const result = await saveTo1Password(signingKey, handle);
1990
+ if (result.success) {
1991
+ spinner.stop("Saved to 1Password");
1992
+ p.log.success(`Created: "${result.itemName}"`);
1993
+ } else {
1994
+ spinner.stop("Failed to save to 1Password");
1995
+ p.log.error(result.error || "Unknown error");
1996
+ p.log.info("Falling back to displaying the key...");
1997
+ p.note([
1998
+ "SIGNING KEY (keep this secret!):",
1999
+ "",
2000
+ signingKey,
2001
+ "",
2002
+ "Copy this to your password manager now."
2003
+ ].join("\n"), "🔑 Your Signing Key");
2004
+ }
2005
+ } else if (backupChoice === "copy") {
2006
+ await copyToClipboard(signingKey);
2007
+ p.log.success("Signing key copied to clipboard");
2008
+ p.log.info("Paste it into your password manager now!");
2009
+ } else if (backupChoice === "file") {
2010
+ const backupPath = await saveKeyBackup(signingKey, handle);
2011
+ p.log.success(`Signing key saved to: ${backupPath}`);
2012
+ p.log.warn("Move this file to a secure location and delete the local copy!");
2013
+ } else if (backupChoice === "show") p.note([
2014
+ "SIGNING KEY (keep this secret!):",
2015
+ "",
2016
+ signingKey,
2017
+ "",
2018
+ "Copy this to your password manager now."
2019
+ ].join("\n"), "🔑 Your Signing Key");
2020
+ if (backupChoice !== "skip") {
2021
+ if (!await promptConfirm({
2022
+ message: "Have you saved your signing key securely?",
2023
+ initialValue: true
2024
+ })) {
2025
+ p.log.warn("Please back up your key before continuing!");
2026
+ p.note(signingKey, "🔑 Signing Key");
2027
+ }
2028
+ }
2029
+ }
1750
2030
  const jwtSecret = await getOrGenerateSecret("JWT_SECRET", devVars, async () => {
1751
2031
  spinner.start("Generating JWT secret...");
1752
2032
  const secret = generateJwtSecret();
@@ -1775,13 +2055,13 @@ const initCommand = defineCommand({
1775
2055
  if (isProduction) spinner.start("Deploying secrets to Cloudflare...");
1776
2056
  else spinner.start("Writing secrets to .dev.vars...");
1777
2057
  await setSecretValue("AUTH_TOKEN", authToken, local);
1778
- await setSecretValue("SIGNING_KEY", signingKey, local);
2058
+ if (signingKeyIsNew) await setSecretValue("SIGNING_KEY", signingKey, local);
1779
2059
  await setSecretValue("JWT_SECRET", jwtSecret, local);
1780
2060
  await setSecretValue("PASSWORD_HASH", passwordHash, local);
1781
2061
  spinner.stop(isProduction ? "Secrets deployed" : "Secrets written to .dev.vars");
1782
2062
  spinner.start("Generating TypeScript types...");
1783
2063
  try {
1784
- await runWranglerTypes();
2064
+ await runWrangler(["types"], { throwOnError: true });
1785
2065
  spinner.stop("TypeScript types generated");
1786
2066
  } catch {
1787
2067
  spinner.stop("Failed to generate types (wrangler types)");
@@ -1793,10 +2073,7 @@ const initCommand = defineCommand({
1793
2073
  " Handle: " + handle,
1794
2074
  " Public signing key: " + signingKeyPublic.slice(0, 20) + "...",
1795
2075
  "",
1796
- isProduction ? "Secrets deployed to Cloudflare ☁️" : "Secrets saved to .dev.vars",
1797
- "",
1798
- "Auth token (save this!):",
1799
- " " + authToken
2076
+ isProduction ? "Secrets deployed to Cloudflare ☁️" : "Secrets saved to .dev.vars"
1800
2077
  ].join("\n"), "Your New Home 🏠");
1801
2078
  let deployedSecrets = isProduction;
1802
2079
  if (!isProduction) {
@@ -1807,28 +2084,59 @@ const initCommand = defineCommand({
1807
2084
  if (!p.isCancel(deployNow) && deployNow) {
1808
2085
  spinner.start("Deploying secrets to Cloudflare...");
1809
2086
  await setSecretValue("AUTH_TOKEN", authToken, false);
1810
- await setSecretValue("SIGNING_KEY", signingKey, false);
2087
+ if (signingKeyIsNew) await setSecretValue("SIGNING_KEY", signingKey, false);
1811
2088
  await setSecretValue("JWT_SECRET", jwtSecret, false);
1812
2089
  await setSecretValue("PASSWORD_HASH", passwordHash, false);
1813
2090
  spinner.stop("Secrets deployed to Cloudflare");
1814
2091
  deployedSecrets = true;
1815
2092
  }
1816
2093
  }
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! 🚀`);
2094
+ let deployed = false;
2095
+ if (deployedSecrets) {
2096
+ const deployWorker = await p.confirm({
2097
+ message: "Deploy to Cloudflare now?",
2098
+ initialValue: true
2099
+ });
2100
+ if (!p.isCancel(deployWorker) && deployWorker) {
2101
+ spinner.start("Building and deploying to Cloudflare...");
2102
+ try {
2103
+ await runCommand(pm === "npm" ? "npm" : pm, ["run", "build"]);
2104
+ await runCommand(pm === "npm" ? "npm" : pm, ["run", "deploy"]);
2105
+ spinner.stop("Deployed to Cloudflare! 🚀");
2106
+ deployed = true;
2107
+ } catch (error) {
2108
+ spinner.stop("Deployment failed");
2109
+ p.log.error(`Failed to deploy: ${error instanceof Error ? error.message : "Unknown error"}`);
2110
+ p.log.info(`You can deploy manually with: ${formatCommand(pm, "deploy")}`);
2111
+ }
2112
+ }
2113
+ }
2114
+ if (isMigrating) {
2115
+ const nextSteps = deployed ? [
2116
+ "Run the migration:",
2117
+ "",
2118
+ ` ${formatCommand(pm, "pds", "migrate")}`,
2119
+ "",
2120
+ "Then update your identity and flip the switch! 🦋",
2121
+ " https://atproto.com/guides/account-migration"
2122
+ ] : [
2123
+ deployedSecrets ? "Deploy your worker and run the migration:" : "Push secrets, deploy, and run the migration:",
2124
+ "",
2125
+ ...deployedSecrets ? [] : [` ${formatCommand(pm, "pds", "init", "--production")}`, ""],
2126
+ ` ${formatCommand(pm, "deploy")}`,
2127
+ ` ${formatCommand(pm, "pds", "migrate")}`,
2128
+ "",
2129
+ "To test locally first:",
2130
+ ` ${formatCommand(pm, "dev")} # in one terminal`,
2131
+ ` ${formatCommand(pm, "pds", "migrate", "--dev")} # in another`,
2132
+ "",
2133
+ "Then update your identity and flip the switch! 🦋",
2134
+ " https://atproto.com/guides/account-migration"
2135
+ ];
2136
+ p.note(nextSteps.join("\n"), "Next Steps 🧳");
2137
+ }
2138
+ if (deployed) p.outro(`Your PDS is live at https://${hostname}! 🚀`);
2139
+ else if (deployedSecrets) p.outro(`Run '${formatCommand(pm, "deploy")}' to launch your PDS! 🚀`);
1832
2140
  else p.outro(`Run '${formatCommand(pm, "dev")}' to start your PDS locally! 🦋`);
1833
2141
  }
1834
2142
  });
@@ -1847,7 +2155,7 @@ async function getOrGenerateSecret(name, devVars, generate) {
1847
2155
 
1848
2156
  //#endregion
1849
2157
  //#region src/cli/commands/migrate.ts
1850
- const brightNote$1 = (lines) => lines.map((l) => `\x1b[0m${l}`).join("\n");
2158
+ const brightNote$2 = (lines) => lines.map((l) => `\x1b[0m${l}`).join("\n");
1851
2159
  /**
1852
2160
  * Format number with commas
1853
2161
  */
@@ -1969,7 +2277,7 @@ const migrateCommand = defineCommand({
1969
2277
  p.outro("Migration cancelled.");
1970
2278
  process.exit(1);
1971
2279
  }
1972
- p.note(brightNote$1([
2280
+ p.note(brightNote$2([
1973
2281
  pc.bold("This will permanently delete from your new PDS:"),
1974
2282
  "",
1975
2283
  ` • ${num(status.repoBlocks)} repository blocks`,
@@ -2045,7 +2353,7 @@ const migrateCommand = defineCommand({
2045
2353
  ` 👥 ${num(profileStats.followsCount)} follows`,
2046
2354
  ` ...plus all your images, likes and preferences`
2047
2355
  ] : [` 📝 Posts, follows, images, likes and preferences`];
2048
- p.note(brightNote$1([
2356
+ p.note(brightNote$2([
2049
2357
  pc.bold(`@${handle}`) + ` (${did.slice(0, 20)}...)`,
2050
2358
  "",
2051
2359
  `Currently at: ${sourceDomain}`,
@@ -2172,12 +2480,12 @@ const migrateCommand = defineCommand({
2172
2480
  }
2173
2481
  });
2174
2482
  function showNextSteps(pm, sourceDomain) {
2175
- p.note(brightNote$1([
2483
+ p.note(brightNote$2([
2176
2484
  pc.bold("Your data is safe in your new PDS."),
2177
2485
  "Two more steps to go live:",
2178
2486
  "",
2179
2487
  pc.bold("1. Update your identity"),
2180
- " Tell the network where you live now.",
2488
+ ` ${formatCommand(pm, "pds", "identity")}`,
2181
2489
  ` (Requires email verification from ${sourceDomain})`,
2182
2490
  "",
2183
2491
  pc.bold("2. Flip the switch"),
@@ -2187,6 +2495,394 @@ function showNextSteps(pm, sourceDomain) {
2187
2495
  ]), "Almost there!");
2188
2496
  }
2189
2497
 
2498
+ //#endregion
2499
+ //#region src/cli/utils/plc-client.ts
2500
+ /**
2501
+ * PLC Directory client for identity operations
2502
+ *
2503
+ * Handles communication with the PLC directory and source PDS
2504
+ * for DID document updates during migration.
2505
+ *
2506
+ * Uses @atcute/client for type-safe XRPC calls.
2507
+ */
2508
+ const PLC_DIRECTORY = "https://plc.directory";
2509
+ /**
2510
+ * Create a fetch handler that adds optional auth token
2511
+ */
2512
+ function createAuthHandler(baseUrl, token) {
2513
+ return async (pathname, init) => {
2514
+ const url = new URL(pathname, baseUrl);
2515
+ const headers = new Headers(init.headers);
2516
+ if (token) headers.set("Authorization", `Bearer ${token}`);
2517
+ return fetch(url, {
2518
+ ...init,
2519
+ headers
2520
+ });
2521
+ };
2522
+ }
2523
+ /**
2524
+ * Client for interacting with source PDS for PLC operations
2525
+ *
2526
+ * Uses @atcute/client for type-safe XRPC calls to:
2527
+ * - com.atproto.identity.requestPlcOperationSignature
2528
+ * - com.atproto.identity.signPlcOperation
2529
+ */
2530
+ var SourcePdsPlcClient = class {
2531
+ client;
2532
+ authToken;
2533
+ baseUrl;
2534
+ constructor(baseUrl, authToken) {
2535
+ this.baseUrl = baseUrl;
2536
+ this.authToken = authToken;
2537
+ this.client = new Client({ handler: createAuthHandler(baseUrl, authToken) });
2538
+ }
2539
+ setAuthToken(token) {
2540
+ this.authToken = token;
2541
+ this.client = new Client({ handler: createAuthHandler(this.baseUrl, token) });
2542
+ }
2543
+ /**
2544
+ * Request a PLC operation signature from the source PDS.
2545
+ * This triggers the source PDS to send an email token to the user.
2546
+ *
2547
+ * Uses: com.atproto.identity.requestPlcOperationSignature
2548
+ */
2549
+ async requestPlcOperationSignature() {
2550
+ try {
2551
+ await ok(this.client.post("com.atproto.identity.requestPlcOperationSignature", { as: null }));
2552
+ return {
2553
+ success: true,
2554
+ credentialInfo: { type: "email" }
2555
+ };
2556
+ } catch (err) {
2557
+ return {
2558
+ success: false,
2559
+ error: err instanceof Error ? err.message : "Network error"
2560
+ };
2561
+ }
2562
+ }
2563
+ /**
2564
+ * Get a signed PLC operation from the source PDS.
2565
+ * This builds and signs the operation to migrate to the new PDS.
2566
+ *
2567
+ * Uses: com.atproto.identity.signPlcOperation
2568
+ *
2569
+ * @param token - The email token received from the source PDS
2570
+ * @param newPdsEndpoint - The endpoint URL of the new PDS
2571
+ * @param newSigningKey - The new signing key DID (did:key:...)
2572
+ */
2573
+ async signPlcOperation(token, newPdsEndpoint, newSigningKey) {
2574
+ try {
2575
+ return {
2576
+ success: true,
2577
+ signedOperation: (await ok(this.client.post("com.atproto.identity.signPlcOperation", { input: {
2578
+ token,
2579
+ rotationKeys: void 0,
2580
+ alsoKnownAs: void 0,
2581
+ verificationMethods: { atproto: newSigningKey },
2582
+ services: { atproto_pds: {
2583
+ type: "AtprotoPersonalDataServer",
2584
+ endpoint: newPdsEndpoint
2585
+ } }
2586
+ } }))).operation
2587
+ };
2588
+ } catch (err) {
2589
+ const errorMessage = err instanceof Error ? err.message : "Network error";
2590
+ if (errorMessage.includes("expired") || errorMessage.includes("ExpiredToken")) return {
2591
+ success: false,
2592
+ error: "Token expired. Request a new one and try again."
2593
+ };
2594
+ if (errorMessage.includes("invalid") || errorMessage.includes("InvalidToken")) return {
2595
+ success: false,
2596
+ error: "Invalid token. Check you entered it correctly."
2597
+ };
2598
+ return {
2599
+ success: false,
2600
+ error: errorMessage
2601
+ };
2602
+ }
2603
+ }
2604
+ };
2605
+ /**
2606
+ * Client for interacting with the PLC directory
2607
+ *
2608
+ * The PLC directory has its own REST API (not XRPC), so we use raw fetch here.
2609
+ * See: https://web.plc.directory/
2610
+ */
2611
+ var PlcDirectoryClient = class {
2612
+ constructor(plcUrl = PLC_DIRECTORY) {
2613
+ this.plcUrl = plcUrl;
2614
+ }
2615
+ /**
2616
+ * Get the audit log for a DID (list of all operations)
2617
+ */
2618
+ async getAuditLog(did) {
2619
+ const res = await fetch(`${this.plcUrl}/${did}/log/audit`);
2620
+ if (!res.ok) throw new Error(`Failed to fetch audit log: ${res.status}`);
2621
+ return res.json();
2622
+ }
2623
+ /**
2624
+ * Get the current DID document
2625
+ */
2626
+ async getDocument(did) {
2627
+ const res = await fetch(`${this.plcUrl}/${did}`);
2628
+ if (!res.ok) {
2629
+ if (res.status === 404) return null;
2630
+ throw new Error(`Failed to fetch DID document: ${res.status}`);
2631
+ }
2632
+ return res.json();
2633
+ }
2634
+ /**
2635
+ * Submit a signed PLC operation to the directory
2636
+ */
2637
+ async submitOperation(did, operation) {
2638
+ try {
2639
+ const res = await fetch(`${this.plcUrl}/${did}`, {
2640
+ method: "POST",
2641
+ headers: { "Content-Type": "application/json" },
2642
+ body: JSON.stringify(operation)
2643
+ });
2644
+ if (!res.ok) return {
2645
+ success: false,
2646
+ error: `PLC directory rejected operation: ${await res.text()}`
2647
+ };
2648
+ return { success: true };
2649
+ } catch (err) {
2650
+ return {
2651
+ success: false,
2652
+ error: err instanceof Error ? err.message : "Network error"
2653
+ };
2654
+ }
2655
+ }
2656
+ };
2657
+
2658
+ //#endregion
2659
+ //#region src/cli/commands/identity.ts
2660
+ /**
2661
+ * Identity command - update DID document to point to new PDS
2662
+ *
2663
+ * This command handles the PLC operation flow for migrating identity
2664
+ * from source PDS to Cirrus. It:
2665
+ * 1. Requests an email token from the source PDS
2666
+ * 2. Gets the source PDS to sign a PLC operation with the new endpoint
2667
+ * 3. Submits the signed operation to the PLC directory
2668
+ */
2669
+ const brightNote$1 = (lines) => lines.map((l) => `\x1b[0m${l}`).join("\n");
2670
+ const identityCommand = defineCommand({
2671
+ meta: {
2672
+ name: "identity",
2673
+ description: "Update your DID to point to your new PDS"
2674
+ },
2675
+ args: {
2676
+ dev: {
2677
+ type: "boolean",
2678
+ description: "Target local development server instead of production",
2679
+ default: false
2680
+ },
2681
+ token: {
2682
+ type: "string",
2683
+ description: "Email token (if you already have one)"
2684
+ }
2685
+ },
2686
+ async run({ args }) {
2687
+ const pm = detectPackageManager();
2688
+ const isDev = args.dev;
2689
+ p.intro("🆔 Update Identity");
2690
+ const spinner = p.spinner();
2691
+ const wranglerVars = getVars();
2692
+ const config = {
2693
+ ...readDevVars(),
2694
+ ...wranglerVars
2695
+ };
2696
+ const did = config.DID;
2697
+ const handle = config.HANDLE;
2698
+ const authToken = config.AUTH_TOKEN;
2699
+ const pdsHostname = config.PDS_HOSTNAME;
2700
+ const signingKey = config.SIGNING_KEY;
2701
+ if (!did) {
2702
+ p.log.error("No DID configured. Run 'pds init' first.");
2703
+ p.outro("Identity update cancelled.");
2704
+ process.exit(1);
2705
+ }
2706
+ if (!handle) {
2707
+ p.log.error("No HANDLE configured. Run 'pds init' first.");
2708
+ p.outro("Identity update cancelled.");
2709
+ process.exit(1);
2710
+ }
2711
+ if (!authToken) {
2712
+ p.log.error("No AUTH_TOKEN found. Run 'pds init' first.");
2713
+ p.outro("Identity update cancelled.");
2714
+ process.exit(1);
2715
+ }
2716
+ if (!pdsHostname && !isDev) {
2717
+ p.log.error("No PDS_HOSTNAME configured in wrangler.jsonc");
2718
+ p.outro("Identity update cancelled.");
2719
+ process.exit(1);
2720
+ }
2721
+ if (!signingKey) {
2722
+ p.log.error("No SIGNING_KEY found. Run 'pds init' first.");
2723
+ p.outro("Identity update cancelled.");
2724
+ process.exit(1);
2725
+ }
2726
+ if (!did.startsWith("did:plc:")) {
2727
+ p.log.error("Only did:plc identities are supported for now.");
2728
+ p.log.info("did:web identities don't use PLC operations.");
2729
+ p.outro("Identity update cancelled.");
2730
+ process.exit(1);
2731
+ }
2732
+ let targetUrl;
2733
+ try {
2734
+ targetUrl = getTargetUrl(isDev, pdsHostname);
2735
+ } catch (err) {
2736
+ p.log.error(err instanceof Error ? err.message : "Configuration error");
2737
+ p.outro("Identity update cancelled.");
2738
+ process.exit(1);
2739
+ }
2740
+ const targetDomain = getDomain(targetUrl);
2741
+ spinner.start("Resolving your DID...");
2742
+ const didDoc = await new DidResolver().resolve(did);
2743
+ if (!didDoc) {
2744
+ spinner.stop("Failed to resolve DID");
2745
+ p.log.error(`Could not resolve DID: ${did}`);
2746
+ p.outro("Identity update cancelled.");
2747
+ process.exit(1);
2748
+ }
2749
+ const sourcePdsUrl = getPdsEndpoint(didDoc);
2750
+ if (!sourcePdsUrl) {
2751
+ spinner.stop("No PDS found in DID document");
2752
+ p.log.error("Could not find PDS endpoint in DID document");
2753
+ p.outro("Identity update cancelled.");
2754
+ process.exit(1);
2755
+ }
2756
+ const sourceDomain = getDomain(sourcePdsUrl);
2757
+ spinner.stop(`Current PDS: ${sourceDomain}`);
2758
+ const targetEndpoint = targetUrl.replace(/\/$/, "");
2759
+ if (sourcePdsUrl.replace(/\/$/, "") === targetEndpoint) {
2760
+ p.log.success("Your DID already points to your new PDS!");
2761
+ p.log.info(`Next step: ${formatCommand(pm, "pds", "activate")}`);
2762
+ p.outro("All set!");
2763
+ return;
2764
+ }
2765
+ spinner.start(`Checking ${targetDomain}...`);
2766
+ if (!await new PDSClient(targetUrl, authToken).healthCheck()) {
2767
+ spinner.stop(`PDS not responding`);
2768
+ p.log.error(`Your new PDS isn't responding at ${targetUrl}`);
2769
+ if (isDev) p.log.info(`Start it with: ${formatCommand(pm, "dev")}`);
2770
+ else p.log.info(`Make sure your worker is deployed: ${formatCommand(pm, "deploy")}`);
2771
+ p.outro("Identity update cancelled.");
2772
+ process.exit(1);
2773
+ }
2774
+ spinner.stop(`New PDS is ready`);
2775
+ spinner.start("Preparing signing key...");
2776
+ const signingKeyDid = await getSigningKeyDid(signingKey);
2777
+ if (!signingKeyDid) {
2778
+ spinner.stop("Failed to derive signing key");
2779
+ p.log.error("Could not convert signing key to did:key format");
2780
+ p.outro("Identity update cancelled.");
2781
+ process.exit(1);
2782
+ }
2783
+ spinner.stop("Signing key ready");
2784
+ const sourceDisplayName = sourceDomain.endsWith(".bsky.network") ? "bsky.social" : sourceDomain;
2785
+ p.note(brightNote$1([
2786
+ pc.bold("Updating your identity:"),
2787
+ "",
2788
+ `${pc.dim("From:")} ${sourceDisplayName}`,
2789
+ `${pc.dim("To:")} ${targetDomain}`,
2790
+ "",
2791
+ pc.dim("This tells the network where to find you.")
2792
+ ]), "🔄 DID Update");
2793
+ const sourcePdsClient = new SourcePdsPlcClient(sourcePdsUrl);
2794
+ let token = args.token;
2795
+ if (!token) {
2796
+ const password = await p.password({ message: `Your password for ${sourceDisplayName}:` });
2797
+ if (p.isCancel(password)) {
2798
+ p.cancel("Identity update cancelled.");
2799
+ process.exit(0);
2800
+ }
2801
+ spinner.start(`Logging in to ${sourceDisplayName}...`);
2802
+ const sourceClient = new PDSClient(sourcePdsUrl);
2803
+ try {
2804
+ const session = await sourceClient.createSession(did, password);
2805
+ sourcePdsClient.setAuthToken(session.accessJwt);
2806
+ spinner.stop("Authenticated");
2807
+ } catch (err) {
2808
+ spinner.stop("Login failed");
2809
+ p.log.error(err instanceof Error ? err.message : "Authentication failed");
2810
+ p.outro("Identity update cancelled.");
2811
+ process.exit(1);
2812
+ }
2813
+ spinner.start("Requesting identity update token...");
2814
+ const signatureRequest = await sourcePdsClient.requestPlcOperationSignature();
2815
+ if (!signatureRequest.success) {
2816
+ spinner.stop("Failed to request token");
2817
+ p.log.error(signatureRequest.error ?? "Could not request PLC operation signature");
2818
+ p.outro("Identity update cancelled.");
2819
+ process.exit(1);
2820
+ }
2821
+ spinner.stop("Token requested");
2822
+ p.log.info("");
2823
+ p.log.info(pc.bold("📧 Check your email!"));
2824
+ p.log.info(`${sourceDisplayName} has sent you a confirmation code.`);
2825
+ p.log.info("");
2826
+ token = await promptText({
2827
+ message: "Enter the confirmation code from your email:",
2828
+ placeholder: "XXXXX-XXXXX",
2829
+ validate: (v) => {
2830
+ if (!v || v.trim().length < 5) return "Please enter the confirmation code";
2831
+ }
2832
+ });
2833
+ }
2834
+ spinner.start("Signing identity update...");
2835
+ const signResult = await sourcePdsClient.signPlcOperation(token.trim(), targetUrl, signingKeyDid);
2836
+ if (!signResult.success || !signResult.signedOperation) {
2837
+ spinner.stop("Failed to sign operation");
2838
+ p.log.error(signResult.error ?? "Could not sign PLC operation");
2839
+ if (signResult.error?.includes("expired")) p.log.info("Run the command again to request a new token.");
2840
+ p.outro("Identity update cancelled.");
2841
+ process.exit(1);
2842
+ }
2843
+ spinner.stop("Operation signed");
2844
+ const plcClient = new PlcDirectoryClient();
2845
+ spinner.start("Submitting to PLC directory...");
2846
+ const submitResult = await plcClient.submitOperation(did, signResult.signedOperation);
2847
+ if (!submitResult.success) {
2848
+ spinner.stop("Failed to submit operation");
2849
+ p.log.error(submitResult.error ?? "PLC directory rejected the operation");
2850
+ p.outro("Identity update cancelled.");
2851
+ process.exit(1);
2852
+ }
2853
+ spinner.stop("Identity updated!");
2854
+ spinner.start("Verifying update...");
2855
+ await new Promise((resolve$1) => setTimeout(resolve$1, 1e3));
2856
+ const newDidDoc = await new DidResolver().resolve(did);
2857
+ if ((newDidDoc ? getPdsEndpoint(newDidDoc) : null)?.replace(/\/$/, "") === targetEndpoint) spinner.stop("Verified! DID now points to new PDS");
2858
+ else {
2859
+ spinner.stop("Update submitted (verification pending)");
2860
+ p.log.warn("It may take a moment for the update to propagate.");
2861
+ }
2862
+ p.log.success("Your identity now points to your new PDS!");
2863
+ p.note(brightNote$1([
2864
+ pc.bold("Final step:"),
2865
+ "",
2866
+ `Run: ${formatCommand(pm, "pds", "activate")}`,
2867
+ "",
2868
+ "This enables writes and notifies the network."
2869
+ ]), "Almost done!");
2870
+ p.outro("Identity updated! 🎉");
2871
+ }
2872
+ });
2873
+ /**
2874
+ * Convert a hex-encoded secp256k1 private key to a did:key
2875
+ *
2876
+ * This imports the private key and returns the did:key representation.
2877
+ */
2878
+ async function getSigningKeyDid(hexPrivateKey) {
2879
+ try {
2880
+ return (await Secp256k1Keypair.import(hexPrivateKey)).did();
2881
+ } catch {
2882
+ return null;
2883
+ }
2884
+ }
2885
+
2190
2886
  //#endregion
2191
2887
  //#region src/cli/utils/checks.ts
2192
2888
  /**
@@ -3028,6 +3724,7 @@ runMain(defineCommand({
3028
3724
  secret: secretCommand,
3029
3725
  passkey: passkeyCommand,
3030
3726
  migrate: migrateCommand,
3727
+ identity: identityCommand,
3031
3728
  activate: activateCommand,
3032
3729
  deactivate: deactivateCommand,
3033
3730
  status: statusCommand,
package/dist/index.js CHANGED
@@ -3755,7 +3755,7 @@ function renderPasskeyErrorPage(error, description) {
3755
3755
 
3756
3756
  //#endregion
3757
3757
  //#region package.json
3758
- var version = "0.6.0";
3758
+ var version = "0.7.0";
3759
3759
 
3760
3760
  //#endregion
3761
3761
  //#region src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getcirrus/pds",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Cirrus – A single-user AT Protocol PDS on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",