@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,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,
@@ -1,4 +1,6 @@
1
- export declare function startDaemonServer(rpcUrl: URL, ipfsGatewayUrl: URL, pkcOptions: any): Promise<{
1
+ export declare function startDaemonServer(rpcUrl: URL, ipfsGatewayUrl: URL, pkcOptions: any, rpcServerOptions?: {
2
+ allowPrivateKeyExport?: boolean;
3
+ }): Promise<{
2
4
  rpcAuthKey: string;
3
5
  listedSub: string[];
4
6
  webuis: {
@@ -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")))
@@ -39,10 +39,23 @@ async function _generateRpcAuthKeyIfNotExisting(pkcDataPath) {
39
39
  return pkcRpcAuthKey;
40
40
  }
41
41
  // The daemon server will host both RPC and webui on the same port
42
- export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions) {
42
+ export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions, rpcServerOptions) {
43
43
  // Start pkc-js RPC
44
44
  const log = PKCLogger("bitsocial-cli:daemon:startDaemonServer");
45
45
  const webuiExpressApp = express();
46
+ // GET /exports/<exportId> is streamed by pkc-js's own request listener, attached to this same
47
+ // http.Server inside PKCWsServer. Express must stay silent for those paths — its catch-all 404
48
+ // races the async pkc-js handler and clobbers the download (the CLI's `community export` would
49
+ // see HTTP 404). Other /exports/ paths fall through to express's 404 because pkc-js ignores
50
+ // them on a caller-supplied server and the request would otherwise hang unanswered.
51
+ // NOT mounted at "/exports": a mounted middleware strips the mount prefix from the shared
52
+ // req.url while the request is held, so pkc-js's listener would no longer recognize it.
53
+ webuiExpressApp.use((req, res, next) => {
54
+ const isExportDownload = /^\/exports\/[0-9a-fA-F-]{36}$/.test(req.path);
55
+ if (!isExportDownload)
56
+ return next();
57
+ // intentionally neither responds nor calls next(): pkc-js's listener owns this request
58
+ });
46
59
  // Wait for bind to actually complete before returning. Calling express.listen() without
47
60
  // awaiting 'listening' lets startup proceed before the port is accepting connections,
48
61
  // and without an 'error' handler a bind failure becomes an uncaughtException that kills
@@ -68,7 +81,8 @@ export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions) {
68
81
  const rpcServer = await PKCRpc.default.PKCWsServer({
69
82
  server: httpServer,
70
83
  pkcOptions: pkcOptions,
71
- authKey: rpcAuthKey
84
+ authKey: rpcAuthKey,
85
+ allowPrivateKeyExport: rpcServerOptions?.allowPrivateKeyExport
72
86
  });
73
87
  const webuisDir = path.join(__dirname, "..", "..", "dist", "webuis");
74
88
  const webUiNames = (await fs.readdir(webuisDir, { withFileTypes: true })).filter((file) => file.isDirectory()).map((file) => file.name);
@@ -110,7 +124,7 @@ export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions) {
110
124
  // Challenge reload endpoints
111
125
  const handleChallengeReload = async (_req, res) => {
112
126
  try {
113
- const loadedNames = await loadChallengesIntoPKC(pkcOptions.dataPath);
127
+ const loadedChallenges = await loadChallengesIntoPKC(pkcOptions.dataPath);
114
128
  // Notify all connected RPC clients about the updated challenges
115
129
  const onSettingsChange = rpcServer._onSettingsChange;
116
130
  if (onSettingsChange) {
@@ -125,7 +139,7 @@ export async function startDaemonServer(rpcUrl, ipfsGatewayUrl, pkcOptions) {
125
139
  }
126
140
  }
127
141
  }
128
- res.json({ ok: true, challenges: loadedNames });
142
+ res.json({ ok: true, challenges: loadedChallenges.map(formatChallengeNameVersion) });
129
143
  }
130
144
  catch (err) {
131
145
  log.error("Failed to reload challenges", err);
@@ -9,7 +9,8 @@
9
9
  "bitsocial daemon --pkcRpcUrl ws://localhost:53812",
10
10
  "bitsocial daemon --pkcOptions.dataPath /tmp/bitsocial-datapath/",
11
11
  "bitsocial daemon --pkcOptions.kuboRpcClientsOptions[0] https://remoteipfsnode.com",
12
- "bitsocial daemon --chainProviderUrls https://mainnet.infura.io/v3/YOUR_KEY"
12
+ "bitsocial daemon --chainProviderUrls https://mainnet.infura.io/v3/YOUR_KEY",
13
+ "bitsocial daemon --no-allowPrivateKeyExport"
13
14
  ],
14
15
  "flags": {
15
16
  "pkcRpcUrl": {
@@ -44,6 +45,12 @@
44
45
  "hasDynamicHelp": false,
45
46
  "multiple": true,
46
47
  "type": "option"
48
+ },
49
+ "allowPrivateKeyExport": {
50
+ "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",
51
+ "name": "allowPrivateKeyExport",
52
+ "allowNo": true,
53
+ "type": "boolean"
47
54
  }
48
55
  },
49
56
  "hasDynamicHelp": false,
@@ -154,7 +161,10 @@
154
161
  ]
155
162
  },
156
163
  "challenge:install": {
157
- "aliases": [],
164
+ "aliases": [
165
+ "challenge:i",
166
+ "challenge:add"
167
+ ],
158
168
  "args": {
159
169
  "package": {
160
170
  "description": "Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)",
@@ -198,7 +208,9 @@
198
208
  ]
199
209
  },
200
210
  "challenge:list": {
201
- "aliases": [],
211
+ "aliases": [
212
+ "challenge:ls"
213
+ ],
202
214
  "args": {},
203
215
  "description": "List installed challenge packages",
204
216
  "examples": [
@@ -240,7 +252,11 @@
240
252
  ]
241
253
  },
242
254
  "challenge:remove": {
243
- "aliases": [],
255
+ "aliases": [
256
+ "challenge:uninstall",
257
+ "challenge:rm",
258
+ "challenge:un"
259
+ ],
244
260
  "args": {
245
261
  "name": {
246
262
  "description": "The challenge package name (e.g., my-challenge or @scope/my-challenge)",
@@ -459,6 +475,91 @@
459
475
  "edit.js"
460
476
  ]
461
477
  },
478
+ "community:export": {
479
+ "aliases": [],
480
+ "args": {
481
+ "address": {
482
+ "description": "Address of the community to export",
483
+ "name": "address",
484
+ "required": false
485
+ }
486
+ },
487
+ "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.",
488
+ "examples": [
489
+ "bitsocial community export plebmusic.bso",
490
+ "bitsocial community export plebmusic.bso --includePrivateKey -o ./backups/plebmusic.sqlite",
491
+ "bitsocial community export --name my-community",
492
+ "bitsocial community export --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu"
493
+ ],
494
+ "flags": {
495
+ "pkcRpcUrl": {
496
+ "name": "pkcRpcUrl",
497
+ "required": true,
498
+ "summary": "URL to PKC RPC",
499
+ "default": "ws://localhost:9138/",
500
+ "hasDynamicHelp": false,
501
+ "multiple": false,
502
+ "type": "option"
503
+ },
504
+ "name": {
505
+ "description": "Name of the community to export",
506
+ "name": "name",
507
+ "hasDynamicHelp": false,
508
+ "multiple": false,
509
+ "type": "option"
510
+ },
511
+ "publicKey": {
512
+ "description": "Public key of the community to export",
513
+ "name": "publicKey",
514
+ "hasDynamicHelp": false,
515
+ "multiple": false,
516
+ "type": "option"
517
+ },
518
+ "path": {
519
+ "char": "o",
520
+ "description": "Destination file for the downloaded snapshot (default: <dataPath>/exports/<address>_<datetime>.sqlite)",
521
+ "name": "path",
522
+ "hasDynamicHelp": false,
523
+ "multiple": false,
524
+ "type": "option"
525
+ },
526
+ "includePrivateKey": {
527
+ "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`)",
528
+ "name": "includePrivateKey",
529
+ "allowNo": false,
530
+ "type": "boolean"
531
+ },
532
+ "force": {
533
+ "description": "Overwrite the destination file if it already exists",
534
+ "name": "force",
535
+ "allowNo": false,
536
+ "type": "boolean"
537
+ },
538
+ "quiet": {
539
+ "char": "q",
540
+ "description": "Suppress progress output; only print the path of the downloaded snapshot",
541
+ "name": "quiet",
542
+ "allowNo": false,
543
+ "type": "boolean"
544
+ }
545
+ },
546
+ "hasDynamicHelp": false,
547
+ "hiddenAliases": [],
548
+ "id": "community:export",
549
+ "pluginAlias": "@bitsocial/bitsocial-cli",
550
+ "pluginName": "@bitsocial/bitsocial-cli",
551
+ "pluginType": "core",
552
+ "strict": true,
553
+ "enableJsonFlag": false,
554
+ "isESM": true,
555
+ "relativePath": [
556
+ "dist",
557
+ "cli",
558
+ "commands",
559
+ "community",
560
+ "export.js"
561
+ ]
562
+ },
462
563
  "community:get": {
463
564
  "aliases": [],
464
565
  "args": {
@@ -770,5 +871,5 @@
770
871
  ]
771
872
  }
772
873
  },
773
- "version": "0.19.64"
874
+ "version": "0.19.66"
774
875
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/bitsocial-cli",
3
- "version": "0.19.64",
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",
@@ -119,7 +119,7 @@
119
119
  "@oclif/plugin-help": "6.2.36",
120
120
  "@oclif/plugin-not-found": "3.2.73",
121
121
  "@oclif/table": "0.5.1",
122
- "@pkcprotocol/pkc-js": "0.0.40",
122
+ "@pkcprotocol/pkc-js": "0.0.41",
123
123
  "dataobject-parser": "1.2.22",
124
124
  "decompress": "4.2.1",
125
125
  "env-paths": "2.2.1",