@getcirrus/pds 0.10.5 → 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 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/secrets.ts
280
+ //#region src/cli/utils/cli-helpers.ts
280
281
  /**
281
- * Secret generation and management utilities for PDS CLI
282
+ * Shared CLI utilities for PDS commands
282
283
  */
283
284
  /**
284
- * Generate a new secp256k1 signing keypair
285
+ * Prompt for text input, exiting on cancel
285
286
  */
286
- async function generateSigningKeypair() {
287
- const keypair = await Secp256k1Keypair.create({ exportable: true });
288
- return {
289
- privateKey: Buffer.from(await keypair.export()).toString("hex"),
290
- publicKey: keypair.did().replace("did:key:", "")
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
- * Derive public key from an existing private key
296
+ * Prompt for confirmation, exiting on cancel
295
297
  */
296
- async function derivePublicKey(privateKeyHex) {
297
- return (await Secp256k1Keypair.import(privateKeyHex)).did().replace("did:key:", "");
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
- * Generate a random auth token (base64url, 32 bytes)
307
+ * Prompt for selection, exiting on cancel
301
308
  */
302
- function generateAuthToken() {
303
- return randomBytes(32).toString("base64url");
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
- * Generate a random JWT secret (base64, 32 bytes)
318
+ * Get target PDS URL based on mode
307
319
  */
308
- function generateJwtSecret() {
309
- return randomBytes(32).toString("base64");
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
- * Hash a password using bcrypt
326
+ * Extract domain from URL
313
327
  */
314
- async function hashPassword(password) {
315
- return bcrypt.hash(password, 10);
328
+ function getDomain(url) {
329
+ try {
330
+ return new URL(url).hostname;
331
+ } catch {
332
+ return url;
333
+ }
316
334
  }
317
335
  /**
318
- * Prompt for password with confirmation (max 3 attempts)
336
+ * Detect which package manager is being used based on npm_config_user_agent
319
337
  */
320
- async function promptPassword(handle) {
321
- const message = handle ? `Choose a password for @${handle}:` : "Enter password:";
322
- const MAX_ATTEMPTS = 3;
323
- let attempts = 0;
324
- while (attempts < MAX_ATTEMPTS) {
325
- attempts++;
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
- * Set a secret value, either locally (.dev.vars) or via wrangler
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
- async function setSecretValue(name, value, local) {
347
- if (local) setDevVar(name, value);
348
- else await setSecret(name, value);
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
- * Set a public var in wrangler.jsonc
355
+ * Copy text to clipboard using platform-specific command
356
+ * Falls back gracefully if clipboard is unavailable
352
357
  */
353
- function setPublicVar(name, value, local) {
354
- if (local) setDevVar(name, value);
355
- else setVar(name, value);
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
- * JWT secret generation command
385
+ * Check if 1Password CLI (op) is available
386
+ * Only checks on POSIX systems (macOS, Linux)
362
387
  */
363
- const jwtCommand = defineCommand({
364
- meta: {
365
- name: "jwt",
366
- description: "Generate and set JWT signing secret"
367
- },
368
- args: { local: {
369
- type: "boolean",
370
- description: "Write to .dev.vars instead of wrangler secrets",
371
- default: false
372
- } },
373
- async run({ args }) {
374
- p.intro("Generate JWT Secret");
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
- * Password hash generation command
401
+ * Save a key to 1Password using the CLI
402
+ * Creates a secure note with the signing key
390
403
  */
391
- const passwordCommand = defineCommand({
392
- meta: {
393
- name: "password",
394
- description: "Set account password (stored as bcrypt hash)"
395
- },
396
- args: { local: {
397
- type: "boolean",
398
- description: "Write to .dev.vars instead of wrangler secrets",
399
- default: false
400
- } },
401
- async run({ args }) {
402
- p.intro("Set Account Password");
403
- const password = await promptPassword();
404
- const spinner = p.spinner();
405
- spinner.start("Hashing password...");
406
- const passwordHash = await hashPassword(password);
407
- spinner.stop("Password hashed");
408
- try {
409
- await setSecretValue("PASSWORD_HASH", passwordHash, args.local);
410
- p.outro(args.local ? "PASSWORD_HASH written to .dev.vars" : "Done!");
411
- } catch (error) {
412
- p.log.error(String(error));
413
- process.exit(1);
414
- }
415
- }
416
- });
417
-
418
- //#endregion
419
- //#region src/cli/commands/secret/key.ts
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
- * Signing key generation command
445
+ * Save a password to 1Password as a Login item for bsky.app
422
446
  */
423
- const keyCommand = defineCommand({
424
- meta: {
425
- name: "key",
426
- description: "Generate and set signing keypair"
427
- },
428
- args: { local: {
429
- type: "boolean",
430
- description: "Write to .dev.vars instead of wrangler secrets/config",
431
- default: false
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
- * Get a migration token for outbound migration.
982
- * This token can be used to migrate to another PDS.
1266
+ * List all registered passkeys
983
1267
  */
984
- async getMigrationToken() {
985
- const url = new URL("/xrpc/gg.mk.experimental.getMigrationToken", this.baseUrl);
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) return {
993
- success: false,
994
- error: (await res.json().catch(() => ({}))).message ?? `Request failed: ${res.status}`
995
- };
996
- return {
997
- success: true,
998
- token: (await res.json()).token
999
- };
1000
- }
1001
- static RELAY_URLS = ["https://relay1.us-west.bsky.network", "https://relay1.us-east.bsky.network"];
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
- * Get relay status from all known relays.
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 requestCrawl(pdsHostname, relayUrl = "https://bsky.network") {
1033
- try {
1034
- const url = new URL("/xrpc/com.atproto.sync.requestCrawl", relayUrl);
1035
- return (await fetch(url.toString(), {
1036
- method: "POST",
1037
- headers: { "Content-Type": "application/json" },
1038
- body: JSON.stringify({ hostname: pdsHostname })
1039
- })).ok;
1040
- } catch {
1041
- return false;
1042
- }
1043
- }
1044
- };
1045
-
1046
- //#endregion
1047
- //#region src/cli/utils/cli-helpers.ts
1048
- /**
1049
- * Shared CLI utilities for PDS commands
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
- child.on("close", (code) => {
1229
- if (code === 0) resolve$1();
1230
- else {
1231
- if (output && !options.stream) console.error(output);
1232
- reject(/* @__PURE__ */ new Error(`${cmd} ${args.join(" ")} failed with code ${code}`));
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
- child.on("error", reject);
1236
- });
1237
- }
1238
- /**
1239
- * Save a key backup file with appropriate warnings
1240
- */
1241
- async function saveKeyBackup(key, handle) {
1242
- const filename = `signing-key-backup-${handle.replace(/[^a-z0-9]/gi, "-")}.txt`;
1243
- const filepath = join(process.cwd(), filename);
1244
- await writeFile(filepath, [
1245
- "=".repeat(60),
1246
- "CIRRUS PDS SIGNING KEY BACKUP",
1247
- "=".repeat(60),
1248
- "",
1249
- `Handle: ${handle}`,
1250
- `Created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1251
- "",
1252
- "WARNING: This key controls your identity!",
1253
- "- Store this file in a secure location (password manager, encrypted drive)",
1254
- "- Delete this file from your local disk after backing up",
1255
- "- Never share this key with anyone",
1256
- "- If compromised, your identity can be stolen",
1257
- "",
1258
- "=".repeat(60),
1259
- "SIGNING KEY (hex-encoded secp256k1 private key)",
1260
- "=".repeat(60),
1261
- "",
1262
- key,
1263
- "",
1264
- "=".repeat(60)
1265
- ].join("\n"), { mode: 384 });
1266
- return filepath;
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
- console.log(` ${INFO} ${firehose.subscribers} firehose subscriber${firehose.subscribers !== 1 ? "s" : ""}, seq: ${firehose.latestSeq ?? "none"}`);
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