@bitsocial/bitsocial-cli 0.19.64 → 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.
@@ -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" });
@@ -0,0 +1,22 @@
1
+ import { BaseCommand } from "../../base-command.js";
2
+ export default class Export extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ address: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ publicKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ path: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ includePrivateKey: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ };
16
+ private _printProgress;
17
+ run(): Promise<void>;
18
+ /** Start the export on the RPC server and resolve with the terminal record (progress === 1). */
19
+ private _runExport;
20
+ /** Download the finished snapshot to destPath, verifying its sha256 against the export record. */
21
+ private _downloadAndVerify;
22
+ }
@@ -0,0 +1,198 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import { BaseCommand } from "../../base-command.js";
3
+ import defaults from "../../../common-utils/defaults.js";
4
+ import { PKCLogger } from "../../../util.js";
5
+ import { createHash } from "node:crypto";
6
+ import { createWriteStream } from "node:fs";
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { Readable } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
+ export default class Export extends BaseCommand {
12
+ static description = "Export a local community to a SQLite snapshot file. The export runs on the RPC server (daemon); once finished the snapshot is downloaded and its sha256 checksum is verified. Pass --includePrivateKey to produce a restorable backup that keeps the community's address.";
13
+ static examples = [
14
+ "bitsocial community export plebmusic.bso",
15
+ "bitsocial community export plebmusic.bso --includePrivateKey -o ./backups/plebmusic.sqlite",
16
+ "bitsocial community export --name my-community",
17
+ "bitsocial community export --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu"
18
+ ];
19
+ static args = {
20
+ address: Args.string({
21
+ name: "address",
22
+ required: false,
23
+ description: "Address of the community to export"
24
+ })
25
+ };
26
+ static flags = {
27
+ name: Flags.string({
28
+ description: "Name of the community to export"
29
+ }),
30
+ publicKey: Flags.string({
31
+ description: "Public key of the community to export"
32
+ }),
33
+ path: Flags.string({
34
+ char: "o",
35
+ description: "Destination file for the downloaded snapshot (default: <dataPath>/exports/<address>_<datetime>.sqlite)"
36
+ }),
37
+ includePrivateKey: Flags.boolean({
38
+ default: false,
39
+ description: "Ask the RPC server to include the community signer's private key in the export. Required for a restorable backup that keeps the same community address. The daemon may refuse (see `bitsocial daemon --no-allowPrivateKeyExport`)"
40
+ }),
41
+ force: Flags.boolean({
42
+ default: false,
43
+ description: "Overwrite the destination file if it already exists"
44
+ }),
45
+ quiet: Flags.boolean({
46
+ char: "q",
47
+ default: false,
48
+ description: "Suppress progress output; only print the path of the downloaded snapshot"
49
+ })
50
+ };
51
+ _printProgress(quiet, message) {
52
+ if (!quiet)
53
+ process.stderr.write(message);
54
+ }
55
+ async run() {
56
+ const { args, flags } = await this.parse(Export);
57
+ const log = PKCLogger("bitsocial-cli:commands:community:export");
58
+ log(`args: `, args);
59
+ log(`flags: `, flags);
60
+ const lookupParam = {};
61
+ if (args.address)
62
+ lookupParam.address = args.address;
63
+ if (flags.name)
64
+ lookupParam.name = flags.name;
65
+ if (flags.publicKey)
66
+ lookupParam.publicKey = flags.publicKey;
67
+ if (Object.keys(lookupParam).length === 0) {
68
+ this.error("At least one of address argument, --name, or --publicKey must be provided");
69
+ }
70
+ const pkc = await this._connectToPkcRpc(flags.pkcRpcUrl.toString());
71
+ // Cancel the in-flight export server-side on Ctrl+C. A second Ctrl+C force-exits
72
+ // (the handler is registered with `once`, so the default SIGINT behavior is restored).
73
+ const abortController = new AbortController();
74
+ const onSigint = () => {
75
+ this._printProgress(flags.quiet, "\nCancelling export... (Ctrl+C again to force exit)\n");
76
+ abortController.abort();
77
+ };
78
+ process.once("SIGINT", onSigint);
79
+ try {
80
+ const community = (await pkc.createCommunity(lookupParam));
81
+ if (typeof community.export !== "function") {
82
+ this.error(`Community is not local to the RPC server at ${flags.pkcRpcUrl}. Only communities created on this daemon can be exported`);
83
+ }
84
+ const exportableCommunity = community;
85
+ // Datetime in the filename matches the daemon log convention (ISO 8601 with ':' → '-')
86
+ // so repeated exports never collide and snapshots sort chronologically
87
+ const defaultFilename = `${exportableCommunity.address}_${new Date().toISOString().replace(/:/g, "-")}.sqlite`;
88
+ const destPath = path.resolve(flags.path ?? path.join(defaults.PKC_DATA_PATH, "exports", defaultFilename));
89
+ const destExists = await fs
90
+ .stat(destPath)
91
+ .then(() => true)
92
+ .catch(() => false);
93
+ if (destExists && !flags.force) {
94
+ this.error(`Destination file already exists: ${destPath}. Use --force to overwrite it`);
95
+ }
96
+ const finishedRecord = await this._runExport(exportableCommunity, flags.includePrivateKey, abortController.signal, flags.quiet);
97
+ log("Export finished on the RPC server", finishedRecord);
98
+ if (!finishedRecord.url) {
99
+ this.error(`Export ${finishedRecord.exportId} finished but the RPC server did not provide a download URL`);
100
+ }
101
+ // Mirrors pkc-js's rpcHttpOrigin: the ws[s]:// RPC URL with the protocol swapped to http[s]://
102
+ const parsedRpcUrl = new URL(flags.pkcRpcUrl.toString());
103
+ const expectedDownloadOrigin = `${parsedRpcUrl.protocol === "wss:" ? "https:" : "http:"}//${parsedRpcUrl.host}`;
104
+ await this._downloadAndVerify(finishedRecord, destPath, abortController.signal, flags.quiet, expectedDownloadOrigin);
105
+ this.log(destPath);
106
+ }
107
+ catch (e) {
108
+ console.error(e);
109
+ await pkc.destroy();
110
+ this.exit(1);
111
+ }
112
+ finally {
113
+ process.removeListener("SIGINT", onSigint);
114
+ }
115
+ await pkc.destroy();
116
+ }
117
+ /** Start the export on the RPC server and resolve with the terminal record (progress === 1). */
118
+ async _runExport(community, includePrivateKey, signal, quiet) {
119
+ const { exportId } = await community.export({ includePrivateKey, signal });
120
+ return new Promise((resolve, reject) => {
121
+ let lastPrintedPercent = -1;
122
+ const cleanup = () => {
123
+ community.removeListener("exportschange", checkRecords);
124
+ signal.removeEventListener("abort", onAbort);
125
+ };
126
+ // Don't wait for the server's terminal ERR_EXPORT_CANCELLED record — it never arrives if the
127
+ // daemon died or the connection dropped. pkc-js's own abort listener (registered inside
128
+ // community.export(), before this one) already dispatched cancelExport() to the server.
129
+ const onAbort = () => {
130
+ cleanup();
131
+ reject(new Error("Export cancelled"));
132
+ };
133
+ const checkRecords = (records) => {
134
+ const record = records.find((rec) => rec.exportId === exportId);
135
+ if (!record)
136
+ return;
137
+ if (record.error) {
138
+ cleanup();
139
+ reject(new Error(`Export failed (${record.error.code}): ${record.error.message}`));
140
+ }
141
+ else if (record.progress === 1) {
142
+ cleanup();
143
+ this._printProgress(quiet, `\rExporting ${community.address}: 100%\n`);
144
+ resolve(record);
145
+ }
146
+ else {
147
+ const percent = Math.floor(record.progress * 100);
148
+ if (percent !== lastPrintedPercent) {
149
+ lastPrintedPercent = percent;
150
+ this._printProgress(quiet, `\rExporting ${community.address}: ${percent}%`);
151
+ }
152
+ }
153
+ };
154
+ community.on("exportschange", checkRecords);
155
+ signal.addEventListener("abort", onAbort, { once: true });
156
+ if (signal.aborted)
157
+ return onAbort();
158
+ // The terminal notification may have arrived before the listener was attached
159
+ checkRecords(community.exports);
160
+ });
161
+ }
162
+ /** Download the finished snapshot to destPath, verifying its sha256 against the export record. */
163
+ async _downloadAndVerify(record, destPath, signal, quiet, expectedOrigin) {
164
+ // The export download contract is GET <rpc-http-origin>/exports/<exportId> — refuse anything else
165
+ // so a misconfigured/compromised RPC server can't use the CLI to fetch arbitrary URLs
166
+ const downloadUrl = new URL(record.url);
167
+ if (downloadUrl.origin !== expectedOrigin || !downloadUrl.pathname.startsWith("/exports/")) {
168
+ this.error(`Refusing to download export from unexpected URL ${record.url} (expected ${expectedOrigin}/exports/<exportId>)`);
169
+ }
170
+ this._printProgress(quiet, `Downloading snapshot from ${downloadUrl}\n`);
171
+ const response = await fetch(downloadUrl, { signal });
172
+ if (!response.ok || !response.body) {
173
+ this.error(`Failed to download export from ${record.url}: HTTP ${response.status}`);
174
+ }
175
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
176
+ // Download to a .partial file so an interrupted/corrupted download never clobbers destPath
177
+ const partialPath = destPath + ".partial";
178
+ const hash = createHash("sha256");
179
+ try {
180
+ await pipeline(Readable.fromWeb(response.body), async function* (source) {
181
+ for await (const chunk of source) {
182
+ hash.update(chunk);
183
+ yield chunk;
184
+ }
185
+ }, createWriteStream(partialPath));
186
+ const downloadedSha256 = hash.digest("hex");
187
+ if (record.sha256 && downloadedSha256 !== record.sha256) {
188
+ throw new Error(`sha256 mismatch for downloaded export: expected ${record.sha256} but downloaded file hashes to ${downloadedSha256}`);
189
+ }
190
+ await fs.rename(partialPath, destPath);
191
+ }
192
+ catch (e) {
193
+ await fs.rm(partialPath, { force: true }).catch(() => { });
194
+ throw e;
195
+ }
196
+ this._printProgress(quiet, `Verified sha256 (${record.sha256}) and saved snapshot${record.size ? ` (${record.size} bytes)` : ""}\n`);
197
+ }
198
+ }
@@ -26,6 +26,7 @@ export default class Daemon extends Command {
26
26
  pkcRpcUrl: import("@oclif/core/interfaces").OptionFlag<import("url").URL, import("@oclif/core/interfaces").CustomOptions>;
27
27
  logPath: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
28
28
  chainProviderUrls: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
29
+ allowPrivateKeyExport: import("@oclif/core/interfaces").BooleanFlag<boolean>;
29
30
  };
30
31
  static examples: string[];
31
32
  private _setupLogger;
@@ -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";
@@ -83,6 +83,11 @@ export default class Daemon extends Command {
83
83
  description: "RPC URL(s) for .bso name resolution. Can be specified multiple times.",
84
84
  multiple: true,
85
85
  default: DEFAULT_PROVIDERS
86
+ }),
87
+ allowPrivateKeyExport: Flags.boolean({
88
+ description: "Allow RPC clients to request community exports that include the community signer's private key (`bitsocial community export --includePrivateKey`). Disable with --no-allowPrivateKeyExport when exposing the RPC to untrusted clients",
89
+ allowNo: true,
90
+ default: true
86
91
  })
87
92
  };
88
93
  static examples = [
@@ -91,6 +96,7 @@ export default class Daemon extends Command {
91
96
  "bitsocial daemon --pkcOptions.dataPath /tmp/bitsocial-datapath/",
92
97
  "bitsocial daemon --pkcOptions.kuboRpcClientsOptions[0] https://remoteipfsnode.com",
93
98
  "bitsocial daemon --chainProviderUrls https://mainnet.infura.io/v3/YOUR_KEY",
99
+ "bitsocial daemon --no-allowPrivateKeyExport",
94
100
  ];
95
101
  _setupLogger(Logger) {
96
102
  setupDebugLogger(Logger, { enableDefaultNamespace: true });
@@ -187,7 +193,8 @@ export default class Daemon extends Command {
187
193
  return { logFilePath, stdoutWrite, fileLogger };
188
194
  }
189
195
  async run() {
190
- printBanner();
196
+ // Daemon output is often viewed through Docker/systemd logs where stdout is not a TTY.
197
+ printBanner({ forceColor: true });
191
198
  // Non-blocking update check — fire-and-forget, won't delay startup
192
199
  import("../../update/npm-registry.js")
193
200
  .then(({ fetchLatestVersion }) => fetchLatestVersion().then(async (latest) => {
@@ -268,7 +275,10 @@ export default class Daemon extends Command {
268
275
  let pendingKuboStart;
269
276
  // Kubo Node may fail randomly, we need to set a listener so when it exits because of an error we restart it
270
277
  let kuboProcess;
271
- 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 () => {
272
282
  if (mainProcessExited)
273
283
  return;
274
284
  const kuboApiPort = Number(kuboRpcEndpoint.port);
@@ -276,6 +286,17 @@ export default class Daemon extends Command {
276
286
  return; // already started, no need to intervene
277
287
  const connectHostname = toConnectableHostname(kuboRpcEndpoint.hostname);
278
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;
279
300
  if (isKuboApiPortTaken) {
280
301
  const connectableEndpoint = new URL(kuboRpcEndpoint.toString());
281
302
  connectableEndpoint.hostname = connectHostname;
@@ -299,8 +320,15 @@ export default class Daemon extends Command {
299
320
  }
300
321
  throw new Error(`Cannot start IPFS daemon because the IPFS API port ${kuboRpcEndpoint.hostname}:${kuboApiPort} (configured as ${kuboRpcEndpoint.toString()}) is already in use.`);
301
322
  }
323
+ let spawnedProcess;
302
324
  const startPromise = startKuboNode(kuboRpcEndpoint, ipfsGatewayEndpoint, mergedPkcOptions.dataPath, (process) => {
325
+ spawnedProcess = process;
303
326
  kuboProcess = process;
327
+ if (process.pid) {
328
+ const pid = process.pid;
329
+ liveKuboPids.add(pid);
330
+ process.once("exit", () => liveKuboPids.delete(pid));
331
+ }
304
332
  });
305
333
  pendingKuboStart = startPromise;
306
334
  let startedProcess;
@@ -308,12 +336,15 @@ export default class Daemon extends Command {
308
336
  startedProcess = await startPromise;
309
337
  }
310
338
  catch (error) {
311
- pendingKuboStart = undefined;
312
- 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)
313
343
  kuboProcess = undefined;
314
344
  throw error;
315
345
  }
316
- pendingKuboStart = undefined;
346
+ if (pendingKuboStart === startPromise)
347
+ pendingKuboStart = undefined;
317
348
  if (mainProcessExited) {
318
349
  if (startedProcess?.pid && !startedProcess.killed) {
319
350
  // Race condition: Kubo finished starting after mainProcessExited.
@@ -334,7 +365,8 @@ export default class Daemon extends Command {
334
365
  /* best effort */
335
366
  }
336
367
  }
337
- kuboProcess = undefined;
368
+ if (kuboProcess === startedProcess)
369
+ kuboProcess = undefined;
338
370
  return;
339
371
  }
340
372
  kuboProcess = startedProcess;
@@ -346,7 +378,8 @@ export default class Daemon extends Command {
346
378
  // Restart Kubo process because it failed
347
379
  if (!mainProcessExited) {
348
380
  log(`Kubo node with pid (${currentProcess?.pid}) exited. Will attempt to restart it`);
349
- kuboProcess = undefined;
381
+ if (kuboProcess === currentProcess)
382
+ kuboProcess = undefined;
350
383
  try {
351
384
  await keepKuboUp();
352
385
  }
@@ -360,6 +393,19 @@ export default class Daemon extends Command {
360
393
  };
361
394
  currentProcess.once("exit", onKuboExit);
362
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
+ };
363
409
  let startedOwnRpc = false;
364
410
  let daemonServer;
365
411
  const createOrConnectRpc = async () => {
@@ -377,8 +423,10 @@ export default class Daemon extends Command {
377
423
  // Load installed challenge packages before starting the RPC server
378
424
  const loadedChallenges = await loadChallengesIntoPKC(mergedPkcOptions.dataPath);
379
425
  if (loadedChallenges.length > 0)
380
- console.log(`Loaded challenge packages: ${loadedChallenges.join(", ")}`);
381
- daemonServer = await startDaemonServer(pkcRpcUrl, ipfsGatewayEndpoint, mergedPkcOptions);
426
+ console.log(`Loaded challenge packages: ${loadedChallenges.map(formatChallengeNameVersion).join(", ")}`);
427
+ daemonServer = await startDaemonServer(pkcRpcUrl, ipfsGatewayEndpoint, mergedPkcOptions, {
428
+ allowPrivateKeyExport: flags.allowPrivateKeyExport
429
+ });
382
430
  startedOwnRpc = true;
383
431
  console.log(`pkc rpc: listening on ${pkcRpcUrl} (local connections only)`);
384
432
  console.log(`pkc rpc: listening on ${pkcRpcUrl}${daemonServer.rpcAuthKey} (secret auth key for remote connections)`);
@@ -399,10 +447,6 @@ export default class Daemon extends Command {
399
447
  console.log(`WebUI (${webui.name}${desc}): http://${remoteIpAddress}:${rpcPort}${webui.endpointRemote}`);
400
448
  }
401
449
  };
402
- // RPC port was already verified free above (fail-fast); only the kuboRpcClientsOptions branch skips local kubo.
403
- if (!pkcOptionsFromFlag?.kuboRpcClientsOptions)
404
- await keepKuboUp();
405
- await createOrConnectRpc();
406
450
  let keepKuboUpInterval;
407
451
  const { asyncExitHook } = await import("exit-hook");
408
452
  const killKuboProcessGroup = (pid, signal) => {
@@ -424,14 +468,19 @@ export default class Daemon extends Command {
424
468
  }
425
469
  };
426
470
  const killKuboProcess = async () => {
427
- if (pendingKuboStart) {
428
- try {
429
- await pendingKuboStart;
430
- }
431
- catch {
432
- /* ignore */
433
- }
434
- }
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
+ ]);
435
484
  if (kuboProcess?.pid && !kuboProcess.killed) {
436
485
  const pid = kuboProcess.pid;
437
486
  log("Attempting to kill kubo process with pid", pid);
@@ -461,6 +510,11 @@ export default class Daemon extends Command {
461
510
  kuboProcess = undefined;
462
511
  }
463
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();
464
518
  };
465
519
  asyncExitHook(async () => {
466
520
  if (keepKuboUpInterval)
@@ -487,13 +541,62 @@ export default class Daemon extends Command {
487
541
  }, { wait: 120000 } // could take two minutes to shut down
488
542
  );
489
543
  // Emergency cleanup: if the process force-exits (e.g. double Ctrl+C),
490
- // synchronously SIGKILL kubo's process group. This is a no-op if
491
- // 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).
492
546
  process.on("exit", () => {
493
547
  if (kuboProcess?.pid) {
494
548
  killKuboProcessGroup(kuboProcess.pid, "SIGKILL");
495
549
  }
550
+ for (const pid of liveKuboPids)
551
+ killKuboProcessGroup(pid, "SIGKILL");
496
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
+ }
596
+ // RPC port was already verified free above (fail-fast); only the kuboRpcClientsOptions branch skips local kubo.
597
+ if (!pkcOptionsFromFlag?.kuboRpcClientsOptions)
598
+ await keepKuboUp();
599
+ await createOrConnectRpc();
497
600
  keepKuboUpInterval = setInterval(async () => {
498
601
  if (mainProcessExited)
499
602
  return;
@@ -115,6 +115,7 @@ export default class Install extends Command {
115
115
  // Restart daemons with the new binary
116
116
  if (aliveDaemons.length > 0 && flags["restart-daemons"]) {
117
117
  await this._restartDaemons(aliveDaemons);
118
+ this.log("To see the daemon logs run `bitsocial logs --stdout`");
118
119
  }
119
120
  }
120
121
  async _restartDaemons(daemons) {
@@ -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[]>;