@getcirrus/pds 0.10.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1163 -447
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,14 +8,15 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { experimental_patchConfig, experimental_readRawConfig } from "wrangler";
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
|
+
import { writeFile } from "node:fs/promises";
|
|
11
12
|
import pc from "picocolors";
|
|
12
13
|
import QRCode from "qrcode";
|
|
13
14
|
import { Client, ClientResponseError, ok } from "@atcute/client";
|
|
14
15
|
import "@atcute/bluesky";
|
|
15
16
|
import "@atcute/atproto";
|
|
16
|
-
import { writeFile } from "node:fs/promises";
|
|
17
17
|
import { CompositeDidDocumentResolver, DohJsonHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from "@atcute/identity-resolver";
|
|
18
18
|
import { getPdsEndpoint } from "@atcute/identity";
|
|
19
|
+
import { decodeAll } from "@atproto/lex-cbor";
|
|
19
20
|
|
|
20
21
|
//#region src/cli/utils/wrangler.ts
|
|
21
22
|
/**
|
|
@@ -276,159 +277,492 @@ function setDevVar(key, value, dir = process.cwd()) {
|
|
|
276
277
|
}
|
|
277
278
|
|
|
278
279
|
//#endregion
|
|
279
|
-
//#region src/cli/utils/
|
|
280
|
+
//#region src/cli/utils/cli-helpers.ts
|
|
280
281
|
/**
|
|
281
|
-
*
|
|
282
|
+
* Shared CLI utilities for PDS commands
|
|
282
283
|
*/
|
|
283
284
|
/**
|
|
284
|
-
*
|
|
285
|
+
* Prompt for text input, exiting on cancel
|
|
285
286
|
*/
|
|
286
|
-
async function
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
287
|
+
async function promptText(options) {
|
|
288
|
+
const result = await p.text(options);
|
|
289
|
+
if (p.isCancel(result)) {
|
|
290
|
+
p.cancel("Cancelled");
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
292
294
|
}
|
|
293
295
|
/**
|
|
294
|
-
*
|
|
296
|
+
* Prompt for confirmation, exiting on cancel
|
|
295
297
|
*/
|
|
296
|
-
async function
|
|
297
|
-
|
|
298
|
+
async function promptConfirm(options) {
|
|
299
|
+
const result = await p.confirm(options);
|
|
300
|
+
if (p.isCancel(result)) {
|
|
301
|
+
p.cancel("Cancelled");
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
298
305
|
}
|
|
299
306
|
/**
|
|
300
|
-
*
|
|
307
|
+
* Prompt for selection, exiting on cancel
|
|
301
308
|
*/
|
|
302
|
-
function
|
|
303
|
-
|
|
309
|
+
async function promptSelect(options) {
|
|
310
|
+
const result = await p.select(options);
|
|
311
|
+
if (p.isCancel(result)) {
|
|
312
|
+
p.cancel("Cancelled");
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
304
316
|
}
|
|
305
317
|
/**
|
|
306
|
-
*
|
|
318
|
+
* Get target PDS URL based on mode
|
|
307
319
|
*/
|
|
308
|
-
function
|
|
309
|
-
|
|
320
|
+
function getTargetUrl(isDev, pdsHostname) {
|
|
321
|
+
if (isDev) return `http://localhost:${process.env.PORT ? parseInt(process.env.PORT) ?? "5173" : "5173"}`;
|
|
322
|
+
if (!pdsHostname) throw new Error("PDS_HOSTNAME not configured in wrangler.jsonc");
|
|
323
|
+
return `https://${pdsHostname}`;
|
|
310
324
|
}
|
|
311
325
|
/**
|
|
312
|
-
*
|
|
326
|
+
* Extract domain from URL
|
|
313
327
|
*/
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
function getDomain(url) {
|
|
329
|
+
try {
|
|
330
|
+
return new URL(url).hostname;
|
|
331
|
+
} catch {
|
|
332
|
+
return url;
|
|
333
|
+
}
|
|
316
334
|
}
|
|
317
335
|
/**
|
|
318
|
-
*
|
|
336
|
+
* Detect which package manager is being used based on npm_config_user_agent
|
|
319
337
|
*/
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const password = await p.password({ message });
|
|
327
|
-
if (p.isCancel(password)) {
|
|
328
|
-
p.cancel("Cancelled");
|
|
329
|
-
process.exit(0);
|
|
330
|
-
}
|
|
331
|
-
const confirm = await p.password({ message: "Confirm password:" });
|
|
332
|
-
if (p.isCancel(confirm)) {
|
|
333
|
-
p.cancel("Cancelled");
|
|
334
|
-
process.exit(0);
|
|
335
|
-
}
|
|
336
|
-
if (password === confirm) return password;
|
|
337
|
-
p.log.error("Passwords do not match. Try again.");
|
|
338
|
-
}
|
|
339
|
-
p.log.error("Too many failed attempts.");
|
|
340
|
-
p.cancel("Password setup cancelled");
|
|
341
|
-
process.exit(1);
|
|
338
|
+
function detectPackageManager() {
|
|
339
|
+
const userAgent = process.env.npm_config_user_agent || "";
|
|
340
|
+
if (userAgent.startsWith("yarn")) return "yarn";
|
|
341
|
+
if (userAgent.startsWith("pnpm")) return "pnpm";
|
|
342
|
+
if (userAgent.startsWith("bun")) return "bun";
|
|
343
|
+
return "npm";
|
|
342
344
|
}
|
|
343
345
|
/**
|
|
344
|
-
*
|
|
346
|
+
* Format a command for the detected package manager
|
|
347
|
+
* npm always needs "run" for scripts, pnpm/yarn/bun can use shorthand
|
|
348
|
+
* except for "deploy" which conflicts with pnpm's built-in deploy command
|
|
345
349
|
*/
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
350
|
+
function formatCommand(pm, ...args) {
|
|
351
|
+
if (pm === "npm" || args[0] === "deploy") return `${pm} run ${args.join(" ")}`;
|
|
352
|
+
return `${pm} ${args.join(" ")}`;
|
|
349
353
|
}
|
|
350
354
|
/**
|
|
351
|
-
*
|
|
355
|
+
* Copy text to clipboard using platform-specific command
|
|
356
|
+
* Falls back gracefully if clipboard is unavailable
|
|
352
357
|
*/
|
|
353
|
-
function
|
|
354
|
-
|
|
355
|
-
|
|
358
|
+
async function copyToClipboard(text) {
|
|
359
|
+
const platform = process.platform;
|
|
360
|
+
let cmd;
|
|
361
|
+
let args;
|
|
362
|
+
if (platform === "darwin") {
|
|
363
|
+
cmd = "pbcopy";
|
|
364
|
+
args = [];
|
|
365
|
+
} else if (platform === "linux") {
|
|
366
|
+
cmd = "xclip";
|
|
367
|
+
args = ["-selection", "clipboard"];
|
|
368
|
+
} else if (platform === "win32") {
|
|
369
|
+
cmd = "clip";
|
|
370
|
+
args = [];
|
|
371
|
+
} else return false;
|
|
372
|
+
return new Promise((resolve$1) => {
|
|
373
|
+
const child = spawn(cmd, args, { stdio: [
|
|
374
|
+
"pipe",
|
|
375
|
+
"ignore",
|
|
376
|
+
"ignore"
|
|
377
|
+
] });
|
|
378
|
+
child.on("error", () => resolve$1(false));
|
|
379
|
+
child.on("close", (code) => resolve$1(code === 0));
|
|
380
|
+
child.stdin?.write(text);
|
|
381
|
+
child.stdin?.end();
|
|
382
|
+
});
|
|
356
383
|
}
|
|
357
|
-
|
|
358
|
-
//#endregion
|
|
359
|
-
//#region src/cli/commands/secret/jwt.ts
|
|
360
384
|
/**
|
|
361
|
-
*
|
|
385
|
+
* Check if 1Password CLI (op) is available
|
|
386
|
+
* Only checks on POSIX systems (macOS, Linux)
|
|
362
387
|
*/
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const secret = generateJwtSecret();
|
|
376
|
-
try {
|
|
377
|
-
await setSecretValue("JWT_SECRET", secret, args.local);
|
|
378
|
-
p.outro(args.local ? "JWT_SECRET written to .dev.vars" : "Done!");
|
|
379
|
-
} catch (error) {
|
|
380
|
-
p.log.error(String(error));
|
|
381
|
-
process.exit(1);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
//#endregion
|
|
387
|
-
//#region src/cli/commands/secret/password.ts
|
|
388
|
+
async function is1PasswordAvailable() {
|
|
389
|
+
if (process.platform === "win32") return false;
|
|
390
|
+
return new Promise((resolve$1) => {
|
|
391
|
+
const child = spawn("which", ["op"], { stdio: [
|
|
392
|
+
"ignore",
|
|
393
|
+
"pipe",
|
|
394
|
+
"ignore"
|
|
395
|
+
] });
|
|
396
|
+
child.on("error", () => resolve$1(false));
|
|
397
|
+
child.on("close", (code) => resolve$1(code === 0));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
388
400
|
/**
|
|
389
|
-
*
|
|
401
|
+
* Save a key to 1Password using the CLI
|
|
402
|
+
* Creates a secure note with the signing key
|
|
390
403
|
*/
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
404
|
+
async function saveTo1Password(key, handle) {
|
|
405
|
+
const itemName = `Cirrus PDS Signing Key - ${handle}`;
|
|
406
|
+
return new Promise((resolve$1) => {
|
|
407
|
+
const child = spawn("op", [
|
|
408
|
+
"item",
|
|
409
|
+
"create",
|
|
410
|
+
"--category",
|
|
411
|
+
"Secure Note",
|
|
412
|
+
"--title",
|
|
413
|
+
itemName,
|
|
414
|
+
`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}`,
|
|
415
|
+
"--tags",
|
|
416
|
+
"cirrus,pds,signing-key"
|
|
417
|
+
], { stdio: [
|
|
418
|
+
"ignore",
|
|
419
|
+
"pipe",
|
|
420
|
+
"pipe"
|
|
421
|
+
] });
|
|
422
|
+
let stderr = "";
|
|
423
|
+
child.stderr?.on("data", (data) => {
|
|
424
|
+
stderr += data.toString();
|
|
425
|
+
});
|
|
426
|
+
child.on("error", (err) => {
|
|
427
|
+
resolve$1({
|
|
428
|
+
success: false,
|
|
429
|
+
error: err.message
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
child.on("close", (code) => {
|
|
433
|
+
if (code === 0) resolve$1({
|
|
434
|
+
success: true,
|
|
435
|
+
itemName
|
|
436
|
+
});
|
|
437
|
+
else resolve$1({
|
|
438
|
+
success: false,
|
|
439
|
+
error: stderr || `1Password CLI exited with code ${code}`
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
}
|
|
420
444
|
/**
|
|
421
|
-
*
|
|
445
|
+
* Save a password to 1Password as a Login item for bsky.app
|
|
422
446
|
*/
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
447
|
+
async function savePasswordTo1Password(password, handle) {
|
|
448
|
+
const itemName = `Bluesky - @${handle}`;
|
|
449
|
+
return new Promise((resolve$1) => {
|
|
450
|
+
const child = spawn("op", [
|
|
451
|
+
"item",
|
|
452
|
+
"create",
|
|
453
|
+
"--category",
|
|
454
|
+
"Login",
|
|
455
|
+
"--title",
|
|
456
|
+
itemName,
|
|
457
|
+
`username=${handle}`,
|
|
458
|
+
`password=${password}`,
|
|
459
|
+
"--url=https://bsky.app",
|
|
460
|
+
"--tags",
|
|
461
|
+
"cirrus,pds,bluesky"
|
|
462
|
+
], { stdio: [
|
|
463
|
+
"ignore",
|
|
464
|
+
"pipe",
|
|
465
|
+
"pipe"
|
|
466
|
+
] });
|
|
467
|
+
let stderr = "";
|
|
468
|
+
child.stderr?.on("data", (data) => {
|
|
469
|
+
stderr += data.toString();
|
|
470
|
+
});
|
|
471
|
+
child.on("error", (err) => {
|
|
472
|
+
resolve$1({
|
|
473
|
+
success: false,
|
|
474
|
+
error: err.message
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
child.on("close", (code) => {
|
|
478
|
+
if (code === 0) resolve$1({
|
|
479
|
+
success: true,
|
|
480
|
+
itemName
|
|
481
|
+
});
|
|
482
|
+
else resolve$1({
|
|
483
|
+
success: false,
|
|
484
|
+
error: stderr || `1Password CLI exited with code ${code}`
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Run a shell command and return a promise.
|
|
491
|
+
* Captures output and throws on non-zero exit code.
|
|
492
|
+
* Use this for running npm/pnpm/yarn scripts etc.
|
|
493
|
+
*/
|
|
494
|
+
function runCommand(cmd, args, options = {}) {
|
|
495
|
+
return new Promise((resolve$1, reject) => {
|
|
496
|
+
const child = spawn(cmd, args, { stdio: options.stream ? "inherit" : "pipe" });
|
|
497
|
+
let output = "";
|
|
498
|
+
if (!options.stream) {
|
|
499
|
+
child.stdout?.on("data", (data) => {
|
|
500
|
+
output += data.toString();
|
|
501
|
+
});
|
|
502
|
+
child.stderr?.on("data", (data) => {
|
|
503
|
+
output += data.toString();
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
child.on("close", (code) => {
|
|
507
|
+
if (code === 0) resolve$1();
|
|
508
|
+
else {
|
|
509
|
+
if (output && !options.stream) console.error(output);
|
|
510
|
+
reject(/* @__PURE__ */ new Error(`${cmd} ${args.join(" ")} failed with code ${code}`));
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
child.on("error", reject);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Save a key backup file with appropriate warnings
|
|
518
|
+
*/
|
|
519
|
+
async function saveKeyBackup(key, handle) {
|
|
520
|
+
const filename = `signing-key-backup-${handle.replace(/[^a-z0-9]/gi, "-")}.txt`;
|
|
521
|
+
const filepath = join(process.cwd(), filename);
|
|
522
|
+
await writeFile(filepath, [
|
|
523
|
+
"=".repeat(60),
|
|
524
|
+
"CIRRUS PDS SIGNING KEY BACKUP",
|
|
525
|
+
"=".repeat(60),
|
|
526
|
+
"",
|
|
527
|
+
`Handle: ${handle}`,
|
|
528
|
+
`Created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
529
|
+
"",
|
|
530
|
+
"WARNING: This key controls your identity!",
|
|
531
|
+
"- Store this file in a secure location (password manager, encrypted drive)",
|
|
532
|
+
"- Delete this file from your local disk after backing up",
|
|
533
|
+
"- Never share this key with anyone",
|
|
534
|
+
"- If compromised, your identity can be stolen",
|
|
535
|
+
"",
|
|
536
|
+
"=".repeat(60),
|
|
537
|
+
"SIGNING KEY (hex-encoded secp256k1 private key)",
|
|
538
|
+
"=".repeat(60),
|
|
539
|
+
"",
|
|
540
|
+
key,
|
|
541
|
+
"",
|
|
542
|
+
"=".repeat(60)
|
|
543
|
+
].join("\n"), { mode: 384 });
|
|
544
|
+
return filepath;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region src/cli/utils/secrets.ts
|
|
549
|
+
/**
|
|
550
|
+
* Secret generation and management utilities for PDS CLI
|
|
551
|
+
*/
|
|
552
|
+
/**
|
|
553
|
+
* Generate a new secp256k1 signing keypair
|
|
554
|
+
*/
|
|
555
|
+
async function generateSigningKeypair() {
|
|
556
|
+
const keypair = await Secp256k1Keypair.create({ exportable: true });
|
|
557
|
+
return {
|
|
558
|
+
privateKey: Buffer.from(await keypair.export()).toString("hex"),
|
|
559
|
+
publicKey: keypair.did().replace("did:key:", "")
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Derive public key from an existing private key
|
|
564
|
+
*/
|
|
565
|
+
async function derivePublicKey(privateKeyHex) {
|
|
566
|
+
return (await Secp256k1Keypair.import(privateKeyHex)).did().replace("did:key:", "");
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Generate a random auth token (base64url, 32 bytes)
|
|
570
|
+
*/
|
|
571
|
+
function generateAuthToken() {
|
|
572
|
+
return randomBytes(32).toString("base64url");
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Generate a random JWT secret (base64, 32 bytes)
|
|
576
|
+
*/
|
|
577
|
+
function generateJwtSecret() {
|
|
578
|
+
return randomBytes(32).toString("base64");
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Hash a password using bcrypt
|
|
582
|
+
*/
|
|
583
|
+
async function hashPassword(password) {
|
|
584
|
+
return bcrypt.hash(password, 10);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Generate a random password (base64url, 24 bytes = 32 chars)
|
|
588
|
+
*/
|
|
589
|
+
function generatePassword() {
|
|
590
|
+
return randomBytes(24).toString("base64url");
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Prompt for password with confirmation (max 3 attempts),
|
|
594
|
+
* or generate one automatically
|
|
595
|
+
*/
|
|
596
|
+
async function promptPassword(handle) {
|
|
597
|
+
if (await promptSelect({
|
|
598
|
+
message: handle ? `Set a password for @${handle}:` : "Set a password:",
|
|
599
|
+
options: [{
|
|
600
|
+
value: "manual",
|
|
601
|
+
label: "Choose a password"
|
|
602
|
+
}, {
|
|
603
|
+
value: "generate",
|
|
604
|
+
label: "Generate one automatically"
|
|
605
|
+
}]
|
|
606
|
+
}) === "generate") {
|
|
607
|
+
const password = generatePassword();
|
|
608
|
+
const has1Password = await is1PasswordAvailable();
|
|
609
|
+
const saveOptions = [];
|
|
610
|
+
if (has1Password) saveOptions.push({
|
|
611
|
+
value: "1password",
|
|
612
|
+
label: "Save to 1Password",
|
|
613
|
+
hint: "as a bsky.app login"
|
|
614
|
+
});
|
|
615
|
+
saveOptions.push({
|
|
616
|
+
value: "clipboard",
|
|
617
|
+
label: "Copy to clipboard",
|
|
618
|
+
hint: "paste into password manager"
|
|
619
|
+
}, {
|
|
620
|
+
value: "show",
|
|
621
|
+
label: "Display it",
|
|
622
|
+
hint: "shown in terminal"
|
|
623
|
+
});
|
|
624
|
+
const saveChoice = await promptSelect({
|
|
625
|
+
message: "Where should we save the password?",
|
|
626
|
+
options: saveOptions
|
|
627
|
+
});
|
|
628
|
+
if (saveChoice === "1password") {
|
|
629
|
+
const spinner = p.spinner();
|
|
630
|
+
spinner.start("Saving to 1Password...");
|
|
631
|
+
const result = await savePasswordTo1Password(password, handle ?? "");
|
|
632
|
+
if (result.success) {
|
|
633
|
+
spinner.stop("Saved to 1Password");
|
|
634
|
+
p.log.success(`Created: "${result.itemName}"`);
|
|
635
|
+
} else {
|
|
636
|
+
spinner.stop("Failed to save to 1Password");
|
|
637
|
+
p.log.error(result.error || "Unknown error");
|
|
638
|
+
if (await copyToClipboard(password)) p.log.info("Copied to clipboard instead");
|
|
639
|
+
else {
|
|
640
|
+
p.note(password, "Generated password");
|
|
641
|
+
p.log.warn("Save this password somewhere safe!");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} else if (saveChoice === "clipboard") if (await copyToClipboard(password)) p.log.success("Password generated and copied to clipboard");
|
|
645
|
+
else {
|
|
646
|
+
p.note(password, "Generated password");
|
|
647
|
+
p.log.warn("Could not copy to clipboard — save this password somewhere safe!");
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
p.note(password, "Generated password");
|
|
651
|
+
p.log.warn("Save this password somewhere safe!");
|
|
652
|
+
}
|
|
653
|
+
return password;
|
|
654
|
+
}
|
|
655
|
+
const message = handle ? `Choose a password for @${handle}:` : "Enter password:";
|
|
656
|
+
const MAX_ATTEMPTS = 3;
|
|
657
|
+
let attempts = 0;
|
|
658
|
+
while (attempts < MAX_ATTEMPTS) {
|
|
659
|
+
attempts++;
|
|
660
|
+
const password = await p.password({ message });
|
|
661
|
+
if (p.isCancel(password)) {
|
|
662
|
+
p.cancel("Cancelled");
|
|
663
|
+
process.exit(0);
|
|
664
|
+
}
|
|
665
|
+
const confirm = await p.password({ message: "Confirm password:" });
|
|
666
|
+
if (p.isCancel(confirm)) {
|
|
667
|
+
p.cancel("Cancelled");
|
|
668
|
+
process.exit(0);
|
|
669
|
+
}
|
|
670
|
+
if (password === confirm) return password;
|
|
671
|
+
p.log.error("Passwords do not match. Try again.");
|
|
672
|
+
}
|
|
673
|
+
p.log.error("Too many failed attempts.");
|
|
674
|
+
p.cancel("Password setup cancelled");
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Set a secret value, either locally (.dev.vars) or via wrangler
|
|
679
|
+
*/
|
|
680
|
+
async function setSecretValue(name, value, local) {
|
|
681
|
+
if (local) setDevVar(name, value);
|
|
682
|
+
else await setSecret(name, value);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Set a public var in wrangler.jsonc
|
|
686
|
+
*/
|
|
687
|
+
function setPublicVar(name, value, local) {
|
|
688
|
+
if (local) setDevVar(name, value);
|
|
689
|
+
else setVar(name, value);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
//#endregion
|
|
693
|
+
//#region src/cli/commands/secret/jwt.ts
|
|
694
|
+
/**
|
|
695
|
+
* JWT secret generation command
|
|
696
|
+
*/
|
|
697
|
+
const jwtCommand = defineCommand({
|
|
698
|
+
meta: {
|
|
699
|
+
name: "jwt",
|
|
700
|
+
description: "Generate and set JWT signing secret"
|
|
701
|
+
},
|
|
702
|
+
args: { local: {
|
|
703
|
+
type: "boolean",
|
|
704
|
+
description: "Write to .dev.vars instead of wrangler secrets",
|
|
705
|
+
default: false
|
|
706
|
+
} },
|
|
707
|
+
async run({ args }) {
|
|
708
|
+
p.intro("Generate JWT Secret");
|
|
709
|
+
const secret = generateJwtSecret();
|
|
710
|
+
try {
|
|
711
|
+
await setSecretValue("JWT_SECRET", secret, args.local);
|
|
712
|
+
p.outro(args.local ? "JWT_SECRET written to .dev.vars" : "Done!");
|
|
713
|
+
} catch (error) {
|
|
714
|
+
p.log.error(String(error));
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/cli/commands/secret/password.ts
|
|
722
|
+
/**
|
|
723
|
+
* Password hash generation command
|
|
724
|
+
*/
|
|
725
|
+
const passwordCommand = defineCommand({
|
|
726
|
+
meta: {
|
|
727
|
+
name: "password",
|
|
728
|
+
description: "Set account password (stored as bcrypt hash)"
|
|
729
|
+
},
|
|
730
|
+
args: { local: {
|
|
731
|
+
type: "boolean",
|
|
732
|
+
description: "Write to .dev.vars instead of wrangler secrets",
|
|
733
|
+
default: false
|
|
734
|
+
} },
|
|
735
|
+
async run({ args }) {
|
|
736
|
+
p.intro("Set Account Password");
|
|
737
|
+
const password = await promptPassword();
|
|
738
|
+
const spinner = p.spinner();
|
|
739
|
+
spinner.start("Hashing password...");
|
|
740
|
+
const passwordHash = await hashPassword(password);
|
|
741
|
+
spinner.stop("Password hashed");
|
|
742
|
+
try {
|
|
743
|
+
await setSecretValue("PASSWORD_HASH", passwordHash, args.local);
|
|
744
|
+
p.outro(args.local ? "PASSWORD_HASH written to .dev.vars" : "Done!");
|
|
745
|
+
} catch (error) {
|
|
746
|
+
p.log.error(String(error));
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/cli/commands/secret/key.ts
|
|
754
|
+
/**
|
|
755
|
+
* Signing key generation command
|
|
756
|
+
*/
|
|
757
|
+
const keyCommand = defineCommand({
|
|
758
|
+
meta: {
|
|
759
|
+
name: "key",
|
|
760
|
+
description: "Generate and set signing keypair"
|
|
761
|
+
},
|
|
762
|
+
args: { local: {
|
|
763
|
+
type: "boolean",
|
|
764
|
+
description: "Write to .dev.vars instead of wrangler secrets/config",
|
|
765
|
+
default: false
|
|
432
766
|
} },
|
|
433
767
|
async run({ args }) {
|
|
434
768
|
p.intro("Generate Signing Keypair");
|
|
@@ -913,56 +1247,7 @@ var PDSClient = class PDSClient {
|
|
|
913
1247
|
const res = await fetch(url.toString(), {
|
|
914
1248
|
method: "POST",
|
|
915
1249
|
headers,
|
|
916
|
-
body: JSON.stringify({ name })
|
|
917
|
-
});
|
|
918
|
-
if (!res.ok) {
|
|
919
|
-
const errorBody = await res.json().catch(() => ({}));
|
|
920
|
-
throw new ClientResponseError({
|
|
921
|
-
status: res.status,
|
|
922
|
-
headers: res.headers,
|
|
923
|
-
data: {
|
|
924
|
-
error: errorBody.error ?? "Unknown",
|
|
925
|
-
message: errorBody.message
|
|
926
|
-
}
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
return res.json();
|
|
930
|
-
}
|
|
931
|
-
/**
|
|
932
|
-
* List all registered passkeys
|
|
933
|
-
*/
|
|
934
|
-
async listPasskeys() {
|
|
935
|
-
const url = new URL("/passkey/list", this.baseUrl);
|
|
936
|
-
const headers = {};
|
|
937
|
-
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
938
|
-
const res = await fetch(url.toString(), {
|
|
939
|
-
method: "GET",
|
|
940
|
-
headers
|
|
941
|
-
});
|
|
942
|
-
if (!res.ok) {
|
|
943
|
-
const errorBody = await res.json().catch(() => ({}));
|
|
944
|
-
throw new ClientResponseError({
|
|
945
|
-
status: res.status,
|
|
946
|
-
headers: res.headers,
|
|
947
|
-
data: {
|
|
948
|
-
error: errorBody.error ?? "Unknown",
|
|
949
|
-
message: errorBody.message
|
|
950
|
-
}
|
|
951
|
-
});
|
|
952
|
-
}
|
|
953
|
-
return res.json();
|
|
954
|
-
}
|
|
955
|
-
/**
|
|
956
|
-
* Delete a passkey by credential ID
|
|
957
|
-
*/
|
|
958
|
-
async deletePasskey(credentialId) {
|
|
959
|
-
const url = new URL("/passkey/delete", this.baseUrl);
|
|
960
|
-
const headers = { "Content-Type": "application/json" };
|
|
961
|
-
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
962
|
-
const res = await fetch(url.toString(), {
|
|
963
|
-
method: "POST",
|
|
964
|
-
headers,
|
|
965
|
-
body: JSON.stringify({ id: credentialId })
|
|
1250
|
+
body: JSON.stringify({ name })
|
|
966
1251
|
});
|
|
967
1252
|
if (!res.ok) {
|
|
968
1253
|
const errorBody = await res.json().catch(() => ({}));
|
|
@@ -978,293 +1263,152 @@ var PDSClient = class PDSClient {
|
|
|
978
1263
|
return res.json();
|
|
979
1264
|
}
|
|
980
1265
|
/**
|
|
981
|
-
*
|
|
982
|
-
* This token can be used to migrate to another PDS.
|
|
1266
|
+
* List all registered passkeys
|
|
983
1267
|
*/
|
|
984
|
-
async
|
|
985
|
-
const url = new URL("/
|
|
1268
|
+
async listPasskeys() {
|
|
1269
|
+
const url = new URL("/passkey/list", this.baseUrl);
|
|
986
1270
|
const headers = {};
|
|
987
1271
|
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
988
1272
|
const res = await fetch(url.toString(), {
|
|
989
1273
|
method: "GET",
|
|
990
1274
|
headers
|
|
991
1275
|
});
|
|
992
|
-
if (!res.ok)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
/**
|
|
1003
|
-
* Get relay's view of this PDS host status from a single relay.
|
|
1004
|
-
* Calls com.atproto.sync.getHostStatus on the relay.
|
|
1005
|
-
*/
|
|
1006
|
-
async getRelayHostStatus(pdsHostname, relayUrl) {
|
|
1007
|
-
try {
|
|
1008
|
-
const url = new URL("/xrpc/com.atproto.sync.getHostStatus", relayUrl);
|
|
1009
|
-
url.searchParams.set("hostname", pdsHostname);
|
|
1010
|
-
const res = await fetch(url.toString());
|
|
1011
|
-
if (!res.ok) return null;
|
|
1012
|
-
return {
|
|
1013
|
-
...await res.json(),
|
|
1014
|
-
relay: relayUrl
|
|
1015
|
-
};
|
|
1016
|
-
} catch {
|
|
1017
|
-
return null;
|
|
1276
|
+
if (!res.ok) {
|
|
1277
|
+
const errorBody = await res.json().catch(() => ({}));
|
|
1278
|
+
throw new ClientResponseError({
|
|
1279
|
+
status: res.status,
|
|
1280
|
+
headers: res.headers,
|
|
1281
|
+
data: {
|
|
1282
|
+
error: errorBody.error ?? "Unknown",
|
|
1283
|
+
message: errorBody.message
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1018
1286
|
}
|
|
1287
|
+
return res.json();
|
|
1019
1288
|
}
|
|
1020
1289
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
* Returns results from each relay that responds.
|
|
1023
|
-
*/
|
|
1024
|
-
async getAllRelayHostStatus(pdsHostname) {
|
|
1025
|
-
return (await Promise.all(PDSClient.RELAY_URLS.map((url) => this.getRelayHostStatus(pdsHostname, url)))).filter((r) => r !== null);
|
|
1026
|
-
}
|
|
1027
|
-
/**
|
|
1028
|
-
* Request the relay to crawl this PDS.
|
|
1029
|
-
* This notifies the Bluesky relay that the PDS is active and ready for federation.
|
|
1030
|
-
* Uses bsky.network by default (the main relay endpoint).
|
|
1290
|
+
* Delete a passkey by credential ID
|
|
1031
1291
|
*/
|
|
1032
|
-
async
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
})
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
*/
|
|
1051
|
-
/**
|
|
1052
|
-
* Prompt for text input, exiting on cancel
|
|
1053
|
-
*/
|
|
1054
|
-
async function promptText(options) {
|
|
1055
|
-
const result = await p.text(options);
|
|
1056
|
-
if (p.isCancel(result)) {
|
|
1057
|
-
p.cancel("Cancelled");
|
|
1058
|
-
process.exit(0);
|
|
1059
|
-
}
|
|
1060
|
-
return result;
|
|
1061
|
-
}
|
|
1062
|
-
/**
|
|
1063
|
-
* Prompt for confirmation, exiting on cancel
|
|
1064
|
-
*/
|
|
1065
|
-
async function promptConfirm(options) {
|
|
1066
|
-
const result = await p.confirm(options);
|
|
1067
|
-
if (p.isCancel(result)) {
|
|
1068
|
-
p.cancel("Cancelled");
|
|
1069
|
-
process.exit(0);
|
|
1070
|
-
}
|
|
1071
|
-
return result;
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* Prompt for selection, exiting on cancel
|
|
1075
|
-
*/
|
|
1076
|
-
async function promptSelect(options) {
|
|
1077
|
-
const result = await p.select(options);
|
|
1078
|
-
if (p.isCancel(result)) {
|
|
1079
|
-
p.cancel("Cancelled");
|
|
1080
|
-
process.exit(0);
|
|
1081
|
-
}
|
|
1082
|
-
return result;
|
|
1083
|
-
}
|
|
1084
|
-
/**
|
|
1085
|
-
* Get target PDS URL based on mode
|
|
1086
|
-
*/
|
|
1087
|
-
function getTargetUrl(isDev, pdsHostname) {
|
|
1088
|
-
if (isDev) return `http://localhost:${process.env.PORT ? parseInt(process.env.PORT) ?? "5173" : "5173"}`;
|
|
1089
|
-
if (!pdsHostname) throw new Error("PDS_HOSTNAME not configured in wrangler.jsonc");
|
|
1090
|
-
return `https://${pdsHostname}`;
|
|
1091
|
-
}
|
|
1092
|
-
/**
|
|
1093
|
-
* Extract domain from URL
|
|
1094
|
-
*/
|
|
1095
|
-
function getDomain(url) {
|
|
1096
|
-
try {
|
|
1097
|
-
return new URL(url).hostname;
|
|
1098
|
-
} catch {
|
|
1099
|
-
return url;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* Detect which package manager is being used based on npm_config_user_agent
|
|
1104
|
-
*/
|
|
1105
|
-
function detectPackageManager() {
|
|
1106
|
-
const userAgent = process.env.npm_config_user_agent || "";
|
|
1107
|
-
if (userAgent.startsWith("yarn")) return "yarn";
|
|
1108
|
-
if (userAgent.startsWith("pnpm")) return "pnpm";
|
|
1109
|
-
if (userAgent.startsWith("bun")) return "bun";
|
|
1110
|
-
return "npm";
|
|
1111
|
-
}
|
|
1112
|
-
/**
|
|
1113
|
-
* Format a command for the detected package manager
|
|
1114
|
-
* npm always needs "run" for scripts, pnpm/yarn/bun can use shorthand
|
|
1115
|
-
* except for "deploy" which conflicts with pnpm's built-in deploy command
|
|
1116
|
-
*/
|
|
1117
|
-
function formatCommand(pm, ...args) {
|
|
1118
|
-
if (pm === "npm" || args[0] === "deploy") return `${pm} run ${args.join(" ")}`;
|
|
1119
|
-
return `${pm} ${args.join(" ")}`;
|
|
1120
|
-
}
|
|
1121
|
-
/**
|
|
1122
|
-
* Copy text to clipboard using platform-specific command
|
|
1123
|
-
* Falls back gracefully if clipboard is unavailable
|
|
1124
|
-
*/
|
|
1125
|
-
async function copyToClipboard(text) {
|
|
1126
|
-
const platform = process.platform;
|
|
1127
|
-
let cmd;
|
|
1128
|
-
let args;
|
|
1129
|
-
if (platform === "darwin") {
|
|
1130
|
-
cmd = "pbcopy";
|
|
1131
|
-
args = [];
|
|
1132
|
-
} else if (platform === "linux") {
|
|
1133
|
-
cmd = "xclip";
|
|
1134
|
-
args = ["-selection", "clipboard"];
|
|
1135
|
-
} else if (platform === "win32") {
|
|
1136
|
-
cmd = "clip";
|
|
1137
|
-
args = [];
|
|
1138
|
-
} else return false;
|
|
1139
|
-
return new Promise((resolve$1) => {
|
|
1140
|
-
const child = spawn(cmd, args, { stdio: [
|
|
1141
|
-
"pipe",
|
|
1142
|
-
"ignore",
|
|
1143
|
-
"ignore"
|
|
1144
|
-
] });
|
|
1145
|
-
child.on("error", () => resolve$1(false));
|
|
1146
|
-
child.on("close", (code) => resolve$1(code === 0));
|
|
1147
|
-
child.stdin?.write(text);
|
|
1148
|
-
child.stdin?.end();
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* Check if 1Password CLI (op) is available
|
|
1153
|
-
* Only checks on POSIX systems (macOS, Linux)
|
|
1154
|
-
*/
|
|
1155
|
-
async function is1PasswordAvailable() {
|
|
1156
|
-
if (process.platform === "win32") return false;
|
|
1157
|
-
return new Promise((resolve$1) => {
|
|
1158
|
-
const child = spawn("which", ["op"], { stdio: [
|
|
1159
|
-
"ignore",
|
|
1160
|
-
"pipe",
|
|
1161
|
-
"ignore"
|
|
1162
|
-
] });
|
|
1163
|
-
child.on("error", () => resolve$1(false));
|
|
1164
|
-
child.on("close", (code) => resolve$1(code === 0));
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
/**
|
|
1168
|
-
* Save a key to 1Password using the CLI
|
|
1169
|
-
* Creates a secure note with the signing key
|
|
1170
|
-
*/
|
|
1171
|
-
async function saveTo1Password(key, handle) {
|
|
1172
|
-
const itemName = `Cirrus PDS Signing Key - ${handle}`;
|
|
1173
|
-
return new Promise((resolve$1) => {
|
|
1174
|
-
const child = spawn("op", [
|
|
1175
|
-
"item",
|
|
1176
|
-
"create",
|
|
1177
|
-
"--category",
|
|
1178
|
-
"Secure Note",
|
|
1179
|
-
"--title",
|
|
1180
|
-
itemName,
|
|
1181
|
-
`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}`,
|
|
1182
|
-
"--tags",
|
|
1183
|
-
"cirrus,pds,signing-key"
|
|
1184
|
-
], { stdio: [
|
|
1185
|
-
"ignore",
|
|
1186
|
-
"pipe",
|
|
1187
|
-
"pipe"
|
|
1188
|
-
] });
|
|
1189
|
-
let stderr = "";
|
|
1190
|
-
child.stderr?.on("data", (data) => {
|
|
1191
|
-
stderr += data.toString();
|
|
1192
|
-
});
|
|
1193
|
-
child.on("error", (err) => {
|
|
1194
|
-
resolve$1({
|
|
1195
|
-
success: false,
|
|
1196
|
-
error: err.message
|
|
1197
|
-
});
|
|
1198
|
-
});
|
|
1199
|
-
child.on("close", (code) => {
|
|
1200
|
-
if (code === 0) resolve$1({
|
|
1201
|
-
success: true,
|
|
1202
|
-
itemName
|
|
1203
|
-
});
|
|
1204
|
-
else resolve$1({
|
|
1205
|
-
success: false,
|
|
1206
|
-
error: stderr || `1Password CLI exited with code ${code}`
|
|
1207
|
-
});
|
|
1208
|
-
});
|
|
1209
|
-
});
|
|
1210
|
-
}
|
|
1211
|
-
/**
|
|
1212
|
-
* Run a shell command and return a promise.
|
|
1213
|
-
* Captures output and throws on non-zero exit code.
|
|
1214
|
-
* Use this for running npm/pnpm/yarn scripts etc.
|
|
1215
|
-
*/
|
|
1216
|
-
function runCommand(cmd, args, options = {}) {
|
|
1217
|
-
return new Promise((resolve$1, reject) => {
|
|
1218
|
-
const child = spawn(cmd, args, { stdio: options.stream ? "inherit" : "pipe" });
|
|
1219
|
-
let output = "";
|
|
1220
|
-
if (!options.stream) {
|
|
1221
|
-
child.stdout?.on("data", (data) => {
|
|
1222
|
-
output += data.toString();
|
|
1223
|
-
});
|
|
1224
|
-
child.stderr?.on("data", (data) => {
|
|
1225
|
-
output += data.toString();
|
|
1292
|
+
async deletePasskey(credentialId) {
|
|
1293
|
+
const url = new URL("/passkey/delete", this.baseUrl);
|
|
1294
|
+
const headers = { "Content-Type": "application/json" };
|
|
1295
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1296
|
+
const res = await fetch(url.toString(), {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers,
|
|
1299
|
+
body: JSON.stringify({ id: credentialId })
|
|
1300
|
+
});
|
|
1301
|
+
if (!res.ok) {
|
|
1302
|
+
const errorBody = await res.json().catch(() => ({}));
|
|
1303
|
+
throw new ClientResponseError({
|
|
1304
|
+
status: res.status,
|
|
1305
|
+
headers: res.headers,
|
|
1306
|
+
data: {
|
|
1307
|
+
error: errorBody.error ?? "Unknown",
|
|
1308
|
+
message: errorBody.message
|
|
1309
|
+
}
|
|
1226
1310
|
});
|
|
1227
1311
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1312
|
+
return res.json();
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Get a migration token for outbound migration.
|
|
1316
|
+
* This token can be used to migrate to another PDS.
|
|
1317
|
+
*/
|
|
1318
|
+
async getMigrationToken() {
|
|
1319
|
+
const url = new URL("/xrpc/gg.mk.experimental.getMigrationToken", this.baseUrl);
|
|
1320
|
+
const headers = {};
|
|
1321
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1322
|
+
const res = await fetch(url.toString(), {
|
|
1323
|
+
method: "GET",
|
|
1324
|
+
headers
|
|
1234
1325
|
});
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
"",
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
""
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1326
|
+
if (!res.ok) return {
|
|
1327
|
+
success: false,
|
|
1328
|
+
error: (await res.json().catch(() => ({}))).message ?? `Request failed: ${res.status}`
|
|
1329
|
+
};
|
|
1330
|
+
return {
|
|
1331
|
+
success: true,
|
|
1332
|
+
token: (await res.json()).token
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* List notifications (proxied through PDS to AppView)
|
|
1337
|
+
*/
|
|
1338
|
+
async listNotifications(limit = 25) {
|
|
1339
|
+
const url = new URL("/xrpc/app.bsky.notification.listNotifications", this.baseUrl);
|
|
1340
|
+
url.searchParams.set("limit", String(limit));
|
|
1341
|
+
const headers = {};
|
|
1342
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1343
|
+
const res = await fetch(url.toString(), { headers });
|
|
1344
|
+
if (!res.ok) throw new Error(`Failed to get notifications: ${res.status}`);
|
|
1345
|
+
return res.json();
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* List repos (for getting PDS rev)
|
|
1349
|
+
*/
|
|
1350
|
+
async listRepos() {
|
|
1351
|
+
const url = new URL("/xrpc/com.atproto.sync.listRepos", this.baseUrl);
|
|
1352
|
+
const res = await fetch(url.toString());
|
|
1353
|
+
if (!res.ok) throw new Error(`Failed to list repos: ${res.status}`);
|
|
1354
|
+
return res.json();
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* List records in a collection
|
|
1358
|
+
*/
|
|
1359
|
+
async listRecords(did, collection, limit = 100) {
|
|
1360
|
+
const url = new URL("/xrpc/com.atproto.repo.listRecords", this.baseUrl);
|
|
1361
|
+
url.searchParams.set("repo", did);
|
|
1362
|
+
url.searchParams.set("collection", collection);
|
|
1363
|
+
url.searchParams.set("limit", String(limit));
|
|
1364
|
+
const res = await fetch(url.toString());
|
|
1365
|
+
if (!res.ok) throw new Error(`Failed to list records: ${res.status}`);
|
|
1366
|
+
return res.json();
|
|
1367
|
+
}
|
|
1368
|
+
static RELAY_URLS = ["https://relay1.us-west.bsky.network", "https://relay1.us-east.bsky.network"];
|
|
1369
|
+
/**
|
|
1370
|
+
* Get relay's view of this PDS host status from a single relay.
|
|
1371
|
+
* Calls com.atproto.sync.getHostStatus on the relay.
|
|
1372
|
+
*/
|
|
1373
|
+
async getRelayHostStatus(pdsHostname, relayUrl) {
|
|
1374
|
+
try {
|
|
1375
|
+
const url = new URL("/xrpc/com.atproto.sync.getHostStatus", relayUrl);
|
|
1376
|
+
url.searchParams.set("hostname", pdsHostname);
|
|
1377
|
+
const res = await fetch(url.toString());
|
|
1378
|
+
if (!res.ok) return null;
|
|
1379
|
+
return {
|
|
1380
|
+
...await res.json(),
|
|
1381
|
+
relay: relayUrl
|
|
1382
|
+
};
|
|
1383
|
+
} catch {
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Get relay status from all known relays.
|
|
1389
|
+
* Returns results from each relay that responds.
|
|
1390
|
+
*/
|
|
1391
|
+
async getAllRelayHostStatus(pdsHostname) {
|
|
1392
|
+
return (await Promise.all(PDSClient.RELAY_URLS.map((url) => this.getRelayHostStatus(pdsHostname, url)))).filter((r) => r !== null);
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Request the relay to crawl this PDS.
|
|
1396
|
+
* This notifies the Bluesky relay that the PDS is active and ready for federation.
|
|
1397
|
+
* Uses bsky.network by default (the main relay endpoint).
|
|
1398
|
+
*/
|
|
1399
|
+
async requestCrawl(pdsHostname, relayUrl = "https://bsky.network") {
|
|
1400
|
+
try {
|
|
1401
|
+
const url = new URL("/xrpc/com.atproto.sync.requestCrawl", relayUrl);
|
|
1402
|
+
return (await fetch(url.toString(), {
|
|
1403
|
+
method: "POST",
|
|
1404
|
+
headers: { "Content-Type": "application/json" },
|
|
1405
|
+
body: JSON.stringify({ hostname: pdsHostname })
|
|
1406
|
+
})).ok;
|
|
1407
|
+
} catch {
|
|
1408
|
+
return false;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1268
1412
|
|
|
1269
1413
|
//#endregion
|
|
1270
1414
|
//#region src/cli/commands/passkey/add.ts
|
|
@@ -3801,7 +3945,8 @@ const statusCommand = defineCommand({
|
|
|
3801
3945
|
}
|
|
3802
3946
|
try {
|
|
3803
3947
|
const firehose = await client.getFirehoseStatus();
|
|
3804
|
-
|
|
3948
|
+
const subCount = firehose.subscribers.length;
|
|
3949
|
+
console.log(` ${INFO} ${subCount} firehose subscriber${subCount !== 1 ? "s" : ""}, seq: ${firehose.latestSeq ?? "none"}`);
|
|
3805
3950
|
} catch {
|
|
3806
3951
|
console.log(` ${pc.dim(" Could not get firehose status")}`);
|
|
3807
3952
|
}
|
|
@@ -3896,6 +4041,576 @@ const emitIdentityCommand = defineCommand({
|
|
|
3896
4041
|
}
|
|
3897
4042
|
});
|
|
3898
4043
|
|
|
4044
|
+
//#endregion
|
|
4045
|
+
//#region src/cli/commands/dashboard.ts
|
|
4046
|
+
/**
|
|
4047
|
+
* Live terminal dashboard for PDS monitoring
|
|
4048
|
+
*/
|
|
4049
|
+
function stripAnsi(s) {
|
|
4050
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
4051
|
+
}
|
|
4052
|
+
function visibleLength(s) {
|
|
4053
|
+
return stripAnsi(s).length;
|
|
4054
|
+
}
|
|
4055
|
+
function padRight(s, width) {
|
|
4056
|
+
const pad = width - visibleLength(s);
|
|
4057
|
+
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
4058
|
+
}
|
|
4059
|
+
function truncate(s, width) {
|
|
4060
|
+
if (visibleLength(s) <= width) return s;
|
|
4061
|
+
return stripAnsi(s).slice(0, width - 1) + pc.dim("…");
|
|
4062
|
+
}
|
|
4063
|
+
function enterAltScreen() {
|
|
4064
|
+
process.stdout.write("\x1B[?1049h");
|
|
4065
|
+
}
|
|
4066
|
+
function exitAltScreen() {
|
|
4067
|
+
process.stdout.write("\x1B[?1049l");
|
|
4068
|
+
}
|
|
4069
|
+
function hideCursor() {
|
|
4070
|
+
process.stdout.write("\x1B[?25l");
|
|
4071
|
+
}
|
|
4072
|
+
function showCursor() {
|
|
4073
|
+
process.stdout.write("\x1B[?25h");
|
|
4074
|
+
}
|
|
4075
|
+
function clearScreen() {
|
|
4076
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
4077
|
+
}
|
|
4078
|
+
function renderColumns(cols, widths) {
|
|
4079
|
+
const maxRows = Math.max(...cols.map((c) => c.length));
|
|
4080
|
+
const lines = [];
|
|
4081
|
+
for (let i = 0; i < maxRows; i++) {
|
|
4082
|
+
let line = "";
|
|
4083
|
+
for (let j = 0; j < cols.length; j++) {
|
|
4084
|
+
const cell = cols[j][i] ?? "";
|
|
4085
|
+
line += padRight(cell, widths[j]);
|
|
4086
|
+
}
|
|
4087
|
+
lines.push(line);
|
|
4088
|
+
}
|
|
4089
|
+
return lines;
|
|
4090
|
+
}
|
|
4091
|
+
function parseFirehoseMessage(data) {
|
|
4092
|
+
try {
|
|
4093
|
+
const decoded = [...decodeAll(data)];
|
|
4094
|
+
if (decoded.length !== 2) return null;
|
|
4095
|
+
const header = decoded[0];
|
|
4096
|
+
const body = decoded[1];
|
|
4097
|
+
if (!header || header.op !== 1) return null;
|
|
4098
|
+
if (!body || typeof body.seq !== "number") return null;
|
|
4099
|
+
if (header.t === "#commit") return {
|
|
4100
|
+
seq: body.seq,
|
|
4101
|
+
type: "commit",
|
|
4102
|
+
ops: (body.ops ?? []).map((op) => ({
|
|
4103
|
+
action: op.action,
|
|
4104
|
+
path: op.path
|
|
4105
|
+
}))
|
|
4106
|
+
};
|
|
4107
|
+
if (header.t === "#identity") return {
|
|
4108
|
+
seq: body.seq,
|
|
4109
|
+
type: "identity",
|
|
4110
|
+
ops: [],
|
|
4111
|
+
handle: body.handle
|
|
4112
|
+
};
|
|
4113
|
+
return null;
|
|
4114
|
+
} catch {
|
|
4115
|
+
return null;
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
const COLLECTION_NAMES = {
|
|
4119
|
+
"app.bsky.feed.post": "posts",
|
|
4120
|
+
"app.bsky.feed.like": "likes",
|
|
4121
|
+
"app.bsky.graph.follow": "follows",
|
|
4122
|
+
"app.bsky.feed.repost": "reposts",
|
|
4123
|
+
"app.bsky.actor.profile": "profile",
|
|
4124
|
+
"app.bsky.graph.block": "blocks",
|
|
4125
|
+
"app.bsky.graph.list": "lists",
|
|
4126
|
+
"app.bsky.graph.listitem": "list items",
|
|
4127
|
+
"app.bsky.feed.generator": "feeds",
|
|
4128
|
+
"app.bsky.feed.threadgate": "threadgates",
|
|
4129
|
+
"app.bsky.graph.starterpack": "starter packs",
|
|
4130
|
+
"chat.bsky.actor.declaration": "chat",
|
|
4131
|
+
"app.bsky.feed.postgate": "postgates",
|
|
4132
|
+
"app.bsky.labeler.service": "labeler"
|
|
4133
|
+
};
|
|
4134
|
+
/** Sort priority for collections (lower = first). Unlisted collections sort alphabetically at the end. */
|
|
4135
|
+
const COLLECTION_ORDER = {
|
|
4136
|
+
"app.bsky.feed.post": 1,
|
|
4137
|
+
"app.bsky.feed.like": 2,
|
|
4138
|
+
"app.bsky.graph.follow": 3,
|
|
4139
|
+
"app.bsky.feed.repost": 4,
|
|
4140
|
+
"app.bsky.graph.list": 5,
|
|
4141
|
+
"app.bsky.feed.generator": 6,
|
|
4142
|
+
"app.bsky.graph.block": 7,
|
|
4143
|
+
"app.bsky.graph.starterpack": 8,
|
|
4144
|
+
"app.bsky.actor.profile": 100
|
|
4145
|
+
};
|
|
4146
|
+
function friendlyName(collection) {
|
|
4147
|
+
return COLLECTION_NAMES[collection] ?? collection.split(".").pop() ?? collection;
|
|
4148
|
+
}
|
|
4149
|
+
/** Shorten IPv6 addresses by collapsing the longest zero run to :: */
|
|
4150
|
+
function shortenIP(ip) {
|
|
4151
|
+
if (!ip.includes(":")) return ip;
|
|
4152
|
+
const parts = ip.split(":");
|
|
4153
|
+
let bestStart = -1;
|
|
4154
|
+
let bestLen = 0;
|
|
4155
|
+
let curStart = -1;
|
|
4156
|
+
let curLen = 0;
|
|
4157
|
+
for (let i = 0; i < parts.length; i++) if (parts[i] === "0" || parts[i] === "0000" || parts[i] === "") {
|
|
4158
|
+
if (curStart === -1) curStart = i;
|
|
4159
|
+
curLen++;
|
|
4160
|
+
} else {
|
|
4161
|
+
if (curLen > bestLen) {
|
|
4162
|
+
bestStart = curStart;
|
|
4163
|
+
bestLen = curLen;
|
|
4164
|
+
}
|
|
4165
|
+
curStart = -1;
|
|
4166
|
+
curLen = 0;
|
|
4167
|
+
}
|
|
4168
|
+
if (curLen > bestLen) {
|
|
4169
|
+
bestStart = curStart;
|
|
4170
|
+
bestLen = curLen;
|
|
4171
|
+
}
|
|
4172
|
+
if (bestLen < 2) return parts.map((p$1) => p$1.replace(/^0+(?=.)/, "")).join(":");
|
|
4173
|
+
const before = parts.slice(0, bestStart).map((p$1) => p$1.replace(/^0+(?=.)/, ""));
|
|
4174
|
+
const after = parts.slice(bestStart + bestLen).map((p$1) => p$1.replace(/^0+(?=.)/, ""));
|
|
4175
|
+
return (before.length ? before.join(":") : "") + "::" + (after.length ? after.join(":") : "");
|
|
4176
|
+
}
|
|
4177
|
+
const REASON_ICON = {
|
|
4178
|
+
like: pc.red("♥"),
|
|
4179
|
+
repost: pc.green("↻"),
|
|
4180
|
+
follow: pc.cyan("+"),
|
|
4181
|
+
mention: pc.yellow("@"),
|
|
4182
|
+
reply: pc.cyan("↩"),
|
|
4183
|
+
quote: pc.yellow("❝"),
|
|
4184
|
+
"starterpack-joined": pc.cyan("★")
|
|
4185
|
+
};
|
|
4186
|
+
const REASON_TEXT = {
|
|
4187
|
+
like: "liked your post",
|
|
4188
|
+
repost: "reposted your post",
|
|
4189
|
+
follow: "followed you",
|
|
4190
|
+
mention: "mentioned you",
|
|
4191
|
+
reply: "replied to you",
|
|
4192
|
+
quote: "quoted your post",
|
|
4193
|
+
"starterpack-joined": "joined your starter pack"
|
|
4194
|
+
};
|
|
4195
|
+
function relativeTime(ts) {
|
|
4196
|
+
const diff = Math.floor((Date.now() - ts) / 1e3);
|
|
4197
|
+
if (diff < 10) return "just now";
|
|
4198
|
+
if (diff < 60) return `${diff}s ago`;
|
|
4199
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
4200
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
4201
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
4202
|
+
}
|
|
4203
|
+
const MAX_EVENTS = 100;
|
|
4204
|
+
function createInitialState() {
|
|
4205
|
+
return {
|
|
4206
|
+
collections: [],
|
|
4207
|
+
syncStatus: "checking",
|
|
4208
|
+
relayRev: null,
|
|
4209
|
+
pdsRev: null,
|
|
4210
|
+
subscribers: 0,
|
|
4211
|
+
latestSeq: null,
|
|
4212
|
+
subscriberDetails: [],
|
|
4213
|
+
events: [],
|
|
4214
|
+
notifications: [],
|
|
4215
|
+
accountActive: false,
|
|
4216
|
+
wsConnected: false,
|
|
4217
|
+
statusMessage: null,
|
|
4218
|
+
statusMessageTimeout: null
|
|
4219
|
+
};
|
|
4220
|
+
}
|
|
4221
|
+
async function fetchRepo(client, did, state, render) {
|
|
4222
|
+
try {
|
|
4223
|
+
const repo = (await client.listRepos()).repos?.[0];
|
|
4224
|
+
if (repo) state.pdsRev = repo.rev;
|
|
4225
|
+
const collections = (await client.describeRepo(did)).collections ?? [];
|
|
4226
|
+
const results = await Promise.all(collections.map(async (col) => {
|
|
4227
|
+
const data = await client.listRecords(did, col, 100);
|
|
4228
|
+
return {
|
|
4229
|
+
name: col,
|
|
4230
|
+
friendlyName: friendlyName(col),
|
|
4231
|
+
count: data.records?.length ?? 0,
|
|
4232
|
+
hasMore: !!data.cursor
|
|
4233
|
+
};
|
|
4234
|
+
}));
|
|
4235
|
+
results.sort((a, b) => {
|
|
4236
|
+
const oa = COLLECTION_ORDER[a.name] ?? 50;
|
|
4237
|
+
const ob = COLLECTION_ORDER[b.name] ?? 50;
|
|
4238
|
+
if (oa !== ob) return oa - ob;
|
|
4239
|
+
return a.friendlyName.localeCompare(b.friendlyName);
|
|
4240
|
+
});
|
|
4241
|
+
state.collections = results.filter((c) => c.count > 0);
|
|
4242
|
+
render();
|
|
4243
|
+
} catch {}
|
|
4244
|
+
}
|
|
4245
|
+
async function fetchRelaySync(did, state, render) {
|
|
4246
|
+
if (!state.pdsRev) return;
|
|
4247
|
+
try {
|
|
4248
|
+
const res = await fetch(`https://bsky.network/xrpc/com.atproto.sync.getLatestCommit?did=${encodeURIComponent(did)}`);
|
|
4249
|
+
if (res.status === 404) state.syncStatus = "unknown";
|
|
4250
|
+
else if (res.ok) {
|
|
4251
|
+
const data = await res.json();
|
|
4252
|
+
state.syncStatus = data.rev === state.pdsRev ? "synced" : "behind";
|
|
4253
|
+
state.relayRev = data.rev;
|
|
4254
|
+
} else state.syncStatus = "error";
|
|
4255
|
+
render();
|
|
4256
|
+
} catch {
|
|
4257
|
+
state.syncStatus = "error";
|
|
4258
|
+
render();
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
async function fetchFirehoseStatus(client, state, render) {
|
|
4262
|
+
try {
|
|
4263
|
+
const data = await client.getFirehoseStatus();
|
|
4264
|
+
state.subscribers = data.subscribers?.length ?? 0;
|
|
4265
|
+
state.subscriberDetails = data.subscribers ?? [];
|
|
4266
|
+
if (data.latestSeq != null) state.latestSeq = data.latestSeq;
|
|
4267
|
+
render();
|
|
4268
|
+
} catch {}
|
|
4269
|
+
}
|
|
4270
|
+
async function fetchNotifications(client, state, render) {
|
|
4271
|
+
try {
|
|
4272
|
+
state.notifications = ((await client.listNotifications(25)).notifications ?? []).map((n) => ({
|
|
4273
|
+
time: new Date(n.indexedAt).toLocaleTimeString("en-GB", {
|
|
4274
|
+
hour12: false,
|
|
4275
|
+
hour: "2-digit",
|
|
4276
|
+
minute: "2-digit"
|
|
4277
|
+
}),
|
|
4278
|
+
icon: REASON_ICON[n.reason] ?? "?",
|
|
4279
|
+
author: n.author.displayName || n.author.handle,
|
|
4280
|
+
text: REASON_TEXT[n.reason] ?? n.reason,
|
|
4281
|
+
isRead: n.isRead
|
|
4282
|
+
}));
|
|
4283
|
+
render();
|
|
4284
|
+
} catch {}
|
|
4285
|
+
}
|
|
4286
|
+
async function fetchAccountStatus(client, state, render) {
|
|
4287
|
+
try {
|
|
4288
|
+
state.accountActive = (await client.getAccountStatus()).active;
|
|
4289
|
+
render();
|
|
4290
|
+
} catch {}
|
|
4291
|
+
}
|
|
4292
|
+
function connectFirehose(targetUrl, state, render) {
|
|
4293
|
+
let ws = null;
|
|
4294
|
+
let reconnectTimer = null;
|
|
4295
|
+
let closed = false;
|
|
4296
|
+
function connect() {
|
|
4297
|
+
if (closed) return;
|
|
4298
|
+
try {
|
|
4299
|
+
const url = `${targetUrl.startsWith("https") ? "wss:" : "ws:"}//${targetUrl.replace(/^https?:\/\//, "")}/xrpc/com.atproto.sync.subscribeRepos`;
|
|
4300
|
+
ws = new WebSocket(url);
|
|
4301
|
+
ws.binaryType = "arraybuffer";
|
|
4302
|
+
ws.onopen = () => {
|
|
4303
|
+
state.wsConnected = true;
|
|
4304
|
+
render();
|
|
4305
|
+
};
|
|
4306
|
+
ws.onmessage = (e) => {
|
|
4307
|
+
const event = parseFirehoseMessage(new Uint8Array(e.data));
|
|
4308
|
+
if (!event) return;
|
|
4309
|
+
const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false });
|
|
4310
|
+
if (event.type === "identity") state.events.unshift({
|
|
4311
|
+
time,
|
|
4312
|
+
seq: event.seq,
|
|
4313
|
+
action: "identity",
|
|
4314
|
+
path: event.handle ?? ""
|
|
4315
|
+
});
|
|
4316
|
+
else for (const op of event.ops) state.events.unshift({
|
|
4317
|
+
time,
|
|
4318
|
+
seq: event.seq,
|
|
4319
|
+
action: op.action,
|
|
4320
|
+
path: op.path
|
|
4321
|
+
});
|
|
4322
|
+
if (state.events.length > MAX_EVENTS) state.events.length = MAX_EVENTS;
|
|
4323
|
+
render();
|
|
4324
|
+
};
|
|
4325
|
+
ws.onclose = () => {
|
|
4326
|
+
state.wsConnected = false;
|
|
4327
|
+
render();
|
|
4328
|
+
if (!closed) reconnectTimer = setTimeout(connect, 3e3);
|
|
4329
|
+
};
|
|
4330
|
+
ws.onerror = () => {
|
|
4331
|
+
state.wsConnected = false;
|
|
4332
|
+
render();
|
|
4333
|
+
};
|
|
4334
|
+
} catch {}
|
|
4335
|
+
}
|
|
4336
|
+
connect();
|
|
4337
|
+
return { close() {
|
|
4338
|
+
closed = true;
|
|
4339
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
4340
|
+
if (ws) {
|
|
4341
|
+
ws.onclose = null;
|
|
4342
|
+
ws.close();
|
|
4343
|
+
}
|
|
4344
|
+
} };
|
|
4345
|
+
}
|
|
4346
|
+
function renderDashboard(state, config) {
|
|
4347
|
+
const cols = process.stdout.columns || 80;
|
|
4348
|
+
const rows = process.stdout.rows || 24;
|
|
4349
|
+
const lines = [];
|
|
4350
|
+
const indent = " ";
|
|
4351
|
+
lines.push("");
|
|
4352
|
+
lines.push(`${indent}${pc.bold("☁ CIRRUS")} ${pc.dim("·")} ${pc.cyan(config.hostname)} ${pc.dim("·")} ${pc.dim("v" + config.version)}`);
|
|
4353
|
+
lines.push(`${indent} ${pc.white("@" + config.handle)} ${pc.dim("·")} ${pc.dim(config.did)}`);
|
|
4354
|
+
lines.push("");
|
|
4355
|
+
const colWidth = Math.floor((cols - 6) / 3);
|
|
4356
|
+
const col1 = [pc.dim("REPOSITORY"), ""];
|
|
4357
|
+
if (state.collections.length === 0) col1.push(pc.dim("Loading…"));
|
|
4358
|
+
else for (const c of state.collections) {
|
|
4359
|
+
const name = c.friendlyName.padEnd(16);
|
|
4360
|
+
const count = String(c.count).padStart(5);
|
|
4361
|
+
const more = c.hasMore ? "+" : " ";
|
|
4362
|
+
col1.push(`${name} ${pc.bold(count)}${more}`);
|
|
4363
|
+
}
|
|
4364
|
+
const col2 = [pc.dim("FEDERATION"), ""];
|
|
4365
|
+
col2.push(pc.dim("bsky.network"));
|
|
4366
|
+
const statusColors = {
|
|
4367
|
+
synced: pc.green,
|
|
4368
|
+
behind: pc.yellow,
|
|
4369
|
+
error: pc.red,
|
|
4370
|
+
checking: pc.dim,
|
|
4371
|
+
unknown: pc.dim
|
|
4372
|
+
};
|
|
4373
|
+
const dotColors = {
|
|
4374
|
+
synced: pc.green("●"),
|
|
4375
|
+
behind: pc.yellow("●"),
|
|
4376
|
+
error: pc.red("●"),
|
|
4377
|
+
checking: pc.dim("○"),
|
|
4378
|
+
unknown: pc.dim("○")
|
|
4379
|
+
};
|
|
4380
|
+
const colorFn = statusColors[state.syncStatus] ?? pc.dim;
|
|
4381
|
+
col2.push(`${dotColors[state.syncStatus] ?? pc.dim("○")} ${colorFn(state.syncStatus.toUpperCase())}`);
|
|
4382
|
+
if (state.relayRev) col2.push(pc.dim(`rev: ${state.relayRev.slice(0, 12)}`));
|
|
4383
|
+
const col3 = [pc.dim("FIREHOSE"), ""];
|
|
4384
|
+
const subDot = state.subscribers > 0 ? pc.green("●") : pc.dim("○");
|
|
4385
|
+
col3.push(`${subDot} ${pc.bold(String(state.subscribers))} subscriber${state.subscribers !== 1 ? "s" : ""}`);
|
|
4386
|
+
col3.push(pc.dim(`seq: ${state.latestSeq != null ? state.latestSeq : "—"}`));
|
|
4387
|
+
if (state.subscriberDetails.length > 0) {
|
|
4388
|
+
col3.push("");
|
|
4389
|
+
for (const sub of state.subscriberDetails.slice(0, 5)) {
|
|
4390
|
+
const ip = sub.ip ? ` ${shortenIP(sub.ip)}` : "";
|
|
4391
|
+
col3.push(pc.dim(`${relativeTime(sub.connectedAt)} cursor: ${sub.cursor}${ip}`));
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
const columnLines = renderColumns([
|
|
4395
|
+
col1,
|
|
4396
|
+
col2,
|
|
4397
|
+
col3
|
|
4398
|
+
], [
|
|
4399
|
+
colWidth,
|
|
4400
|
+
colWidth,
|
|
4401
|
+
colWidth
|
|
4402
|
+
]);
|
|
4403
|
+
for (const line of columnLines) lines.push(indent + line);
|
|
4404
|
+
lines.push("");
|
|
4405
|
+
const remaining = rows - lines.length - 3;
|
|
4406
|
+
const notifHeight = Math.max(3, Math.floor(remaining * .4));
|
|
4407
|
+
const eventsHeight = Math.max(3, remaining - notifHeight);
|
|
4408
|
+
const notifSeparator = "─".repeat(Math.max(0, cols - visibleLength(indent + "NOTIFICATIONS ") - 2));
|
|
4409
|
+
lines.push(`${indent}${pc.dim("NOTIFICATIONS " + notifSeparator)}`);
|
|
4410
|
+
if (state.notifications.length === 0) {
|
|
4411
|
+
lines.push(`${indent}${pc.dim("No notifications yet")}`);
|
|
4412
|
+
for (let i = 1; i < notifHeight - 1; i++) lines.push("");
|
|
4413
|
+
} else {
|
|
4414
|
+
const visibleNotifs = state.notifications.slice(0, notifHeight - 1);
|
|
4415
|
+
for (const n of visibleNotifs) {
|
|
4416
|
+
const readDim = n.isRead ? pc.dim : (s) => s;
|
|
4417
|
+
const line = `${indent}${pc.dim(n.time)} ${n.icon} ${readDim(n.author)} ${readDim(pc.dim(n.text))}`;
|
|
4418
|
+
lines.push(truncate(line, cols));
|
|
4419
|
+
}
|
|
4420
|
+
for (let i = visibleNotifs.length; i < notifHeight - 1; i++) lines.push("");
|
|
4421
|
+
}
|
|
4422
|
+
lines.push("");
|
|
4423
|
+
const wsStatusText = state.wsConnected ? "● connected" : "○ disconnected";
|
|
4424
|
+
indent + "";
|
|
4425
|
+
const eventsSuffix = " " + wsStatusText + " ";
|
|
4426
|
+
const eventsSeparator = "─".repeat(Math.max(0, cols - 9 - eventsSuffix.length));
|
|
4427
|
+
const wsStatus = state.wsConnected ? pc.green(wsStatusText) : pc.dim(wsStatusText);
|
|
4428
|
+
lines.push(`${indent}${pc.dim("EVENTS " + eventsSeparator)} ${wsStatus}`);
|
|
4429
|
+
if (state.events.length === 0) {
|
|
4430
|
+
lines.push(`${indent}${pc.dim("Waiting for events…")}`);
|
|
4431
|
+
for (let i = 1; i < eventsHeight - 1; i++) lines.push("");
|
|
4432
|
+
} else {
|
|
4433
|
+
const visibleEvents = state.events.slice(0, eventsHeight - 1);
|
|
4434
|
+
for (const ev of visibleEvents) {
|
|
4435
|
+
const actionColor = {
|
|
4436
|
+
create: pc.green,
|
|
4437
|
+
update: pc.yellow,
|
|
4438
|
+
delete: pc.red,
|
|
4439
|
+
identity: pc.cyan
|
|
4440
|
+
}[ev.action] ?? pc.dim;
|
|
4441
|
+
const line = `${indent}${pc.dim(ev.time)} ${pc.dim("#" + String(ev.seq).padStart(4))} ${actionColor(ev.action.toUpperCase().padEnd(7))} ${ev.path}`;
|
|
4442
|
+
lines.push(truncate(line, cols));
|
|
4443
|
+
}
|
|
4444
|
+
for (let i = visibleEvents.length; i < eventsHeight - 1; i++) lines.push("");
|
|
4445
|
+
}
|
|
4446
|
+
lines.push("");
|
|
4447
|
+
const accountStatus = state.accountActive ? pc.green("● active") : pc.yellow("○ deactivated");
|
|
4448
|
+
let footer = `${indent}${pc.dim("[a]")} activate ${pc.dim("·")} ${pc.dim("[r]")} crawl ${pc.dim("·")} ${pc.dim("[e]")} emit identity ${pc.dim("·")} ${pc.dim("[q]")} quit`;
|
|
4449
|
+
if (state.statusMessage) footer += ` ${pc.yellow(state.statusMessage)}`;
|
|
4450
|
+
else footer += ` ${accountStatus}`;
|
|
4451
|
+
lines.push(footer);
|
|
4452
|
+
while (lines.length < rows) lines.push("");
|
|
4453
|
+
const output = lines.slice(0, rows).map((l) => padRight(l, cols)).join("\n");
|
|
4454
|
+
process.stdout.write("\x1B[H" + output);
|
|
4455
|
+
}
|
|
4456
|
+
function setStatusMessage(state, message, render, durationMs = 3e3) {
|
|
4457
|
+
if (state.statusMessageTimeout) clearTimeout(state.statusMessageTimeout);
|
|
4458
|
+
state.statusMessage = message;
|
|
4459
|
+
render();
|
|
4460
|
+
state.statusMessageTimeout = setTimeout(() => {
|
|
4461
|
+
state.statusMessage = null;
|
|
4462
|
+
render();
|
|
4463
|
+
}, durationMs);
|
|
4464
|
+
}
|
|
4465
|
+
const dashboardCommand = defineCommand({
|
|
4466
|
+
meta: {
|
|
4467
|
+
name: "dashboard",
|
|
4468
|
+
description: "Live dashboard for PDS monitoring"
|
|
4469
|
+
},
|
|
4470
|
+
args: { dev: {
|
|
4471
|
+
type: "boolean",
|
|
4472
|
+
description: "Target local development server instead of production",
|
|
4473
|
+
default: false
|
|
4474
|
+
} },
|
|
4475
|
+
async run({ args }) {
|
|
4476
|
+
const isDev = args.dev;
|
|
4477
|
+
const wranglerVars = getVars();
|
|
4478
|
+
const config = {
|
|
4479
|
+
...readDevVars(),
|
|
4480
|
+
...wranglerVars
|
|
4481
|
+
};
|
|
4482
|
+
let targetUrl;
|
|
4483
|
+
try {
|
|
4484
|
+
targetUrl = getTargetUrl(isDev, config.PDS_HOSTNAME);
|
|
4485
|
+
} catch (err) {
|
|
4486
|
+
console.error(pc.red("Error:"), err instanceof Error ? err.message : "Configuration error");
|
|
4487
|
+
console.log(pc.dim("Run 'pds init' first to configure your PDS."));
|
|
4488
|
+
process.exit(1);
|
|
4489
|
+
}
|
|
4490
|
+
const authToken = config.AUTH_TOKEN;
|
|
4491
|
+
const handle = config.HANDLE ?? "";
|
|
4492
|
+
const did = config.DID ?? "";
|
|
4493
|
+
if (!authToken) {
|
|
4494
|
+
console.error(pc.red("Error:"), "No AUTH_TOKEN found. Run 'pds init' first.");
|
|
4495
|
+
process.exit(1);
|
|
4496
|
+
}
|
|
4497
|
+
const client = new PDSClient(targetUrl, authToken);
|
|
4498
|
+
if (!await client.healthCheck()) {
|
|
4499
|
+
console.error(pc.red("Error:"), `PDS not responding at ${targetUrl}`);
|
|
4500
|
+
process.exit(1);
|
|
4501
|
+
}
|
|
4502
|
+
const state = createInitialState();
|
|
4503
|
+
const dashConfig = {
|
|
4504
|
+
hostname: config.PDS_HOSTNAME ?? targetUrl,
|
|
4505
|
+
handle,
|
|
4506
|
+
did,
|
|
4507
|
+
version: "0.10.6"
|
|
4508
|
+
};
|
|
4509
|
+
const render = () => renderDashboard(state, dashConfig);
|
|
4510
|
+
enterAltScreen();
|
|
4511
|
+
hideCursor();
|
|
4512
|
+
clearScreen();
|
|
4513
|
+
const intervals = [];
|
|
4514
|
+
let firehose = null;
|
|
4515
|
+
function cleanup() {
|
|
4516
|
+
for (const interval of intervals) clearInterval(interval);
|
|
4517
|
+
if (firehose) firehose.close();
|
|
4518
|
+
if (state.statusMessageTimeout) clearTimeout(state.statusMessageTimeout);
|
|
4519
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
4520
|
+
showCursor();
|
|
4521
|
+
exitAltScreen();
|
|
4522
|
+
}
|
|
4523
|
+
process.on("SIGINT", () => {
|
|
4524
|
+
cleanup();
|
|
4525
|
+
process.exit(0);
|
|
4526
|
+
});
|
|
4527
|
+
process.on("SIGTERM", () => {
|
|
4528
|
+
cleanup();
|
|
4529
|
+
process.exit(0);
|
|
4530
|
+
});
|
|
4531
|
+
render();
|
|
4532
|
+
await Promise.all([
|
|
4533
|
+
fetchRepo(client, did, state, render),
|
|
4534
|
+
fetchFirehoseStatus(client, state, render),
|
|
4535
|
+
fetchAccountStatus(client, state, render),
|
|
4536
|
+
fetchNotifications(client, state, render)
|
|
4537
|
+
]);
|
|
4538
|
+
await fetchRelaySync(did, state, render);
|
|
4539
|
+
intervals.push(setInterval(() => fetchRepo(client, did, state, render), 3e4));
|
|
4540
|
+
intervals.push(setInterval(() => fetchRelaySync(did, state, render), 5e3));
|
|
4541
|
+
intervals.push(setInterval(() => fetchFirehoseStatus(client, state, render), 1e4));
|
|
4542
|
+
intervals.push(setInterval(() => fetchNotifications(client, state, render), 15e3));
|
|
4543
|
+
intervals.push(setInterval(() => fetchAccountStatus(client, state, render), 3e4));
|
|
4544
|
+
firehose = connectFirehose(targetUrl, state, render);
|
|
4545
|
+
process.stdout.on("resize", render);
|
|
4546
|
+
if (process.stdin.isTTY) {
|
|
4547
|
+
process.stdin.setRawMode(true);
|
|
4548
|
+
process.stdin.resume();
|
|
4549
|
+
process.stdin.setEncoding("utf8");
|
|
4550
|
+
let activateConfirmTimeout = null;
|
|
4551
|
+
let awaitingActivateConfirm = false;
|
|
4552
|
+
process.stdin.on("data", async (key) => {
|
|
4553
|
+
if (key === "") {
|
|
4554
|
+
cleanup();
|
|
4555
|
+
process.exit(0);
|
|
4556
|
+
}
|
|
4557
|
+
if (key === "q" || key === "Q") {
|
|
4558
|
+
cleanup();
|
|
4559
|
+
process.exit(0);
|
|
4560
|
+
}
|
|
4561
|
+
if (key === "a" || key === "A") {
|
|
4562
|
+
if (awaitingActivateConfirm) {
|
|
4563
|
+
awaitingActivateConfirm = false;
|
|
4564
|
+
if (activateConfirmTimeout) clearTimeout(activateConfirmTimeout);
|
|
4565
|
+
setStatusMessage(state, "Activating…", render, 1e4);
|
|
4566
|
+
try {
|
|
4567
|
+
await client.activateAccount();
|
|
4568
|
+
state.accountActive = true;
|
|
4569
|
+
const pdsHostname = config.PDS_HOSTNAME;
|
|
4570
|
+
if (pdsHostname && !isDev) await client.requestCrawl(pdsHostname);
|
|
4571
|
+
try {
|
|
4572
|
+
await client.emitIdentity();
|
|
4573
|
+
} catch {}
|
|
4574
|
+
setStatusMessage(state, pc.green("✓ Activated! Crawl requested."), render, 5e3);
|
|
4575
|
+
} catch (err) {
|
|
4576
|
+
setStatusMessage(state, pc.red(`\u2717 ${err instanceof Error ? err.message : "Activation failed"}`), render, 5e3);
|
|
4577
|
+
}
|
|
4578
|
+
} else {
|
|
4579
|
+
awaitingActivateConfirm = true;
|
|
4580
|
+
setStatusMessage(state, "Press [a] again to activate", render, 3e3);
|
|
4581
|
+
activateConfirmTimeout = setTimeout(() => {
|
|
4582
|
+
awaitingActivateConfirm = false;
|
|
4583
|
+
state.statusMessage = null;
|
|
4584
|
+
render();
|
|
4585
|
+
}, 3e3);
|
|
4586
|
+
}
|
|
4587
|
+
return;
|
|
4588
|
+
}
|
|
4589
|
+
if (key === "r" || key === "R") {
|
|
4590
|
+
const pdsHostname = config.PDS_HOSTNAME;
|
|
4591
|
+
if (!pdsHostname || isDev) {
|
|
4592
|
+
setStatusMessage(state, pc.yellow("No PDS hostname configured"), render);
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
setStatusMessage(state, "Requesting crawl…", render, 1e4);
|
|
4596
|
+
setStatusMessage(state, await client.requestCrawl(pdsHostname) ? pc.green("✓ Crawl requested") : pc.red("✗ Crawl request failed"), render);
|
|
4597
|
+
return;
|
|
4598
|
+
}
|
|
4599
|
+
if (key === "e" || key === "E") {
|
|
4600
|
+
setStatusMessage(state, "Emitting identity…", render, 1e4);
|
|
4601
|
+
try {
|
|
4602
|
+
const result = await client.emitIdentity();
|
|
4603
|
+
setStatusMessage(state, pc.green(`\u2713 Identity emitted (seq: ${result.seq})`), render);
|
|
4604
|
+
} catch (err) {
|
|
4605
|
+
setStatusMessage(state, pc.red(`\u2717 ${err instanceof Error ? err.message : "Failed"}`), render);
|
|
4606
|
+
}
|
|
4607
|
+
return;
|
|
4608
|
+
}
|
|
4609
|
+
});
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
});
|
|
4613
|
+
|
|
3899
4614
|
//#endregion
|
|
3900
4615
|
//#region src/cli/index.ts
|
|
3901
4616
|
/**
|
|
@@ -3917,7 +4632,8 @@ runMain(defineCommand({
|
|
|
3917
4632
|
activate: activateCommand,
|
|
3918
4633
|
deactivate: deactivateCommand,
|
|
3919
4634
|
status: statusCommand,
|
|
3920
|
-
"emit-identity": emitIdentityCommand
|
|
4635
|
+
"emit-identity": emitIdentityCommand,
|
|
4636
|
+
dashboard: dashboardCommand
|
|
3921
4637
|
}
|
|
3922
4638
|
}));
|
|
3923
4639
|
|