@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.
@@ -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
- entries.push({ timestamp, lines: [line] });
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 tailed = this._tailEntries(filtered, flags.tail);
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
- const existingContent = await fsPromise.readFile(latestLogFile, "utf-8");
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 tailed = this._tailEntries(filtered, flags.tail);
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(latestLogFile);
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(latestLogFile);
180
+ const currentStat = await fsPromise.stat(currentLogFile);
154
181
  if (currentStat.size > position) {
155
- const fd = await fsPromise.open(latestLogFile, "r");
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 time filtering — pass through directly
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 output = filteredNew.map((e) => e.lines.join("\n")).join("\n");
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
- fs.watchFile(latestLogFile, { interval: 300 }, readNewData);
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
- fs.unwatchFile(latestLogFile, readNewData);
268
+ clearInterval(newFileCheckInterval);
269
+ fs.unwatchFile(currentLogFile, readNewData);
190
270
  process.exit(0);
191
271
  });
192
272
  process.on("SIGTERM", () => {
193
- fs.unwatchFile(latestLogFile, readNewData);
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
- try {
99
- await installGlobal(targetVersion);
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
- catch (err) {
102
- this.error(`Update failed: ${err.message}`, { exit: 1 });
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, reject) => {
162
+ await new Promise((resolve) => {
148
163
  const timeout = setTimeout(() => {
149
- const lastError = errors[errors.length - 1];
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 communities = pkc.communities;
164
- if (communities.length === 0)
184
+ const communityAddresses = pkc.communities;
185
+ if (communityAddresses.length === 0)
165
186
  return;
166
- const statuses = await Promise.all(communities.map(async (address) => {
167
- const community = await pkc.createCommunity({ address });
168
- return community.started;
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
- if (startedCount === total) {
173
- this.log(` ${startedCount} ${startedCount === 1 ? "community" : "communities"} started.`);
174
- }
175
- else if (startedCount > 0) {
176
- this.log(` ${startedCount} of ${total} communities started (remaining still loading).`);
177
- }
178
- else {
179
- this.log(` ${total} ${total === 1 ? "community" : "communities"} in data path (still loading). Check with: bitsocial community list`);
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,3 +1,4 @@
1
+ export declare const PACKAGE_NAME = "@bitsocial/bitsocial-cli";
1
2
  /** Query npm registry for the latest published version. */
2
3
  export declare function fetchLatestVersion(): Promise<string>;
3
4
  /** Query npm registry for all published versions (oldest-first). */
@@ -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
+ }