@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.
- package/README.md +255 -15
- package/dist/challenge-packages/challenge-utils.d.ts +2 -1
- package/dist/challenge-packages/challenge-utils.js +6 -3
- package/dist/cli/ascii-banner.d.ts +8 -1
- package/dist/cli/ascii-banner.js +31 -24
- package/dist/cli/commands/challenge/install.d.ts +1 -0
- package/dist/cli/commands/challenge/install.js +27 -31
- package/dist/cli/commands/challenge/list.d.ts +1 -0
- package/dist/cli/commands/challenge/list.js +10 -9
- package/dist/cli/commands/challenge/remove.d.ts +1 -0
- package/dist/cli/commands/challenge/remove.js +13 -2
- package/dist/cli/commands/community/export.d.ts +22 -0
- package/dist/cli/commands/community/export.js +198 -0
- package/dist/cli/commands/daemon.d.ts +1 -0
- package/dist/cli/commands/daemon.js +127 -24
- package/dist/cli/commands/update/install.js +1 -0
- package/dist/common-utils/daemon-state.d.ts +4 -2
- package/dist/common-utils/daemon-state.js +73 -9
- package/dist/ipfs/startIpfs.js +47 -47
- package/dist/webui/daemon-server.d.ts +3 -1
- package/dist/webui/daemon-server.js +19 -5
- package/oclif.manifest.json +106 -5
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
}
|
|
@@ -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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
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[]>;
|