@bitsocial/bitsocial-cli 0.19.45 → 0.19.47
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 +57 -26
- package/dist/cli/commands/community/create.d.ts +1 -0
- package/dist/cli/commands/community/create.js +38 -2
- package/dist/cli/commands/community/edit.js +27 -22
- package/dist/cli/commands/community/start.d.ts +3 -0
- package/dist/cli/commands/community/start.js +25 -5
- package/dist/cli/commands/daemon.js +5 -5
- package/dist/cli/commands/logs.d.ts +5 -0
- package/dist/cli/commands/logs.js +96 -15
- package/dist/cli/commands/update/install.js +62 -26
- package/dist/update/fast-update.d.ts +6 -0
- package/dist/update/fast-update.js +182 -0
- package/dist/update/npm-registry.d.ts +1 -0
- package/dist/update/npm-registry.js +1 -1
- package/dist/util.d.ts +6 -1
- package/dist/util.js +44 -4
- package/oclif.manifest.json +52 -7
- package/package.json +4 -2
|
@@ -27,6 +27,16 @@ export default class Logs extends Command {
|
|
|
27
27
|
logPath: Flags.directory({
|
|
28
28
|
description: "Specify the directory containing log files",
|
|
29
29
|
required: false
|
|
30
|
+
}),
|
|
31
|
+
stdout: Flags.boolean({
|
|
32
|
+
description: "Show only stdout log entries",
|
|
33
|
+
default: false,
|
|
34
|
+
exclusive: ["stderr"]
|
|
35
|
+
}),
|
|
36
|
+
stderr: Flags.boolean({
|
|
37
|
+
description: "Show only stderr log entries (output of pkc-logger library)",
|
|
38
|
+
default: false,
|
|
39
|
+
exclusive: ["stdout"]
|
|
30
40
|
})
|
|
31
41
|
};
|
|
32
42
|
static examples = [
|
|
@@ -35,7 +45,10 @@ export default class Logs extends Command {
|
|
|
35
45
|
"bitsocial logs -n 50",
|
|
36
46
|
"bitsocial logs --since 5m",
|
|
37
47
|
"bitsocial logs --since 2026-01-02T13:23:37Z --until 2026-01-02T14:00:00Z",
|
|
38
|
-
"bitsocial logs --since 1h -f"
|
|
48
|
+
"bitsocial logs --since 1h -f",
|
|
49
|
+
"bitsocial logs --stdout",
|
|
50
|
+
"bitsocial logs --stderr",
|
|
51
|
+
"bitsocial logs --stdout -f"
|
|
39
52
|
];
|
|
40
53
|
async _findLatestLogFile(logPath) {
|
|
41
54
|
let entries;
|
|
@@ -76,6 +89,12 @@ export default class Logs extends Command {
|
|
|
76
89
|
return null;
|
|
77
90
|
return new Date(match[1]);
|
|
78
91
|
}
|
|
92
|
+
_extractStream(line) {
|
|
93
|
+
const match = line.match(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[(stdout|stderr)\] /);
|
|
94
|
+
if (!match)
|
|
95
|
+
return null;
|
|
96
|
+
return match[1];
|
|
97
|
+
}
|
|
79
98
|
_parseLogEntries(content) {
|
|
80
99
|
const lines = content.split("\n");
|
|
81
100
|
const entries = [];
|
|
@@ -83,7 +102,8 @@ export default class Logs extends Command {
|
|
|
83
102
|
const timestamp = this._extractTimestamp(line);
|
|
84
103
|
if (timestamp !== null) {
|
|
85
104
|
// New timestamped entry
|
|
86
|
-
|
|
105
|
+
const stream = this._extractStream(line);
|
|
106
|
+
entries.push({ timestamp, stream, lines: [line] });
|
|
87
107
|
}
|
|
88
108
|
else if (entries.length > 0) {
|
|
89
109
|
// Continuation line — belongs to the previous entry
|
|
@@ -91,7 +111,7 @@ export default class Logs extends Command {
|
|
|
91
111
|
}
|
|
92
112
|
else {
|
|
93
113
|
// Line before any timestamped entry (legacy/header)
|
|
94
|
-
entries.push({ timestamp: null, lines: [line] });
|
|
114
|
+
entries.push({ timestamp: null, stream: null, lines: [line] });
|
|
95
115
|
}
|
|
96
116
|
}
|
|
97
117
|
return entries;
|
|
@@ -109,6 +129,9 @@ export default class Logs extends Command {
|
|
|
109
129
|
return true;
|
|
110
130
|
});
|
|
111
131
|
}
|
|
132
|
+
_filterByStream(entries, stream) {
|
|
133
|
+
return entries.filter((entry) => entry.stream === stream);
|
|
134
|
+
}
|
|
112
135
|
_tailEntries(entries, tailValue) {
|
|
113
136
|
if (tailValue === "all")
|
|
114
137
|
return entries;
|
|
@@ -126,33 +149,37 @@ export default class Logs extends Command {
|
|
|
126
149
|
const latestLogFile = await this._findLatestLogFile(logPath);
|
|
127
150
|
const since = flags.since ? this._parseTimestamp(flags.since) : undefined;
|
|
128
151
|
const until = flags.until ? this._parseTimestamp(flags.until) : undefined;
|
|
152
|
+
const streamFilter = flags.stdout ? "stdout" : flags.stderr ? "stderr" : undefined;
|
|
129
153
|
if (!flags.follow) {
|
|
130
154
|
const content = await fsPromise.readFile(latestLogFile, "utf-8");
|
|
131
155
|
const entries = this._parseLogEntries(content);
|
|
132
156
|
const filtered = this._filterEntries(entries, since, until);
|
|
133
|
-
const
|
|
157
|
+
const streamFiltered = streamFilter ? this._filterByStream(filtered, streamFilter) : filtered;
|
|
158
|
+
const tailed = this._tailEntries(streamFiltered, flags.tail);
|
|
134
159
|
const output = tailed.map((e) => e.lines.join("\n")).join("\n");
|
|
135
160
|
if (output)
|
|
136
161
|
process.stdout.write(output + "\n");
|
|
137
162
|
return;
|
|
138
163
|
}
|
|
139
164
|
// Follow mode: dump existing content (filtered + tailed) then watch for new data
|
|
140
|
-
|
|
165
|
+
let currentLogFile = latestLogFile;
|
|
166
|
+
const existingContent = await fsPromise.readFile(currentLogFile, "utf-8");
|
|
141
167
|
const entries = this._parseLogEntries(existingContent);
|
|
142
168
|
const filtered = this._filterEntries(entries, since, until);
|
|
143
|
-
const
|
|
169
|
+
const streamFiltered = streamFilter ? this._filterByStream(filtered, streamFilter) : filtered;
|
|
170
|
+
const tailed = this._tailEntries(streamFiltered, flags.tail);
|
|
144
171
|
const initialOutput = tailed.map((e) => e.lines.join("\n")).join("\n");
|
|
145
172
|
if (initialOutput)
|
|
146
173
|
process.stdout.write(initialOutput + "\n");
|
|
147
|
-
const stat = await fsPromise.stat(
|
|
174
|
+
const stat = await fsPromise.stat(currentLogFile);
|
|
148
175
|
let position = stat.size;
|
|
149
176
|
let pendingBuffer = "";
|
|
150
177
|
// Watch for new data using polling (works across filesystems including Docker volumes)
|
|
151
178
|
const readNewData = async () => {
|
|
152
179
|
try {
|
|
153
|
-
const currentStat = await fsPromise.stat(
|
|
180
|
+
const currentStat = await fsPromise.stat(currentLogFile);
|
|
154
181
|
if (currentStat.size > position) {
|
|
155
|
-
const fd = await fsPromise.open(
|
|
182
|
+
const fd = await fsPromise.open(currentLogFile, "r");
|
|
156
183
|
const buf = new Uint8Array(currentStat.size - position);
|
|
157
184
|
const { bytesRead } = await fd.read(buf, 0, buf.length, position);
|
|
158
185
|
await fd.close();
|
|
@@ -166,14 +193,15 @@ export default class Logs extends Command {
|
|
|
166
193
|
}
|
|
167
194
|
pendingBuffer = chunk.slice(lastNewline + 1);
|
|
168
195
|
const completeText = chunk.slice(0, lastNewline + 1);
|
|
169
|
-
if (!since && !until) {
|
|
170
|
-
// No
|
|
196
|
+
if (!since && !until && !streamFilter) {
|
|
197
|
+
// No filtering — pass through directly
|
|
171
198
|
process.stdout.write(completeText);
|
|
172
199
|
}
|
|
173
200
|
else {
|
|
174
201
|
const newEntries = this._parseLogEntries(completeText.replace(/\n$/, ""));
|
|
175
202
|
const filteredNew = this._filterEntries(newEntries, since, until);
|
|
176
|
-
const
|
|
203
|
+
const streamFilteredNew = streamFilter ? this._filterByStream(filteredNew, streamFilter) : filteredNew;
|
|
204
|
+
const output = streamFilteredNew.map((e) => e.lines.join("\n")).join("\n");
|
|
177
205
|
if (output)
|
|
178
206
|
process.stdout.write(output + "\n");
|
|
179
207
|
}
|
|
@@ -183,14 +211,67 @@ export default class Logs extends Command {
|
|
|
183
211
|
// File may have been rotated or deleted
|
|
184
212
|
}
|
|
185
213
|
};
|
|
186
|
-
|
|
214
|
+
// Periodically check if a newer log file has appeared (e.g. after daemon restart)
|
|
215
|
+
const checkForNewLogFile = async () => {
|
|
216
|
+
try {
|
|
217
|
+
const newestFile = await this._findLatestLogFile(logPath);
|
|
218
|
+
if (newestFile === currentLogFile)
|
|
219
|
+
return;
|
|
220
|
+
// Flush any remaining partial line from old file
|
|
221
|
+
if (pendingBuffer) {
|
|
222
|
+
if (!since && !until && !streamFilter) {
|
|
223
|
+
process.stdout.write(pendingBuffer + "\n");
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const pbEntries = this._parseLogEntries(pendingBuffer);
|
|
227
|
+
const pbFiltered = this._filterEntries(pbEntries, since, until);
|
|
228
|
+
const pbStreamFiltered = streamFilter ? this._filterByStream(pbFiltered, streamFilter) : pbFiltered;
|
|
229
|
+
const pbOutput = pbStreamFiltered.map((e) => e.lines.join("\n")).join("\n");
|
|
230
|
+
if (pbOutput)
|
|
231
|
+
process.stdout.write(pbOutput + "\n");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Switch watchers
|
|
235
|
+
fs.unwatchFile(currentLogFile, readNewData);
|
|
236
|
+
currentLogFile = newestFile;
|
|
237
|
+
pendingBuffer = "";
|
|
238
|
+
process.stderr.write(`\n--- switched to new log file: ${path.basename(newestFile)} ---\n\n`);
|
|
239
|
+
// Read and output entire new file content (with filters, no tail limit)
|
|
240
|
+
const newContent = await fsPromise.readFile(currentLogFile, "utf-8");
|
|
241
|
+
if (newContent) {
|
|
242
|
+
if (!since && !until && !streamFilter) {
|
|
243
|
+
process.stdout.write(newContent);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const newEntries = this._parseLogEntries(newContent.replace(/\n$/, ""));
|
|
247
|
+
const filteredNew = this._filterEntries(newEntries, since, until);
|
|
248
|
+
const streamFilteredNew = streamFilter
|
|
249
|
+
? this._filterByStream(filteredNew, streamFilter)
|
|
250
|
+
: filteredNew;
|
|
251
|
+
const output = streamFilteredNew.map((e) => e.lines.join("\n")).join("\n");
|
|
252
|
+
if (output)
|
|
253
|
+
process.stdout.write(output + "\n");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const newStat = await fsPromise.stat(currentLogFile);
|
|
257
|
+
position = newStat.size;
|
|
258
|
+
fs.watchFile(currentLogFile, { interval: 300 }, readNewData);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Directory listing failed or file disappeared — retry next cycle
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
fs.watchFile(currentLogFile, { interval: 300 }, readNewData);
|
|
265
|
+
const newFileCheckInterval = setInterval(checkForNewLogFile, 3000);
|
|
187
266
|
// Keep the process alive and clean up on exit
|
|
188
267
|
process.on("SIGINT", () => {
|
|
189
|
-
|
|
268
|
+
clearInterval(newFileCheckInterval);
|
|
269
|
+
fs.unwatchFile(currentLogFile, readNewData);
|
|
190
270
|
process.exit(0);
|
|
191
271
|
});
|
|
192
272
|
process.on("SIGTERM", () => {
|
|
193
|
-
|
|
273
|
+
clearInterval(newFileCheckInterval);
|
|
274
|
+
fs.unwatchFile(currentLogFile, readNewData);
|
|
194
275
|
process.exit(0);
|
|
195
276
|
});
|
|
196
277
|
// Keep process alive
|
|
@@ -2,6 +2,7 @@ import { Args, Flags, Command } from "@oclif/core";
|
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
3
|
import tcpPortUsed from "tcp-port-used";
|
|
4
4
|
import { fetchLatestVersion, installGlobal } from "../../../update/npm-registry.js";
|
|
5
|
+
import { fastInstallGlobal } from "../../../update/fast-update.js";
|
|
5
6
|
import { compareVersions } from "../../../update/semver.js";
|
|
6
7
|
import { getAliveDaemonStates } from "../../../common-utils/daemon-state.js";
|
|
7
8
|
import PKC from "@pkcprotocol/pkc-js";
|
|
@@ -95,11 +96,25 @@ export default class Install extends Command {
|
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
this.log(`Installing bitsocial-cli@${targetVersion}...`);
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
let installed = false;
|
|
100
|
+
if (!flags.force) {
|
|
101
|
+
try {
|
|
102
|
+
installed = await fastInstallGlobal(targetVersion, this.config.root, (msg) => this.log(msg));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
installed = false;
|
|
106
|
+
}
|
|
100
107
|
}
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
if (!installed) {
|
|
109
|
+
if (!flags.force) {
|
|
110
|
+
this.log("Falling back to full install...");
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await installGlobal(targetVersion);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
this.error(`Update failed: ${err.message}`, { exit: 1 });
|
|
117
|
+
}
|
|
103
118
|
}
|
|
104
119
|
this.log(`Installed bitsocial v${targetVersion} (was v${current}).`);
|
|
105
120
|
// Restart daemons with the new binary
|
|
@@ -144,39 +159,60 @@ export default class Install extends Command {
|
|
|
144
159
|
pkc.on("error", (err) => {
|
|
145
160
|
errors.push(err);
|
|
146
161
|
});
|
|
147
|
-
await new Promise((resolve
|
|
162
|
+
await new Promise((resolve) => {
|
|
148
163
|
const timeout = setTimeout(() => {
|
|
149
|
-
|
|
150
|
-
reject(lastError ?? new Error(`Timed out waiting for RPC server at ${pkcRpcUrl} to respond`));
|
|
151
|
-
}, 20000);
|
|
152
|
-
pkc.once("communitieschange", () => {
|
|
153
|
-
clearTimeout(timeout);
|
|
164
|
+
pkc.removeListener("communitieschange", handler);
|
|
154
165
|
resolve();
|
|
155
|
-
});
|
|
166
|
+
}, 20000);
|
|
167
|
+
const handler = () => {
|
|
168
|
+
if (pkc.communities.length > 0) {
|
|
169
|
+
pkc.removeListener("communitieschange", handler);
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
resolve();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
pkc.on("communitieschange", handler);
|
|
156
175
|
});
|
|
157
176
|
return pkc;
|
|
158
177
|
}
|
|
159
178
|
async _reportCommunityStatus(pkcRpcUrl) {
|
|
179
|
+
const POLL_INTERVAL_MS = 2000;
|
|
180
|
+
const TIMEOUT_MS = 120_000;
|
|
160
181
|
let pkc;
|
|
161
182
|
try {
|
|
162
183
|
pkc = await this._connectToRpc(pkcRpcUrl);
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
184
|
+
const communityAddresses = pkc.communities;
|
|
185
|
+
if (communityAddresses.length === 0)
|
|
165
186
|
return;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}));
|
|
170
|
-
const startedCount = statuses.filter(Boolean).length;
|
|
187
|
+
// Create community objects once — they are RPC proxies whose
|
|
188
|
+
// .started property reflects live daemon state
|
|
189
|
+
const communities = await Promise.all(communityAddresses.map((address) => pkc.createCommunity({ address })));
|
|
171
190
|
const total = communities.length;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
191
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
192
|
+
let lastReportedCount = -1;
|
|
193
|
+
while (true) {
|
|
194
|
+
const startedCount = communities.filter((c) => c.started === true).length;
|
|
195
|
+
if (startedCount === total) {
|
|
196
|
+
this.log(` All ${total} ${total === 1 ? "community" : "communities"} started.`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Only print a progress update when the count changes
|
|
200
|
+
if (startedCount !== lastReportedCount) {
|
|
201
|
+
if (startedCount > 0) {
|
|
202
|
+
this.log(` ${startedCount} of ${total} communities started...`);
|
|
203
|
+
}
|
|
204
|
+
lastReportedCount = startedCount;
|
|
205
|
+
}
|
|
206
|
+
if (Date.now() >= deadline) {
|
|
207
|
+
if (startedCount > 0) {
|
|
208
|
+
this.log(` ${startedCount} of ${total} communities started (remaining still loading).`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
this.log(` ${total} ${total === 1 ? "community" : "communities"} in data path (still loading). Check with: bitsocial community list`);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
180
216
|
}
|
|
181
217
|
}
|
|
182
218
|
catch {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempt a fast update by downloading only the tarball and reusing node_modules/
|
|
3
|
+
* and dist/webuis/ from the existing install. Falls back (returns false) if
|
|
4
|
+
* dependencies changed or any step fails.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fastInstallGlobal(version: string, installRoot: string, log: (msg: string) => void): Promise<boolean>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { finished as streamFinished } from "node:stream/promises";
|
|
7
|
+
import decompress from "decompress";
|
|
8
|
+
import { runNpmPack, ensureNpmAvailable } from "../challenge-packages/challenge-utils.js";
|
|
9
|
+
import { PACKAGE_NAME } from "./npm-registry.js";
|
|
10
|
+
/**
|
|
11
|
+
* Attempt a fast update by downloading only the tarball and reusing node_modules/
|
|
12
|
+
* and dist/webuis/ from the existing install. Falls back (returns false) if
|
|
13
|
+
* dependencies changed or any step fails.
|
|
14
|
+
*/
|
|
15
|
+
export async function fastInstallGlobal(version, installRoot, log) {
|
|
16
|
+
const stagingDir = installRoot + ".__fast_update_staging";
|
|
17
|
+
const backupDir = installRoot + ".__fast_update_backup";
|
|
18
|
+
// Phase: cleanup leftovers from any interrupted previous fast-update
|
|
19
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
20
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
21
|
+
let nodeModulesMoved = false;
|
|
22
|
+
try {
|
|
23
|
+
// Phase: download tarball
|
|
24
|
+
await ensureNpmAvailable();
|
|
25
|
+
await fs.mkdir(stagingDir, { recursive: true });
|
|
26
|
+
log("Trying fast update...");
|
|
27
|
+
const tgzPath = await runNpmPack(`${PACKAGE_NAME}@${version}`, stagingDir);
|
|
28
|
+
// Phase: extract (strip: 1 removes the "package/" prefix in npm tarballs)
|
|
29
|
+
await decompress(tgzPath, stagingDir, { strip: 1 });
|
|
30
|
+
await fs.rm(tgzPath);
|
|
31
|
+
// Phase: compare dependencies
|
|
32
|
+
const oldPkg = JSON.parse(await fs.readFile(path.join(installRoot, "package.json"), "utf-8"));
|
|
33
|
+
const newPkg = JSON.parse(await fs.readFile(path.join(stagingDir, "package.json"), "utf-8"));
|
|
34
|
+
if (JSON.stringify(oldPkg.dependencies) !== JSON.stringify(newPkg.dependencies)) {
|
|
35
|
+
log("Dependencies changed, falling back to full install.");
|
|
36
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
// Phase: reuse node_modules from old install (O(1) rename, same filesystem)
|
|
40
|
+
try {
|
|
41
|
+
await fs.rename(path.join(installRoot, "node_modules"), path.join(stagingDir, "node_modules"));
|
|
42
|
+
nodeModulesMoved = true;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err.code === "EXDEV") {
|
|
46
|
+
log("Cross-device rename, falling back to full install.");
|
|
47
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
// Phase: reuse unchanged webuis, download only changed ones
|
|
53
|
+
const oldWebuis = oldPkg.webuis ?? [];
|
|
54
|
+
const newWebuis = newPkg.webuis ?? [];
|
|
55
|
+
const oldWebuisByUrl = new Map(oldWebuis.map((w) => [w.url, w.sha256OfHtmlZip]));
|
|
56
|
+
// Move the entire dist/webuis/ directory from old install to staging
|
|
57
|
+
const oldWebuisDir = path.join(installRoot, "dist", "webuis");
|
|
58
|
+
const newWebuisDir = path.join(stagingDir, "dist", "webuis");
|
|
59
|
+
let webuisDirMoved = false;
|
|
60
|
+
try {
|
|
61
|
+
await fs.access(oldWebuisDir);
|
|
62
|
+
await fs.rename(oldWebuisDir, newWebuisDir);
|
|
63
|
+
webuisDirMoved = true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// webuis dir missing in old install — will create fresh
|
|
67
|
+
}
|
|
68
|
+
// Identify which webui entries changed
|
|
69
|
+
const changedWebuis = newWebuis.filter((w) => oldWebuisByUrl.get(w.url) !== w.sha256OfHtmlZip);
|
|
70
|
+
if (changedWebuis.length > 0 && webuisDirMoved) {
|
|
71
|
+
// Delete subdirectories for changed webuis so they get re-downloaded.
|
|
72
|
+
// We don't know exact dir names, but we can match by repo name from the URL.
|
|
73
|
+
const existingDirs = await fs.readdir(newWebuisDir, { withFileTypes: true })
|
|
74
|
+
.catch(() => []);
|
|
75
|
+
for (const changed of changedWebuis) {
|
|
76
|
+
const repoName = extractRepoName(changed.url);
|
|
77
|
+
if (!repoName)
|
|
78
|
+
continue;
|
|
79
|
+
for (const entry of existingDirs) {
|
|
80
|
+
if (entry.isDirectory() && entry.name.toLowerCase().includes(repoName.toLowerCase())) {
|
|
81
|
+
await fs.rm(path.join(newWebuisDir, entry.name), { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Phase: atomic swap
|
|
87
|
+
await fs.rename(installRoot, backupDir);
|
|
88
|
+
await fs.rename(stagingDir, installRoot);
|
|
89
|
+
nodeModulesMoved = false; // now part of installRoot, no rollback needed
|
|
90
|
+
// Phase: download changed/missing webuis
|
|
91
|
+
if (changedWebuis.length > 0 || !webuisDirMoved) {
|
|
92
|
+
const webuisDir = path.join(installRoot, "dist", "webuis");
|
|
93
|
+
await fs.mkdir(webuisDir, { recursive: true });
|
|
94
|
+
const toDownload = !webuisDirMoved ? newWebuis : changedWebuis;
|
|
95
|
+
if (toDownload.length > 0) {
|
|
96
|
+
log(`Downloading ${toDownload.length} changed web UI(s)...`);
|
|
97
|
+
for (const entry of toDownload) {
|
|
98
|
+
try {
|
|
99
|
+
await downloadWebui(entry, webuisDir, log);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
log(`Warning: failed to download ${entry.url}: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Phase: cleanup backup
|
|
108
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Rollback: restore node_modules if we moved it out
|
|
113
|
+
if (nodeModulesMoved) {
|
|
114
|
+
try {
|
|
115
|
+
await fs.rename(path.join(stagingDir, "node_modules"), path.join(installRoot, "node_modules"));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// best-effort
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// If installRoot was renamed to backup but staging didn't land, restore it
|
|
122
|
+
try {
|
|
123
|
+
await fs.access(installRoot);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
try {
|
|
127
|
+
await fs.rename(backupDir, installRoot);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// best-effort
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
134
|
+
log(`Fast update failed: ${err.message}`);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Extract repo name from a GitHub release URL (e.g. "seedit" from ".../plebbit/seedit/releases/tag/v0.5.10") */
|
|
139
|
+
function extractRepoName(url) {
|
|
140
|
+
const match = url.match(/github\.com\/[^/]+\/([^/]+)\/releases\/tag\//);
|
|
141
|
+
return match?.[1];
|
|
142
|
+
}
|
|
143
|
+
/** Download a single webui entry — mirrors the logic in bin/postinstall.js */
|
|
144
|
+
async function downloadWebui(entry, webuisDir, log) {
|
|
145
|
+
const match = entry.url.match(/github\.com\/([^/]+\/[^/]+)\/releases\/tag\/(.+)$/);
|
|
146
|
+
if (!match)
|
|
147
|
+
throw new Error(`Could not parse GitHub release URL: ${entry.url}`);
|
|
148
|
+
const [, ownerRepo, tag] = match;
|
|
149
|
+
const githubToken = process.env["GITHUB_TOKEN"];
|
|
150
|
+
const headers = {};
|
|
151
|
+
if (githubToken)
|
|
152
|
+
headers["authorization"] = `Bearer ${githubToken}`;
|
|
153
|
+
const releaseReq = await fetch(`https://api.github.com/repos/${ownerRepo}/releases/tags/${tag}`, { headers });
|
|
154
|
+
if (!releaseReq.ok)
|
|
155
|
+
throw new Error(`Failed to fetch release ${ownerRepo}@${tag}, status ${releaseReq.status}`);
|
|
156
|
+
const release = await releaseReq.json();
|
|
157
|
+
const htmlZipAsset = release.assets.find((asset) => asset.name.includes("html"));
|
|
158
|
+
if (!htmlZipAsset)
|
|
159
|
+
throw new Error(`No HTML zip asset in ${ownerRepo}@${tag}`);
|
|
160
|
+
const zipfilePath = path.join(webuisDir, htmlZipAsset.name);
|
|
161
|
+
const downloadReq = await fetch(htmlZipAsset["browser_download_url"], { headers });
|
|
162
|
+
if (!downloadReq.ok || !downloadReq.body)
|
|
163
|
+
throw new Error(`Failed to download ${htmlZipAsset.name}, status ${downloadReq.status}`);
|
|
164
|
+
const writer = createWriteStream(zipfilePath);
|
|
165
|
+
await streamFinished(Readable.fromWeb(downloadReq.body).pipe(writer));
|
|
166
|
+
writer.close();
|
|
167
|
+
// Verify SHA-256 checksum
|
|
168
|
+
const fileBuffer = await fs.readFile(zipfilePath);
|
|
169
|
+
const actualHash = createHash("sha256").update(fileBuffer).digest("hex");
|
|
170
|
+
if (actualHash !== entry.sha256OfHtmlZip) {
|
|
171
|
+
await fs.rm(zipfilePath);
|
|
172
|
+
throw new Error(`SHA-256 mismatch for ${htmlZipAsset.name}! Expected: ${entry.sha256OfHtmlZip}, Actual: ${actualHash}`);
|
|
173
|
+
}
|
|
174
|
+
await decompress(zipfilePath, webuisDir);
|
|
175
|
+
await fs.rm(zipfilePath);
|
|
176
|
+
// Rename index.html to prevent access to unconfigured version
|
|
177
|
+
const extractedDirName = htmlZipAsset.name.replace(".zip", "");
|
|
178
|
+
const indexPath = path.join(webuisDir, extractedDirName, "index.html");
|
|
179
|
+
const backupPath = path.join(webuisDir, extractedDirName, "index_backup_no_rpc.html");
|
|
180
|
+
await fs.rename(indexPath, backupPath);
|
|
181
|
+
log(`Downloaded ${ownerRepo}@${tag}`);
|
|
182
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { getNpmCliPath, getNpmEnv, ensureNpmAvailable } from "../challenge-packages/challenge-utils.js";
|
|
3
|
-
const PACKAGE_NAME = "@bitsocial/bitsocial-cli";
|
|
3
|
+
export const PACKAGE_NAME = "@bitsocial/bitsocial-cli";
|
|
4
4
|
function runNpmView(args) {
|
|
5
5
|
return new Promise(async (resolve, reject) => {
|
|
6
6
|
const npmCliPath = await getNpmCliPath();
|
package/dist/util.d.ts
CHANGED
|
@@ -25,8 +25,13 @@ export declare function getLanIpV4Address(): string | undefined;
|
|
|
25
25
|
export declare function loadKuboConfigFile(pkcDataPath: string): Promise<any | undefined>;
|
|
26
26
|
export declare function parseMultiAddrKuboRpcToUrl(kuboMultiAddrString: string): Promise<import("url").URL>;
|
|
27
27
|
export declare function parseMultiAddrIpfsGatewayToUrl(ipfsGatewaymultiAddrString: string): Promise<import("url").URL>;
|
|
28
|
+
/** Recursively replaces all `null` values with `undefined`.
|
|
29
|
+
* Used before calling community.edit() since pkc-js expects `undefined` for removal,
|
|
30
|
+
* but JSON/CLI input produces `null`. */
|
|
31
|
+
export declare function replaceNullWithUndefined(obj: any): any;
|
|
28
32
|
/**
|
|
29
33
|
* Custom merge function that implements CLI-specific merge behavior.
|
|
30
34
|
* This matches the expected behavior from the test suite.
|
|
31
35
|
*/
|
|
32
|
-
export declare function mergeDeep(target: any, source: any): any;
|
|
36
|
+
export declare function mergeDeep(target: any, source: any, arrayStrategy?: "concat" | "replace"): any;
|
|
37
|
+
export declare function parseJsoncFile(filePath: string): Promise<Record<string, unknown>>;
|
package/dist/util.js
CHANGED
|
@@ -2,6 +2,7 @@ import os from "os";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import * as fsPromises from "fs/promises";
|
|
5
|
+
import stripJsonComments from "strip-json-comments";
|
|
5
6
|
import PKCLogger from "@pkcprotocol/pkc-logger";
|
|
6
7
|
export { PKCLogger };
|
|
7
8
|
/**
|
|
@@ -75,11 +76,28 @@ export async function parseMultiAddrIpfsGatewayToUrl(ipfsGatewaymultiAddrString)
|
|
|
75
76
|
throw new Error(`Unable to parse IPFS gateway multiaddr: ${ipfsGatewaymultiAddrString}`);
|
|
76
77
|
return new URL(`http://${parsed.host}:${parsed.port}`);
|
|
77
78
|
}
|
|
79
|
+
/** Recursively replaces all `null` values with `undefined`.
|
|
80
|
+
* Used before calling community.edit() since pkc-js expects `undefined` for removal,
|
|
81
|
+
* but JSON/CLI input produces `null`. */
|
|
82
|
+
export function replaceNullWithUndefined(obj) {
|
|
83
|
+
if (obj === null)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (Array.isArray(obj))
|
|
86
|
+
return obj.map(replaceNullWithUndefined);
|
|
87
|
+
if (typeof obj === "object" && obj.constructor === Object) {
|
|
88
|
+
const result = {};
|
|
89
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
90
|
+
result[key] = replaceNullWithUndefined(value);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
return obj;
|
|
95
|
+
}
|
|
78
96
|
/**
|
|
79
97
|
* Custom merge function that implements CLI-specific merge behavior.
|
|
80
98
|
* This matches the expected behavior from the test suite.
|
|
81
99
|
*/
|
|
82
|
-
export function mergeDeep(target, source) {
|
|
100
|
+
export function mergeDeep(target, source, arrayStrategy = "concat") {
|
|
83
101
|
function isObject(item) {
|
|
84
102
|
return item && typeof item === "object" && !Array.isArray(item);
|
|
85
103
|
}
|
|
@@ -88,6 +106,10 @@ export function mergeDeep(target, source) {
|
|
|
88
106
|
}
|
|
89
107
|
// Handle arrays with CLI-specific behavior
|
|
90
108
|
if (Array.isArray(target) && Array.isArray(source)) {
|
|
109
|
+
// RFC 7396 JSON Merge Patch: arrays are replaced entirely
|
|
110
|
+
if (arrayStrategy === "replace") {
|
|
111
|
+
return source;
|
|
112
|
+
}
|
|
91
113
|
// Check if source is sparse (has holes/empty items) - indicates indexed assignment like --rules[2]
|
|
92
114
|
const sourceHasHoles = source.length !== Object.keys(source).length;
|
|
93
115
|
if (sourceHasHoles) {
|
|
@@ -97,7 +119,7 @@ export function mergeDeep(target, source) {
|
|
|
97
119
|
for (let i = 0; i < maxLength; i++) {
|
|
98
120
|
if (i in source) {
|
|
99
121
|
if (i in target && isPlainObject(target[i]) && isPlainObject(source[i])) {
|
|
100
|
-
result[i] = mergeDeep(target[i], source[i]);
|
|
122
|
+
result[i] = mergeDeep(target[i], source[i], arrayStrategy);
|
|
101
123
|
}
|
|
102
124
|
else {
|
|
103
125
|
result[i] = source[i];
|
|
@@ -138,10 +160,10 @@ export function mergeDeep(target, source) {
|
|
|
138
160
|
for (const key in source) {
|
|
139
161
|
if (source.hasOwnProperty(key)) {
|
|
140
162
|
if (Array.isArray(target[key]) && Array.isArray(source[key])) {
|
|
141
|
-
result[key] = mergeDeep(target[key], source[key]);
|
|
163
|
+
result[key] = mergeDeep(target[key], source[key], arrayStrategy);
|
|
142
164
|
}
|
|
143
165
|
else if (isPlainObject(target[key]) && isPlainObject(source[key])) {
|
|
144
|
-
result[key] = mergeDeep(target[key], source[key]);
|
|
166
|
+
result[key] = mergeDeep(target[key], source[key], arrayStrategy);
|
|
145
167
|
}
|
|
146
168
|
else {
|
|
147
169
|
result[key] = source[key];
|
|
@@ -153,3 +175,21 @@ export function mergeDeep(target, source) {
|
|
|
153
175
|
// If not both objects/arrays, source takes precedence
|
|
154
176
|
return source;
|
|
155
177
|
}
|
|
178
|
+
export async function parseJsoncFile(filePath) {
|
|
179
|
+
const fileContent = await fsPromises.readFile(filePath, "utf-8");
|
|
180
|
+
const stripped = stripJsonComments(fileContent);
|
|
181
|
+
let parsed;
|
|
182
|
+
try {
|
|
183
|
+
parsed = JSON.parse(stripped);
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
if (e instanceof SyntaxError) {
|
|
187
|
+
throw new Error(`Invalid JSONC in file ${filePath}: ${e.message}`);
|
|
188
|
+
}
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
192
|
+
throw new Error("JSONC file must contain a JSON object (not an array, null, string, or number)");
|
|
193
|
+
}
|
|
194
|
+
return parsed;
|
|
195
|
+
}
|