@bitsocial/bitsocial-cli 0.19.65 → 0.19.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -298,9 +298,15 @@ $ bitsocial community edit mysub.bso '--roles["author-address.bso"]' null
298
298
  ## Commands
299
299
 
300
300
  <!-- commands -->
301
+ * [`bitsocial challenge add PACKAGE`](#bitsocial-challenge-add-package)
302
+ * [`bitsocial challenge i PACKAGE`](#bitsocial-challenge-i-package)
301
303
  * [`bitsocial challenge install PACKAGE`](#bitsocial-challenge-install-package)
302
304
  * [`bitsocial challenge list`](#bitsocial-challenge-list)
305
+ * [`bitsocial challenge ls`](#bitsocial-challenge-ls)
303
306
  * [`bitsocial challenge remove NAME`](#bitsocial-challenge-remove-name)
307
+ * [`bitsocial challenge rm NAME`](#bitsocial-challenge-rm-name)
308
+ * [`bitsocial challenge un NAME`](#bitsocial-challenge-un-name)
309
+ * [`bitsocial challenge uninstall NAME`](#bitsocial-challenge-uninstall-name)
304
310
  * [`bitsocial community create`](#bitsocial-community-create)
305
311
  * [`bitsocial community delete ADDRESSES`](#bitsocial-community-delete-addresses)
306
312
  * [`bitsocial community edit ADDRESS`](#bitsocial-community-edit-address)
@@ -316,6 +322,72 @@ $ bitsocial community edit mysub.bso '--roles["author-address.bso"]' null
316
322
  * [`bitsocial update install [VERSION]`](#bitsocial-update-install-version)
317
323
  * [`bitsocial update versions`](#bitsocial-update-versions)
318
324
 
325
+ ## `bitsocial challenge add PACKAGE`
326
+
327
+ Install a challenge package (npm package name, git URL, tarball URL, or local path)
328
+
329
+ ```
330
+ USAGE
331
+ $ bitsocial challenge add PACKAGE [--pkcOptions.dataPath <value>]
332
+
333
+ ARGUMENTS
334
+ PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)
335
+
336
+ FLAGS
337
+ --pkcOptions.dataPath=<value> Data path to install the challenge into
338
+
339
+ DESCRIPTION
340
+ Install a challenge package (npm package name, git URL, tarball URL, or local path)
341
+
342
+ ALIASES
343
+ $ bitsocial challenge i
344
+ $ bitsocial challenge add
345
+
346
+ EXAMPLES
347
+ $ bitsocial challenge install @bitsocial/mintpass-challenge
348
+
349
+ $ bitsocial challenge install @bitsocial/mintpass-challenge@1.0.0
350
+
351
+ $ bitsocial challenge install github:user/repo
352
+
353
+ $ bitsocial challenge install https://example.com/my-challenge-1.0.0.tar.gz
354
+
355
+ $ bitsocial challenge install ./my-local-challenge
356
+ ```
357
+
358
+ ## `bitsocial challenge i PACKAGE`
359
+
360
+ Install a challenge package (npm package name, git URL, tarball URL, or local path)
361
+
362
+ ```
363
+ USAGE
364
+ $ bitsocial challenge i PACKAGE [--pkcOptions.dataPath <value>]
365
+
366
+ ARGUMENTS
367
+ PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)
368
+
369
+ FLAGS
370
+ --pkcOptions.dataPath=<value> Data path to install the challenge into
371
+
372
+ DESCRIPTION
373
+ Install a challenge package (npm package name, git URL, tarball URL, or local path)
374
+
375
+ ALIASES
376
+ $ bitsocial challenge i
377
+ $ bitsocial challenge add
378
+
379
+ EXAMPLES
380
+ $ bitsocial challenge install @bitsocial/mintpass-challenge
381
+
382
+ $ bitsocial challenge install @bitsocial/mintpass-challenge@1.0.0
383
+
384
+ $ bitsocial challenge install github:user/repo
385
+
386
+ $ bitsocial challenge install https://example.com/my-challenge-1.0.0.tar.gz
387
+
388
+ $ bitsocial challenge install ./my-local-challenge
389
+ ```
390
+
319
391
  ## `bitsocial challenge install PACKAGE`
320
392
 
321
393
  Install a challenge package (npm package name, git URL, tarball URL, or local path)
@@ -333,6 +405,10 @@ FLAGS
333
405
  DESCRIPTION
334
406
  Install a challenge package (npm package name, git URL, tarball URL, or local path)
335
407
 
408
+ ALIASES
409
+ $ bitsocial challenge i
410
+ $ bitsocial challenge add
411
+
336
412
  EXAMPLES
337
413
  $ bitsocial challenge install @bitsocial/mintpass-challenge
338
414
 
@@ -345,7 +421,7 @@ EXAMPLES
345
421
  $ bitsocial challenge install ./my-local-challenge
346
422
  ```
347
423
 
348
- _See code: [src/cli/commands/challenge/install.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/challenge/install.ts)_
424
+ _See code: [src/cli/commands/challenge/install.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/challenge/install.ts)_
349
425
 
350
426
  ## `bitsocial challenge list`
351
427
 
@@ -362,13 +438,40 @@ FLAGS
362
438
  DESCRIPTION
363
439
  List installed challenge packages
364
440
 
441
+ ALIASES
442
+ $ bitsocial challenge ls
443
+
365
444
  EXAMPLES
366
445
  $ bitsocial challenge list
367
446
 
368
447
  $ bitsocial challenge list -q
369
448
  ```
370
449
 
371
- _See code: [src/cli/commands/challenge/list.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/challenge/list.ts)_
450
+ _See code: [src/cli/commands/challenge/list.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/challenge/list.ts)_
451
+
452
+ ## `bitsocial challenge ls`
453
+
454
+ List installed challenge packages
455
+
456
+ ```
457
+ USAGE
458
+ $ bitsocial challenge ls [-q] [--pkcOptions.dataPath <value>]
459
+
460
+ FLAGS
461
+ -q, --quiet Only display challenge names
462
+ --pkcOptions.dataPath=<value> Data path where challenges are installed
463
+
464
+ DESCRIPTION
465
+ List installed challenge packages
466
+
467
+ ALIASES
468
+ $ bitsocial challenge ls
469
+
470
+ EXAMPLES
471
+ $ bitsocial challenge list
472
+
473
+ $ bitsocial challenge list -q
474
+ ```
372
475
 
373
476
  ## `bitsocial challenge remove NAME`
374
477
 
@@ -387,13 +490,102 @@ FLAGS
387
490
  DESCRIPTION
388
491
  Remove an installed challenge package
389
492
 
493
+ ALIASES
494
+ $ bitsocial challenge uninstall
495
+ $ bitsocial challenge rm
496
+ $ bitsocial challenge un
497
+
498
+ EXAMPLES
499
+ $ bitsocial challenge remove my-challenge
500
+
501
+ $ bitsocial challenge remove @scope/my-challenge
502
+ ```
503
+
504
+ _See code: [src/cli/commands/challenge/remove.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/challenge/remove.ts)_
505
+
506
+ ## `bitsocial challenge rm NAME`
507
+
508
+ Remove an installed challenge package
509
+
510
+ ```
511
+ USAGE
512
+ $ bitsocial challenge rm NAME [--pkcOptions.dataPath <value>]
513
+
514
+ ARGUMENTS
515
+ NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
516
+
517
+ FLAGS
518
+ --pkcOptions.dataPath=<value> Data path where challenges are installed
519
+
520
+ DESCRIPTION
521
+ Remove an installed challenge package
522
+
523
+ ALIASES
524
+ $ bitsocial challenge uninstall
525
+ $ bitsocial challenge rm
526
+ $ bitsocial challenge un
527
+
528
+ EXAMPLES
529
+ $ bitsocial challenge remove my-challenge
530
+
531
+ $ bitsocial challenge remove @scope/my-challenge
532
+ ```
533
+
534
+ ## `bitsocial challenge un NAME`
535
+
536
+ Remove an installed challenge package
537
+
538
+ ```
539
+ USAGE
540
+ $ bitsocial challenge un NAME [--pkcOptions.dataPath <value>]
541
+
542
+ ARGUMENTS
543
+ NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
544
+
545
+ FLAGS
546
+ --pkcOptions.dataPath=<value> Data path where challenges are installed
547
+
548
+ DESCRIPTION
549
+ Remove an installed challenge package
550
+
551
+ ALIASES
552
+ $ bitsocial challenge uninstall
553
+ $ bitsocial challenge rm
554
+ $ bitsocial challenge un
555
+
390
556
  EXAMPLES
391
557
  $ bitsocial challenge remove my-challenge
392
558
 
393
559
  $ bitsocial challenge remove @scope/my-challenge
394
560
  ```
395
561
 
396
- _See code: [src/cli/commands/challenge/remove.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/challenge/remove.ts)_
562
+ ## `bitsocial challenge uninstall NAME`
563
+
564
+ Remove an installed challenge package
565
+
566
+ ```
567
+ USAGE
568
+ $ bitsocial challenge uninstall NAME [--pkcOptions.dataPath <value>]
569
+
570
+ ARGUMENTS
571
+ NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
572
+
573
+ FLAGS
574
+ --pkcOptions.dataPath=<value> Data path where challenges are installed
575
+
576
+ DESCRIPTION
577
+ Remove an installed challenge package
578
+
579
+ ALIASES
580
+ $ bitsocial challenge uninstall
581
+ $ bitsocial challenge rm
582
+ $ bitsocial challenge un
583
+
584
+ EXAMPLES
585
+ $ bitsocial challenge remove my-challenge
586
+
587
+ $ bitsocial challenge remove @scope/my-challenge
588
+ ```
397
589
 
398
590
  ## `bitsocial community create`
399
591
 
@@ -423,7 +615,7 @@ EXAMPLES
423
615
  $ bitsocial community create --jsonFile ./create-options.json
424
616
  ```
425
617
 
426
- _See code: [src/cli/commands/community/create.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/create.ts)_
618
+ _See code: [src/cli/commands/community/create.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/create.ts)_
427
619
 
428
620
  ## `bitsocial community delete ADDRESSES`
429
621
 
@@ -448,7 +640,7 @@ EXAMPLES
448
640
  $ bitsocial community delete 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
449
641
  ```
450
642
 
451
- _See code: [src/cli/commands/community/delete.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/delete.ts)_
643
+ _See code: [src/cli/commands/community/delete.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/delete.ts)_
452
644
 
453
645
  ## `bitsocial community edit ADDRESS`
454
646
 
@@ -518,7 +710,7 @@ EXAMPLES
518
710
  $ bitsocial community edit bitsocial.bso --jsonFile ./edit-options.json
519
711
  ```
520
712
 
521
- _See code: [src/cli/commands/community/edit.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/edit.ts)_
713
+ _See code: [src/cli/commands/community/edit.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/edit.ts)_
522
714
 
523
715
  ## `bitsocial community export [ADDRESS]`
524
716
 
@@ -559,7 +751,7 @@ EXAMPLES
559
751
  $ bitsocial community export --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
560
752
  ```
561
753
 
562
- _See code: [src/cli/commands/community/export.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/export.ts)_
754
+ _See code: [src/cli/commands/community/export.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/export.ts)_
563
755
 
564
756
  ## `bitsocial community get [ADDRESS]`
565
757
 
@@ -590,7 +782,7 @@ EXAMPLES
590
782
  $ bitsocial community get --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
591
783
  ```
592
784
 
593
- _See code: [src/cli/commands/community/get.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/get.ts)_
785
+ _See code: [src/cli/commands/community/get.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/get.ts)_
594
786
 
595
787
  ## `bitsocial community list`
596
788
 
@@ -613,7 +805,7 @@ EXAMPLES
613
805
  $ bitsocial community list
614
806
  ```
615
807
 
616
- _See code: [src/cli/commands/community/list.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/list.ts)_
808
+ _See code: [src/cli/commands/community/list.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/list.ts)_
617
809
 
618
810
  ## `bitsocial community start ADDRESSES`
619
811
 
@@ -647,7 +839,7 @@ EXAMPLES
647
839
  $ bitsocial community start $(bitsocial community list -q) --concurrency 1
648
840
  ```
649
841
 
650
- _See code: [src/cli/commands/community/start.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/start.ts)_
842
+ _See code: [src/cli/commands/community/start.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/start.ts)_
651
843
 
652
844
  ## `bitsocial community stop ADDRESSES`
653
845
 
@@ -672,7 +864,7 @@ EXAMPLES
672
864
  $ bitsocial community stop Qmb99crTbSUfKXamXwZBe829Vf6w5w5TktPkb6WstC9RFW
673
865
  ```
674
866
 
675
- _See code: [src/cli/commands/community/stop.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/community/stop.ts)_
867
+ _See code: [src/cli/commands/community/stop.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/community/stop.ts)_
676
868
 
677
869
  ## `bitsocial daemon`
678
870
 
@@ -719,7 +911,7 @@ EXAMPLES
719
911
  $ bitsocial daemon --no-allowPrivateKeyExport
720
912
  ```
721
913
 
722
- _See code: [src/cli/commands/daemon.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/daemon.ts)_
914
+ _See code: [src/cli/commands/daemon.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/daemon.ts)_
723
915
 
724
916
  ## `bitsocial help [COMMAND]`
725
917
 
@@ -785,7 +977,7 @@ EXAMPLES
785
977
  $ bitsocial logs --stdout -f
786
978
  ```
787
979
 
788
- _See code: [src/cli/commands/logs.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/logs.ts)_
980
+ _See code: [src/cli/commands/logs.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/logs.ts)_
789
981
 
790
982
  ## `bitsocial update check`
791
983
 
@@ -802,7 +994,7 @@ EXAMPLES
802
994
  $ bitsocial update check
803
995
  ```
804
996
 
805
- _See code: [src/cli/commands/update/check.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/update/check.ts)_
997
+ _See code: [src/cli/commands/update/check.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/update/check.ts)_
806
998
 
807
999
  ## `bitsocial update install [VERSION]`
808
1000
 
@@ -834,7 +1026,7 @@ EXAMPLES
834
1026
  $ bitsocial update install --no-restart-daemons
835
1027
  ```
836
1028
 
837
- _See code: [src/cli/commands/update/install.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/update/install.ts)_
1029
+ _See code: [src/cli/commands/update/install.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/update/install.ts)_
838
1030
 
839
1031
  ## `bitsocial update versions`
840
1032
 
@@ -856,7 +1048,7 @@ EXAMPLES
856
1048
  $ bitsocial update versions --limit 5
857
1049
  ```
858
1050
 
859
- _See code: [src/cli/commands/update/versions.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.65/src/cli/commands/update/versions.ts)_
1051
+ _See code: [src/cli/commands/update/versions.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.66/src/cli/commands/update/versions.ts)_
860
1052
  <!-- commandsstop -->
861
1053
 
862
1054
  ## Contribution
@@ -21,4 +21,5 @@ export declare function ensureNpmAvailable(): Promise<void>;
21
21
  export declare function runNpmPack(packageSpec: string, destDir: string): Promise<string>;
22
22
  export declare function runNpmInstall(challengeDir: string): Promise<void>;
23
23
  export declare function verifyNativeModuleAbi(challengeDir: string): Promise<void>;
24
- export declare function loadChallengesIntoPKC(dataPath?: string): Promise<string[]>;
24
+ export declare function formatChallengeNameVersion(challenge: Pick<InstalledChallenge, "name" | "version">): string;
25
+ export declare function loadChallengesIntoPKC(dataPath?: string): Promise<InstalledChallenge[]>;
@@ -285,12 +285,15 @@ export async function verifyNativeModuleAbi(challengeDir) {
285
285
  `Ensure the challenge package was built for this Node.js version.`);
286
286
  }
287
287
  }
288
+ export function formatChallengeNameVersion(challenge) {
289
+ return challenge.version && challenge.version !== "unknown" ? `${challenge.name}@${challenge.version}` : challenge.name;
290
+ }
288
291
  export async function loadChallengesIntoPKC(dataPath) {
289
292
  const challenges = await listInstalledChallenges(dataPath);
290
293
  if (challenges.length === 0)
291
294
  return [];
292
295
  const PKC = await import("@pkcprotocol/pkc-js");
293
- const loadedNames = [];
296
+ const loaded = [];
294
297
  for (const challenge of challenges) {
295
298
  try {
296
299
  const pkg = await readChallengePackageJson(challenge.path);
@@ -300,11 +303,11 @@ export async function loadChallengesIntoPKC(dataPath) {
300
303
  const imported = await import(pathToFileURL(entryPath).href);
301
304
  const factory = imported.default || imported;
302
305
  PKC.default.challenges[challenge.name] = factory;
303
- loadedNames.push(challenge.name);
306
+ loaded.push(challenge);
304
307
  }
305
308
  catch (err) {
306
309
  console.error(`Failed to load challenge "${challenge.name}":`, err);
307
310
  }
308
311
  }
309
- return loadedNames;
312
+ return loaded;
310
313
  }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "@oclif/core";
2
2
  export default class Install extends Command {
3
3
  static description: string;
4
+ static aliases: string[];
4
5
  static args: {
5
6
  package: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
7
  };
@@ -6,6 +6,7 @@ import defaults from "../../../common-utils/defaults.js";
6
6
  import { ensureNpmAvailable, ensureChallengesDir, challengeNameToDir, readChallengePackageJson, runNpmPack, runNpmInstall, verifyNativeModuleAbi } from "../../../challenge-packages/challenge-utils.js";
7
7
  export default class Install extends Command {
8
8
  static description = "Install a challenge package (npm package name, git URL, tarball URL, or local path)";
9
+ static aliases = ["challenge:i", "challenge:add"];
9
10
  static args = {
10
11
  package: Args.string({
11
12
  description: "Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)",
@@ -26,9 +27,9 @@ export default class Install extends Command {
26
27
  "bitsocial challenge install ./my-local-challenge"
27
28
  ];
28
29
  async run() {
30
+ const startTime = Date.now();
29
31
  const { args, flags } = await this.parse(Install);
30
32
  const dataPath = flags["pkcOptions.dataPath"] || defaults.PKC_DATA_PATH;
31
- this.log("Installing challenge package — this may take a few minutes...");
32
33
  // 1. Check npm is available
33
34
  await ensureNpmAvailable();
34
35
  // 2. Use npm pack to download the package as a tarball
@@ -68,7 +69,16 @@ export default class Install extends Command {
68
69
  }
69
70
  // 5. Read package info
70
71
  const pkg = await readChallengePackageJson(pkgDir);
71
- // 6. Check not already installed
72
+ // 6. Run npm install in the temp dir, so a failed install never
73
+ // touches an existing working installation of the same package
74
+ process.stderr.write(`[challenge-install] starting npm install in ${pkgDir}\n`);
75
+ await runNpmInstall(pkgDir);
76
+ process.stderr.write("[challenge-install] npm install completed\n");
77
+ // 7. Verify native modules are ABI-compatible (still in the temp dir,
78
+ // so failure needs no rollback — the temp dir is cleaned up below)
79
+ await verifyNativeModuleAbi(pkgDir);
80
+ // 8. Swap the verified package into the challenges dir, replacing any
81
+ // existing installation (idempotent, like npm install)
72
82
  const challengesDir = await ensureChallengesDir(dataPath);
73
83
  const destDir = challengeNameToDir(challengesDir, pkg.name);
74
84
  let alreadyExists = true;
@@ -78,44 +88,30 @@ export default class Install extends Command {
78
88
  catch {
79
89
  alreadyExists = false;
80
90
  }
81
- if (alreadyExists) {
82
- this.error(`Challenge "${pkg.name}" is already installed. Remove it first with: bitsocial challenge remove ${pkg.name}`);
83
- }
84
- // 7. Move to challenges dir
91
+ // Move any existing install aside (same filesystem — tmpDir lives under
92
+ // dataPath) so it can be restored if the final rename fails
93
+ const backupDir = path.join(tmpDir, "previous-install");
94
+ if (alreadyExists)
95
+ await fs.rename(destDir, backupDir);
85
96
  if (pkg.name.startsWith("@")) {
86
97
  // Ensure scope dir exists for scoped packages
87
98
  const scopeDir = path.dirname(destDir);
88
99
  await fs.mkdir(scopeDir, { recursive: true });
89
100
  }
90
- await fs.rename(pkgDir, destDir);
91
- // 8. Run npm install
92
- process.stderr.write(`[challenge-install] starting npm install in ${destDir}\n`);
93
- await runNpmInstall(destDir);
94
- process.stderr.write("[challenge-install] npm install completed\n");
95
- // 9. Verify native modules are ABI-compatible
96
101
  try {
97
- await verifyNativeModuleAbi(destDir);
102
+ await fs.rename(pkgDir, destDir);
98
103
  }
99
104
  catch (err) {
100
- // Roll back the installation on ABI mismatch
101
- await fs.rm(destDir, { recursive: true, force: true });
102
- if (pkg.name.startsWith("@")) {
103
- const scopeDir = path.dirname(destDir);
104
- try {
105
- const entries = await fs.readdir(scopeDir);
106
- if (entries.length === 0)
107
- await fs.rmdir(scopeDir);
108
- }
109
- catch {
110
- // ignore
111
- }
112
- }
113
- this.error(err instanceof Error ? err.message : String(err));
105
+ // Restore the previous installation before surfacing the error
106
+ if (alreadyExists)
107
+ await fs.rename(backupDir, destDir);
108
+ throw err;
114
109
  }
115
- // 10. Print success
110
+ // 9. Print success (npm-style)
116
111
  const version = pkg.version ? `@${pkg.version}` : "";
117
- this.log(`Installed challenge '${pkg.name}${version}'`);
118
- // 11. Best-effort reload via daemon
112
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - startTime) / 1000));
113
+ this.log(`${alreadyExists ? "changed" : "added"} ${pkg.name}${version} in ${elapsedSeconds}s`);
114
+ // 10. Best-effort reload via daemon
119
115
  try {
120
116
  await fetch("http://localhost:9138/api/challenges/reload", { method: "POST" });
121
117
  }
@@ -124,7 +120,7 @@ export default class Install extends Command {
124
120
  }
125
121
  }
126
122
  finally {
127
- // 12. Clean up temp dir
123
+ // 11. Clean up temp dir (includes the previous-install backup, if any)
128
124
  await fs.rm(tmpDir, { recursive: true, force: true });
129
125
  }
130
126
  }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "@oclif/core";
2
2
  export default class List extends Command {
3
3
  static description: string;
4
+ static aliases: string[];
4
5
  static flags: {
5
6
  quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  "pkcOptions.dataPath": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,10 +1,11 @@
1
1
  import { Flags, Command } from "@oclif/core";
2
2
  import { EOL } from "os";
3
- import { printTable } from "@oclif/table";
3
+ import path from "path";
4
4
  import defaults from "../../../common-utils/defaults.js";
5
- import { listInstalledChallenges } from "../../../challenge-packages/challenge-utils.js";
5
+ import { getChallengesDir, listInstalledChallenges, formatChallengeNameVersion } from "../../../challenge-packages/challenge-utils.js";
6
6
  export default class List extends Command {
7
7
  static description = "List installed challenge packages";
8
+ static aliases = ["challenge:ls"];
8
9
  static flags = {
9
10
  quiet: Flags.boolean({ char: "q", summary: "Only display challenge names" }),
10
11
  "pkcOptions.dataPath": Flags.directory({
@@ -16,7 +17,8 @@ export default class List extends Command {
16
17
  async run() {
17
18
  const { flags } = await this.parse(List);
18
19
  const dataPath = flags["pkcOptions.dataPath"] || defaults.PKC_DATA_PATH;
19
- const challenges = await listInstalledChallenges(dataPath);
20
+ // Sort alphabetically like npm ls (readdir order is filesystem-dependent)
21
+ const challenges = (await listInstalledChallenges(dataPath)).sort((a, b) => a.name.localeCompare(b.name));
20
22
  if (challenges.length === 0) {
21
23
  this.log("No challenge packages installed.");
22
24
  return;
@@ -25,12 +27,11 @@ export default class List extends Command {
25
27
  this.log(challenges.map((c) => c.name).join(EOL));
26
28
  }
27
29
  else {
28
- printTable({
29
- data: challenges.map((c) => ({
30
- name: c.name,
31
- version: c.version,
32
- description: c.description
33
- }))
30
+ // npm-ls-style tree: challenges dir header, then name@version entries
31
+ this.log(path.resolve(getChallengesDir(dataPath)));
32
+ challenges.forEach((c, i) => {
33
+ const branch = i === challenges.length - 1 ? "└── " : "├── ";
34
+ this.log(`${branch}${formatChallengeNameVersion(c)}`);
34
35
  });
35
36
  }
36
37
  }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "@oclif/core";
2
2
  export default class Remove extends Command {
3
3
  static description: string;
4
+ static aliases: string[];
4
5
  static args: {
5
6
  name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
7
  };
@@ -2,9 +2,10 @@ import { Args, Flags, Command } from "@oclif/core";
2
2
  import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import defaults from "../../../common-utils/defaults.js";
5
- import { getChallengesDir, challengeNameToDir } from "../../../challenge-packages/challenge-utils.js";
5
+ import { getChallengesDir, challengeNameToDir, readChallengePackageJson } from "../../../challenge-packages/challenge-utils.js";
6
6
  export default class Remove extends Command {
7
7
  static description = "Remove an installed challenge package";
8
+ static aliases = ["challenge:uninstall", "challenge:rm", "challenge:un"];
8
9
  static args = {
9
10
  name: Args.string({
10
11
  description: "The challenge package name (e.g., my-challenge or @scope/my-challenge)",
@@ -33,6 +34,16 @@ export default class Remove extends Command {
33
34
  catch {
34
35
  this.error(`Challenge "${args.name}" is not installed.`);
35
36
  }
37
+ // Read the installed version for the success message (best-effort)
38
+ let version = "";
39
+ try {
40
+ const pkg = await readChallengePackageJson(challengeDir);
41
+ if (pkg.version)
42
+ version = `@${pkg.version}`;
43
+ }
44
+ catch {
45
+ // unreadable package.json — report the name only
46
+ }
36
47
  // Remove the challenge directory
37
48
  await fs.rm(challengeDir, { recursive: true, force: true });
38
49
  // Clean up empty @scope/ dir for scoped packages
@@ -48,7 +59,7 @@ export default class Remove extends Command {
48
59
  // ignore
49
60
  }
50
61
  }
51
- this.log(`Removed challenge '${args.name}'`);
62
+ this.log(`removed ${args.name}${version}`);
52
63
  // Best-effort reload via daemon
53
64
  try {
54
65
  await fetch("http://localhost:9138/api/challenges/reload", { method: "POST" });
@@ -6,7 +6,7 @@ import tcpPortUsed from "tcp-port-used";
6
6
  import { getLanIpV4Address, PKCLogger, setupDebugLogger, loadKuboConfigFile, parseMultiAddrKuboRpcToUrl, parseMultiAddrIpfsGatewayToUrl } from "../../util.js";
7
7
  import { startDaemonServer } from "../../webui/daemon-server.js";
8
8
  import { printBanner } from "../ascii-banner.js";
9
- import { loadChallengesIntoPKC } from "../../challenge-packages/challenge-utils.js";
9
+ import { loadChallengesIntoPKC, formatChallengeNameVersion } from "../../challenge-packages/challenge-utils.js";
10
10
  import { migrateDataDirectory } from "../../common-utils/data-migration.js";
11
11
  import { createBsoResolvers, DEFAULT_PROVIDERS } from "../../common-utils/resolvers.js";
12
12
  import { pruneStaleStates, writeDaemonState, deleteDaemonState } from "../../common-utils/daemon-state.js";
@@ -275,7 +275,10 @@ export default class Daemon extends Command {
275
275
  let pendingKuboStart;
276
276
  // Kubo Node may fail randomly, we need to set a listener so when it exits because of an error we restart it
277
277
  let kuboProcess;
278
- const keepKuboUp = async () => {
278
+ // Every kubo we've spawned that hasn't exited yet. Exit cleanup kills all of these,
279
+ // so a kubo that slipped out of kuboProcess tracking still dies with the daemon (issue #70)
280
+ const liveKuboPids = new Set();
281
+ const keepKuboUpOnce = async () => {
279
282
  if (mainProcessExited)
280
283
  return;
281
284
  const kuboApiPort = Number(kuboRpcEndpoint.port);
@@ -283,6 +286,17 @@ export default class Daemon extends Command {
283
286
  return; // already started, no need to intervene
284
287
  const connectHostname = toConnectableHostname(kuboRpcEndpoint.hostname);
285
288
  const isKuboApiPortTaken = await tcpPortUsed.check(kuboApiPort, connectHostname);
289
+ // Test hook: widens the window between the re-entrancy guard above and the pendingKuboStart
290
+ // assignment below, so tests can deterministically reproduce concurrent keepKuboUp entries
291
+ // (issue #70, see test/cli/daemon-kubo-restart-race.test.ts)
292
+ const portCheckDelayRaw = process.env["PKC_CLI_TEST_KEEPKUBOUP_PORTCHECK_DELAY_MS"];
293
+ const portCheckDelay = portCheckDelayRaw ? Number(portCheckDelayRaw) : 0;
294
+ if (Number.isFinite(portCheckDelay) && portCheckDelay > 0)
295
+ await new Promise((resolve) => setTimeout(resolve, portCheckDelay));
296
+ // Re-check after the awaits above: the daemon may have begun shutting down, or another
297
+ // kubo may have been adopted in the meantime — spawning now would race it (issue #70)
298
+ if (mainProcessExited || kuboProcess || pendingKuboStart)
299
+ return;
286
300
  if (isKuboApiPortTaken) {
287
301
  const connectableEndpoint = new URL(kuboRpcEndpoint.toString());
288
302
  connectableEndpoint.hostname = connectHostname;
@@ -306,8 +320,15 @@ export default class Daemon extends Command {
306
320
  }
307
321
  throw new Error(`Cannot start IPFS daemon because the IPFS API port ${kuboRpcEndpoint.hostname}:${kuboApiPort} (configured as ${kuboRpcEndpoint.toString()}) is already in use.`);
308
322
  }
323
+ let spawnedProcess;
309
324
  const startPromise = startKuboNode(kuboRpcEndpoint, ipfsGatewayEndpoint, mergedPkcOptions.dataPath, (process) => {
325
+ spawnedProcess = process;
310
326
  kuboProcess = process;
327
+ if (process.pid) {
328
+ const pid = process.pid;
329
+ liveKuboPids.add(pid);
330
+ process.once("exit", () => liveKuboPids.delete(pid));
331
+ }
311
332
  });
312
333
  pendingKuboStart = startPromise;
313
334
  let startedProcess;
@@ -315,12 +336,15 @@ export default class Daemon extends Command {
315
336
  startedProcess = await startPromise;
316
337
  }
317
338
  catch (error) {
318
- pendingKuboStart = undefined;
319
- if (!mainProcessExited)
339
+ // Only clear state this attempt owns — it may track another attempt's healthy kubo (issue #70)
340
+ if (pendingKuboStart === startPromise)
341
+ pendingKuboStart = undefined;
342
+ if (!mainProcessExited && spawnedProcess && kuboProcess === spawnedProcess)
320
343
  kuboProcess = undefined;
321
344
  throw error;
322
345
  }
323
- pendingKuboStart = undefined;
346
+ if (pendingKuboStart === startPromise)
347
+ pendingKuboStart = undefined;
324
348
  if (mainProcessExited) {
325
349
  if (startedProcess?.pid && !startedProcess.killed) {
326
350
  // Race condition: Kubo finished starting after mainProcessExited.
@@ -341,7 +365,8 @@ export default class Daemon extends Command {
341
365
  /* best effort */
342
366
  }
343
367
  }
344
- kuboProcess = undefined;
368
+ if (kuboProcess === startedProcess)
369
+ kuboProcess = undefined;
345
370
  return;
346
371
  }
347
372
  kuboProcess = startedProcess;
@@ -353,7 +378,8 @@ export default class Daemon extends Command {
353
378
  // Restart Kubo process because it failed
354
379
  if (!mainProcessExited) {
355
380
  log(`Kubo node with pid (${currentProcess?.pid}) exited. Will attempt to restart it`);
356
- kuboProcess = undefined;
381
+ if (kuboProcess === currentProcess)
382
+ kuboProcess = undefined;
357
383
  try {
358
384
  await keepKuboUp();
359
385
  }
@@ -367,6 +393,19 @@ export default class Daemon extends Command {
367
393
  };
368
394
  currentProcess.once("exit", onKuboExit);
369
395
  };
396
+ // Single-flight wrapper: keepKuboUp is invoked from independent places (the kubo exit
397
+ // handler and the watchdog interval). Concurrent callers must share one attempt —
398
+ // otherwise both can pass keepKuboUpOnce's re-entrancy guard during its awaits and
399
+ // spawn two kubo processes whose failure handling corrupts shared state (issue #70)
400
+ let keepKuboUpInFlight;
401
+ const keepKuboUp = () => {
402
+ if (!keepKuboUpInFlight) {
403
+ keepKuboUpInFlight = keepKuboUpOnce().finally(() => {
404
+ keepKuboUpInFlight = undefined;
405
+ });
406
+ }
407
+ return keepKuboUpInFlight;
408
+ };
370
409
  let startedOwnRpc = false;
371
410
  let daemonServer;
372
411
  const createOrConnectRpc = async () => {
@@ -384,7 +423,7 @@ export default class Daemon extends Command {
384
423
  // Load installed challenge packages before starting the RPC server
385
424
  const loadedChallenges = await loadChallengesIntoPKC(mergedPkcOptions.dataPath);
386
425
  if (loadedChallenges.length > 0)
387
- console.log(`Loaded challenge packages: ${loadedChallenges.join(", ")}`);
426
+ console.log(`Loaded challenge packages: ${loadedChallenges.map(formatChallengeNameVersion).join(", ")}`);
388
427
  daemonServer = await startDaemonServer(pkcRpcUrl, ipfsGatewayEndpoint, mergedPkcOptions, {
389
428
  allowPrivateKeyExport: flags.allowPrivateKeyExport
390
429
  });
@@ -429,14 +468,19 @@ export default class Daemon extends Command {
429
468
  }
430
469
  };
431
470
  const killKuboProcess = async () => {
432
- if (pendingKuboStart) {
433
- try {
434
- await pendingKuboStart;
435
- }
436
- catch {
437
- /* ignore */
438
- }
439
- }
471
+ // Wait (bounded) for any in-flight start attempt so we kill the kubo it may still
472
+ // spawn. Both promises settle on all failure paths (issue #70), but a spawned kubo
473
+ // that wedges before "Daemon is ready" without exiting keeps them pending — the
474
+ // bound ensures shutdown still reaches the SIGINT/SIGKILL flow below, which kills
475
+ // it via kuboProcess (set in onSpawn) or the liveKuboPids sweep (PR #71 review).
476
+ const inFlightStarts = [keepKuboUpInFlight, pendingKuboStart]
477
+ .filter((promise) => promise !== undefined)
478
+ .map((promise) => promise.catch(() => { }));
479
+ if (inFlightStarts.length > 0)
480
+ await Promise.race([
481
+ Promise.all(inFlightStarts),
482
+ new Promise((resolve) => setTimeout(resolve, 15_000).unref())
483
+ ]);
440
484
  if (kuboProcess?.pid && !kuboProcess.killed) {
441
485
  const pid = kuboProcess.pid;
442
486
  log("Attempting to kill kubo process with pid", pid);
@@ -466,6 +510,11 @@ export default class Daemon extends Command {
466
510
  kuboProcess = undefined;
467
511
  }
468
512
  }
513
+ // Defense in depth: SIGKILL any spawned kubo that slipped out of kuboProcess
514
+ // tracking (e.g. via a state race) so nothing outlives the daemon (issue #70)
515
+ for (const pid of liveKuboPids)
516
+ killKuboProcessGroup(pid, "SIGKILL");
517
+ liveKuboPids.clear();
469
518
  };
470
519
  asyncExitHook(async () => {
471
520
  if (keepKuboUpInterval)
@@ -492,13 +541,58 @@ export default class Daemon extends Command {
492
541
  }, { wait: 120000 } // could take two minutes to shut down
493
542
  );
494
543
  // Emergency cleanup: if the process force-exits (e.g. double Ctrl+C),
495
- // synchronously SIGKILL kubo's process group. This is a no-op if
496
- // killKuboProcess() already ran (it sets kuboProcess = undefined).
544
+ // synchronously SIGKILL every live kubo's process group. This is a no-op if
545
+ // killKuboProcess() already ran (it clears kuboProcess and liveKuboPids).
497
546
  process.on("exit", () => {
498
547
  if (kuboProcess?.pid) {
499
548
  killKuboProcessGroup(kuboProcess.pid, "SIGKILL");
500
549
  }
550
+ for (const pid of liveKuboPids)
551
+ killKuboProcessGroup(pid, "SIGKILL");
501
552
  });
553
+ // Persistent signal guard (issue #70): exit-hook registers its SIGINT/SIGTERM handlers
554
+ // with process.once, so its listener vanishes from the listener list the moment a
555
+ // signal is dispatched. signal-exit (loaded by @pkcprotocol/proper-lock-file and other
556
+ // dependencies) re-raises the signal when every remaining listener is its own — which
557
+ // would kill the process while the async exit hook above is still shutting kubo down.
558
+ // A persistent non-signal-exit listener keeps that heuristic from ever firing.
559
+ // A repeated signal force-quits immediately (impatient Ctrl+C): process.exit triggers
560
+ // the emergency "exit" handler above, which SIGKILLs every live kubo.
561
+ let terminationSignalCount = 0;
562
+ for (const signal of ["SIGINT", "SIGTERM"]) {
563
+ process.on(signal, () => {
564
+ terminationSignalCount++;
565
+ if (terminationSignalCount >= 2) {
566
+ log(`Received ${signal} again during shutdown, force-quitting`);
567
+ process.exit(signal === "SIGINT" ? 130 : 143);
568
+ }
569
+ });
570
+ }
571
+ // Test hook (issue #70): simulates a dependency registering a signal-exit handler
572
+ // AFTER the asyncExitHook above — what @pkcprotocol/proper-lock-file (and the
573
+ // signal-exit copies under ink/restore-cursor) do at module load. signal-exit
574
+ // re-raises the signal when every remaining listener belongs to the signal-exit
575
+ // family (`if (listeners.length === count) { ... process.kill(process.pid, s) }`),
576
+ // which kills the daemon while the async exit hook is still cleaning up kubo —
577
+ // exit-hook registers with process.once, so its listener is already gone by then.
578
+ if (process.env["PKC_CLI_TEST_SIMULATE_LATE_SIGNAL_EXIT"]) {
579
+ for (const signal of ["SIGINT", "SIGTERM"]) {
580
+ // Real signal-exit copies only count each other as "family" (via a shared
581
+ // global marker); any other listener makes them defer. Identify family by
582
+ // source: signal-exit's dispatcher carries the "an exit is coming" comment.
583
+ const isSignalExitFamily = (listener) => String(listener).includes("an exit is coming");
584
+ const reRaiser = () => {
585
+ const onlyFamilyLeft = process
586
+ .listeners(signal)
587
+ .every((listener) => listener === reRaiser || isSignalExitFamily(listener));
588
+ if (onlyFamilyLeft) {
589
+ process.removeListener(signal, reRaiser);
590
+ process.kill(process.pid, signal);
591
+ }
592
+ };
593
+ process.on(signal, reRaiser);
594
+ }
595
+ }
502
596
  // RPC port was already verified free above (fail-fast); only the kuboRpcClientsOptions branch skips local kubo.
503
597
  if (!pkcOptionsFromFlag?.kuboRpcClientsOptions)
504
598
  await keepKuboUp();
@@ -3,6 +3,8 @@ export interface DaemonState {
3
3
  startedAt: string;
4
4
  argv: string[];
5
5
  pkcRpcUrl: string;
6
+ /** OS-reported process start time, used to detect PID reuse. Absent in legacy state files. */
7
+ procStartTime?: string;
6
8
  }
7
9
  /** Write a daemon state file atomically (write to .tmp then rename). */
8
10
  export declare function writeDaemonState(state: DaemonState): Promise<void>;
@@ -10,7 +12,7 @@ export declare function writeDaemonState(state: DaemonState): Promise<void>;
10
12
  export declare function readAllDaemonStates(): Promise<DaemonState[]>;
11
13
  /** Delete a specific daemon's state file. Ignores ENOENT. */
12
14
  export declare function deleteDaemonState(pid: number): Promise<void>;
13
- /** Delete state files for dead PIDs from disk. */
15
+ /** Delete state files for dead or reused PIDs from disk. */
14
16
  export declare function pruneStaleStates(): Promise<void>;
15
- /** Read all states, delete stale files (dead PIDs) from disk, return only alive ones. */
17
+ /** Read all states, delete stale files (dead or reused PIDs) from disk, return only alive ones. */
16
18
  export declare function getAliveDaemonStates(): Promise<DaemonState[]>;
@@ -1,12 +1,60 @@
1
1
  import defaults from "./defaults.js";
2
2
  import path from "path";
3
3
  import fs from "fs/promises";
4
+ import { execFile } from "child_process";
5
+ import { promisify } from "util";
6
+ const execFileAsync = promisify(execFile);
4
7
  const DAEMON_STATES_DIR = path.join(defaults.PKC_DATA_PATH, ".daemon_states");
5
8
  function stateFilePath(pid) {
6
9
  return path.join(DAEMON_STATES_DIR, `${pid}-daemon.state`);
7
10
  }
11
+ /**
12
+ * OS-reported start time of a process, used as an identity token: if a state file's PID
13
+ * was reused by an unrelated process, its start time won't match the recorded one.
14
+ * Linux: starttime (field 22) of /proc/<pid>/stat, in clock ticks since boot.
15
+ * Other unix: `ps -o lstart=` output. Returns undefined when it can't be determined.
16
+ */
17
+ async function getProcessStartTime(pid) {
18
+ try {
19
+ const stat = await fs.readFile(`/proc/${pid}/stat`, "utf-8");
20
+ // comm (field 2) may contain spaces/parens — real fields resume after the last ')'
21
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
22
+ return fields[19]; // field 22 (starttime), offset by the 3 fields before the split
23
+ }
24
+ catch {
25
+ try {
26
+ const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "lstart="]);
27
+ return stdout.trim() || undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ }
34
+ /** Full command line of a process, or undefined when it can't be determined. */
35
+ async function getProcessCommandLine(pid) {
36
+ try {
37
+ // An empty /proc cmdline is meaningful (kernel thread — not a daemon), so keep it
38
+ const raw = await fs.readFile(`/proc/${pid}/cmdline`, "utf-8");
39
+ return raw.split("\0").join(" ").trim();
40
+ }
41
+ catch {
42
+ try {
43
+ const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "args="]);
44
+ return stdout.trim() || undefined;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ }
8
51
  /** Write a daemon state file atomically (write to .tmp then rename). */
9
52
  export async function writeDaemonState(state) {
53
+ if (state.procStartTime === undefined) {
54
+ const procStartTime = await getProcessStartTime(state.pid);
55
+ if (procStartTime !== undefined)
56
+ state = { ...state, procStartTime };
57
+ }
10
58
  await fs.mkdir(DAEMON_STATES_DIR, { recursive: true });
11
59
  const dest = stateFilePath(state.pid);
12
60
  const tmp = dest + ".tmp";
@@ -60,21 +108,37 @@ function isPidAlive(pid) {
60
108
  return false; // ESRCH — no such process
61
109
  }
62
110
  }
63
- /** Delete state files for dead PIDs from disk. */
64
- export async function pruneStaleStates() {
65
- const states = await readAllDaemonStates();
66
- for (const state of states) {
67
- if (!isPidAlive(state.pid)) {
68
- await deleteDaemonState(state.pid);
69
- }
111
+ /**
112
+ * Check whether the daemon that wrote `state` is still the process running under its PID.
113
+ * A bare liveness check is not enough: a stale state file's PID may have been reused by an
114
+ * unrelated process (e.g. a state file written inside a Docker container whose PID maps to
115
+ * a kernel thread on the host — issue #66).
116
+ */
117
+ async function isDaemonStateAlive(state) {
118
+ if (!isPidAlive(state.pid))
119
+ return false;
120
+ if (state.procStartTime !== undefined) {
121
+ const current = await getProcessStartTime(state.pid);
122
+ if (current !== undefined)
123
+ return current === state.procStartTime; // mismatch — PID was reused
124
+ return true; // identity undeterminable — fall back to liveness only
70
125
  }
126
+ // Legacy state file without procStartTime — heuristic: the command line must reference bitsocial
127
+ const cmdline = await getProcessCommandLine(state.pid);
128
+ if (cmdline === undefined)
129
+ return true; // identity undeterminable — fall back to liveness only
130
+ return cmdline.includes("bitsocial");
131
+ }
132
+ /** Delete state files for dead or reused PIDs from disk. */
133
+ export async function pruneStaleStates() {
134
+ await getAliveDaemonStates();
71
135
  }
72
- /** Read all states, delete stale files (dead PIDs) from disk, return only alive ones. */
136
+ /** Read all states, delete stale files (dead or reused PIDs) from disk, return only alive ones. */
73
137
  export async function getAliveDaemonStates() {
74
138
  const states = await readAllDaemonStates();
75
139
  const alive = [];
76
140
  for (const state of states) {
77
- if (isPidAlive(state.pid)) {
141
+ if (await isDaemonStateAlive(state)) {
78
142
  alive.push(state);
79
143
  }
80
144
  else {
@@ -185,53 +185,53 @@ async function ensureIpfsPortsAreAvailable(log, configPath, apiUrl, gatewayUrl)
185
185
  }
186
186
  }
187
187
  export async function startKuboNode(apiUrl, gatewayUrl, dataPath, onSpawn) {
188
- return new Promise(async (resolve, reject) => {
189
- const log = PKCLogger("bitsocial-cli:ipfs:startKuboNode");
190
- const ipfsDataPath = process.env["IPFS_PATH"] || path.join(dataPath, ".bitsocial-cli.ipfs");
191
- await fs.promises.mkdir(ipfsDataPath, { recursive: true });
192
- const ipfsConfigPath = path.join(ipfsDataPath, "config");
193
- const kuboExePath = await getKuboExePath();
194
- const kuboVersion = await getKuboVersion();
195
- log(`Using Kubo version: ${kuboVersion}`);
196
- log(`IpfsDataPath (${ipfsDataPath}), kuboExePath (${kuboExePath})`, "kubo ipfs config file", path.join(ipfsDataPath, "config"));
197
- log("If you would like to change kubo config, please edit the config file at", path.join(ipfsDataPath, "config"));
198
- const env = { IPFS_PATH: ipfsDataPath, DEBUG_COLORS: "1" };
199
- let configJustInitialized = false;
200
- try {
201
- await _spawnAsync(log, kuboExePath, ["init"], { env, hideWindows: true });
202
- configJustInitialized = true;
203
- }
204
- catch (e) {
205
- const error = e;
206
- if (!error?.message?.includes("ipfs configuration file already exists!"))
207
- throw new Error("Failed to call ipfs init" + error);
208
- }
209
- if (configJustInitialized) {
210
- await _spawnAsync(log, kuboExePath, ["config", "profile", "apply", `server`], {
211
- env,
212
- hideWindows: true
213
- });
214
- log("Called 'ipfs config profile apply server' successfully");
215
- await mergeCliDefaultsIntoIpfsConfig(log, ipfsConfigPath, apiUrl, gatewayUrl);
216
- }
217
- else {
218
- log("IPFS config already exists; skipping config overrides to preserve user changes.");
219
- }
220
- try {
221
- await _spawnAsync(log, kuboExePath, ["repo", "migrate"], { env, hideWindows: true });
222
- log("Ensured IPFS repository is migrated to the latest supported version.");
223
- }
224
- catch (migrationError) {
225
- log.error("Failed to run IPFS repo migrations automatically", migrationError);
226
- throw migrationError;
227
- }
228
- try {
229
- await ensureIpfsPortsAreAvailable(log, ipfsConfigPath, apiUrl, gatewayUrl);
230
- }
231
- catch (error) {
232
- reject(error instanceof Error ? error : new Error(String(error)));
233
- return;
234
- }
188
+ // Preparation phase runs as plain awaits so any failure rejects the returned promise.
189
+ // It must NOT live inside the new Promise() executor below: an async executor swallows
190
+ // throws as unhandledRejections and the promise never settles, which wedges the daemon's
191
+ // pendingKuboStart tracking and hangs its shutdown (issue #70).
192
+ const log = PKCLogger("bitsocial-cli:ipfs:startKuboNode");
193
+ const ipfsDataPath = process.env["IPFS_PATH"] || path.join(dataPath, ".bitsocial-cli.ipfs");
194
+ await fs.promises.mkdir(ipfsDataPath, { recursive: true });
195
+ const ipfsConfigPath = path.join(ipfsDataPath, "config");
196
+ const kuboExePath = await getKuboExePath();
197
+ const kuboVersion = await getKuboVersion();
198
+ log(`Using Kubo version: ${kuboVersion}`);
199
+ log(`IpfsDataPath (${ipfsDataPath}), kuboExePath (${kuboExePath})`, "kubo ipfs config file", path.join(ipfsDataPath, "config"));
200
+ log("If you would like to change kubo config, please edit the config file at", path.join(ipfsDataPath, "config"));
201
+ const env = { IPFS_PATH: ipfsDataPath, DEBUG_COLORS: "1" };
202
+ let configJustInitialized = false;
203
+ try {
204
+ await _spawnAsync(log, kuboExePath, ["init"], { env, hideWindows: true });
205
+ configJustInitialized = true;
206
+ }
207
+ catch (e) {
208
+ const error = e;
209
+ if (!error?.message?.includes("ipfs configuration file already exists!"))
210
+ throw new Error("Failed to call ipfs init" + error);
211
+ }
212
+ if (configJustInitialized) {
213
+ await _spawnAsync(log, kuboExePath, ["config", "profile", "apply", `server`], {
214
+ env,
215
+ hideWindows: true
216
+ });
217
+ log("Called 'ipfs config profile apply server' successfully");
218
+ await mergeCliDefaultsIntoIpfsConfig(log, ipfsConfigPath, apiUrl, gatewayUrl);
219
+ }
220
+ else {
221
+ log("IPFS config already exists; skipping config overrides to preserve user changes.");
222
+ }
223
+ try {
224
+ await _spawnAsync(log, kuboExePath, ["repo", "migrate"], { env, hideWindows: true });
225
+ log("Ensured IPFS repository is migrated to the latest supported version.");
226
+ }
227
+ catch (migrationError) {
228
+ log.error("Failed to run IPFS repo migrations automatically", migrationError);
229
+ throw migrationError;
230
+ }
231
+ await ensureIpfsPortsAreAvailable(log, ipfsConfigPath, apiUrl, gatewayUrl);
232
+ // Spawn phase: the promise only wraps the event-driven wait for kubo's "Daemon is ready",
233
+ // so every settle path goes through resolve/reject.
234
+ return new Promise((resolve, reject) => {
235
235
  const daemonArgs = ["--enable-namesys-pubsub", "--migrate"];
236
236
  const kuboProcess = spawn(kuboExePath, ["daemon", ...daemonArgs], {
237
237
  env,
@@ -6,7 +6,7 @@ import fs from "fs/promises";
6
6
  import { PKCLogger } from "../util.js";
7
7
  import { randomBytes } from "crypto";
8
8
  import express from "express";
9
- import { loadChallengesIntoPKC } from "../challenge-packages/challenge-utils.js";
9
+ import { loadChallengesIntoPKC, formatChallengeNameVersion } from "../challenge-packages/challenge-utils.js";
10
10
  const rootHashRedirectScriptPattern = /<script\b[^>]*>(?:(?!<\/script>)[\s\S])*?window\.location\.replace\(["']\/#["']\s*\+\s*window\.location\.pathname\s*\+\s*window\.location\.search\);(?:(?!<\/script>)[\s\S])*?<\/script>/;
11
11
  async function _generateModifiedIndexHtmlWithRpcSettings(webuiPath, webuiName, ipfsGatewayPort) {
12
12
  const indexHtmlString = (await fs.readFile(path.join(webuiPath, "index_backup_no_rpc.html")))
@@ -124,7 +124,7 @@ export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions, rpcS
124
124
  // Challenge reload endpoints
125
125
  const handleChallengeReload = async (_req, res) => {
126
126
  try {
127
- const loadedNames = await loadChallengesIntoPKC(pkcOptions.dataPath);
127
+ const loadedChallenges = await loadChallengesIntoPKC(pkcOptions.dataPath);
128
128
  // Notify all connected RPC clients about the updated challenges
129
129
  const onSettingsChange = rpcServer._onSettingsChange;
130
130
  if (onSettingsChange) {
@@ -139,7 +139,7 @@ export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions, rpcS
139
139
  }
140
140
  }
141
141
  }
142
- res.json({ ok: true, challenges: loadedNames });
142
+ res.json({ ok: true, challenges: loadedChallenges.map(formatChallengeNameVersion) });
143
143
  }
144
144
  catch (err) {
145
145
  log.error("Failed to reload challenges", err);
@@ -161,7 +161,10 @@
161
161
  ]
162
162
  },
163
163
  "challenge:install": {
164
- "aliases": [],
164
+ "aliases": [
165
+ "challenge:i",
166
+ "challenge:add"
167
+ ],
165
168
  "args": {
166
169
  "package": {
167
170
  "description": "Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)",
@@ -205,7 +208,9 @@
205
208
  ]
206
209
  },
207
210
  "challenge:list": {
208
- "aliases": [],
211
+ "aliases": [
212
+ "challenge:ls"
213
+ ],
209
214
  "args": {},
210
215
  "description": "List installed challenge packages",
211
216
  "examples": [
@@ -247,7 +252,11 @@
247
252
  ]
248
253
  },
249
254
  "challenge:remove": {
250
- "aliases": [],
255
+ "aliases": [
256
+ "challenge:uninstall",
257
+ "challenge:rm",
258
+ "challenge:un"
259
+ ],
251
260
  "args": {
252
261
  "name": {
253
262
  "description": "The challenge package name (e.g., my-challenge or @scope/my-challenge)",
@@ -862,5 +871,5 @@
862
871
  ]
863
872
  }
864
873
  },
865
- "version": "0.19.65"
874
+ "version": "0.19.66"
866
875
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/bitsocial-cli",
3
- "version": "0.19.65",
3
+ "version": "0.19.66",
4
4
  "description": "Command line interface to Bitsocial API",
5
5
  "types": "./dist/index.d.ts",
6
6
  "homepage": "https://github.com/bitsocialnet/bitsocial-cli",