@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 +84 -11
- package/dist/cli.js +803 -106
- package/dist/index.js +1 -1
- package/package.json +1 -1
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
|
-
> **⚠️
|
|
11
|
+
> **⚠️ Beta Software**
|
|
12
12
|
>
|
|
13
|
-
> This is
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
+
You'll receive an email with a confirmation token – enter it when prompted.
|
|
439
479
|
|
|
440
|
-
### 4
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
*
|
|
179
|
+
* Parse wrangler secret list output (JSON or table format)
|
|
180
|
+
* Exported for testing
|
|
133
181
|
*/
|
|
134
|
-
function
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
let
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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