Skip to content

Commit 6001517

Browse files
committed
feat: report community status after daemon restart in update install
After restarting daemons, connect to each daemon's RPC and check how many communities are started, providing user feedback about auto-start progress. Ref #26
1 parent d61a225 commit 6001517

File tree

2 files changed

+271
-0
lines changed

2 files changed

+271
-0
lines changed

src/cli/commands/update/install.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import tcpPortUsed from "tcp-port-used";
44
import { fetchLatestVersion, installGlobal } from "../../../update/npm-registry.js";
55
import { compareVersions } from "../../../update/semver.js";
66
import { getAliveDaemonStates, type DaemonState } from "../../../common-utils/daemon-state.js";
7+
import PKC from "@pkcprotocol/pkc-js";
8+
type PKCInstance = Awaited<ReturnType<typeof PKC>>;
9+
type PKCConnectOverride = (pkcRpcUrl: string) => Promise<PKCInstance>;
10+
11+
const getPKCConnectOverride = (): PKCConnectOverride | undefined => {
12+
const globalWithOverride = globalThis as { __PKC_RPC_CONNECT_OVERRIDE?: PKCConnectOverride };
13+
return globalWithOverride.__PKC_RPC_CONNECT_OVERRIDE;
14+
};
715

816
export default class Install extends Command {
917
static override description = "Install a specific version of bitsocial from npm";
@@ -142,9 +150,63 @@ export default class Install extends Command {
142150
const started = await tcpPortUsed.waitUntilUsed(port, 500, 30000).then(() => true).catch(() => false);
143151
if (started) {
144152
this.log(` Daemon started (port ${port}).`);
153+
await this._reportCommunityStatus(d.pkcRpcUrl);
145154
} else {
146155
this.warn(` Daemon may not have started — port ${port} not responding after 30s. Check logs with: bitsocial logs`);
147156
}
148157
}
149158
}
159+
160+
private async _connectToRpc(pkcRpcUrl: string): Promise<PKCInstance> {
161+
const connectOverride = getPKCConnectOverride();
162+
if (connectOverride) {
163+
return connectOverride(pkcRpcUrl);
164+
}
165+
const pkc = await PKC({ pkcRpcClientsOptions: [pkcRpcUrl] });
166+
const errors: Error[] = [];
167+
pkc.on("error", (err: Error) => {
168+
errors.push(err);
169+
});
170+
await new Promise<void>((resolve, reject) => {
171+
const timeout = setTimeout(() => {
172+
const lastError = errors[errors.length - 1];
173+
reject(lastError ?? new Error(`Timed out waiting for RPC server at ${pkcRpcUrl} to respond`));
174+
}, 20000);
175+
pkc.once("communitieschange", () => {
176+
clearTimeout(timeout);
177+
resolve();
178+
});
179+
});
180+
return pkc;
181+
}
182+
183+
private async _reportCommunityStatus(pkcRpcUrl: string): Promise<void> {
184+
let pkc: PKCInstance | undefined;
185+
try {
186+
pkc = await this._connectToRpc(pkcRpcUrl);
187+
const communities: string[] = pkc.communities;
188+
if (communities.length === 0) return;
189+
190+
const statuses = await Promise.all(
191+
communities.map(async (address: string) => {
192+
const community = await pkc!.createCommunity({ address });
193+
return community.started as boolean;
194+
})
195+
);
196+
const startedCount = statuses.filter(Boolean).length;
197+
const total = communities.length;
198+
199+
if (startedCount === total) {
200+
this.log(` ${startedCount} ${startedCount === 1 ? "community" : "communities"} started.`);
201+
} else if (startedCount > 0) {
202+
this.log(` ${startedCount} of ${total} communities started (remaining still loading).`);
203+
} else {
204+
this.log(` ${total} ${total === 1 ? "community" : "communities"} in data path (still loading). Check with: bitsocial community list`);
205+
}
206+
} catch {
207+
this.warn("Could not check community status.");
208+
} finally {
209+
if (pkc) await pkc.destroy().catch(() => {});
210+
}
211+
}
150212
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import EventEmitter from "events";
3+
4+
vi.mock("@pkcprotocol/pkc-js", () => ({
5+
default: vi.fn()
6+
}));
7+
8+
vi.mock("tcp-port-used", () => ({
9+
default: {
10+
waitUntilFree: vi.fn().mockResolvedValue(undefined),
11+
waitUntilUsed: vi.fn().mockResolvedValue(undefined)
12+
}
13+
}));
14+
15+
vi.mock("child_process", async () => {
16+
const actual = await vi.importActual<typeof import("child_process")>("child_process");
17+
return {
18+
...actual,
19+
spawn: vi.fn(() => ({ pid: 99999, unref: vi.fn() }))
20+
};
21+
});
22+
23+
vi.mock("../../src/common-utils/daemon-state.js", () => ({
24+
getAliveDaemonStates: vi.fn().mockResolvedValue([])
25+
}));
26+
27+
vi.mock("../../src/update/npm-registry.js", () => ({
28+
fetchLatestVersion: vi.fn().mockResolvedValue("99.99.99"),
29+
installGlobal: vi.fn().mockResolvedValue(undefined)
30+
}));
31+
32+
vi.mock("../../src/update/semver.js", () => ({
33+
compareVersions: vi.fn().mockReturnValue(-1)
34+
}));
35+
36+
describe("update install — community status reporting", () => {
37+
let logOutput: string[];
38+
let warnOutput: string[];
39+
40+
beforeEach(() => {
41+
logOutput = [];
42+
warnOutput = [];
43+
});
44+
45+
afterEach(() => {
46+
vi.restoreAllMocks();
47+
});
48+
49+
async function createInstallCommand() {
50+
const mod = await import("../../src/cli/commands/update/install.js");
51+
const Install = mod.default;
52+
const cmd = new Install([], { version: "0.0.1" } as any);
53+
cmd.log = (...args: any[]) => logOutput.push(args.join(" "));
54+
cmd.warn = (...args: any[]) => warnOutput.push(args.join(" "));
55+
cmd.parse = vi.fn().mockResolvedValue({
56+
args: { version: "latest" },
57+
flags: { force: false, "restart-daemons": true }
58+
});
59+
cmd.error = vi.fn((msg: string) => {
60+
throw new Error(msg);
61+
}) as any;
62+
return cmd;
63+
}
64+
65+
it("prints started count when all communities are started", async () => {
66+
const { getAliveDaemonStates } = await import("../../src/common-utils/daemon-state.js");
67+
vi.mocked(getAliveDaemonStates).mockResolvedValue([
68+
{ pid: 12345, startedAt: "2026-01-01", argv: [], pkcRpcUrl: "ws://localhost:39123" }
69+
]);
70+
71+
const { default: PKCMock } = await import("@pkcprotocol/pkc-js");
72+
const fakePkc = Object.assign(new EventEmitter(), {
73+
communities: ["community1.bso", "community2.bso"],
74+
createCommunity: vi.fn().mockResolvedValue({ started: true }),
75+
destroy: vi.fn().mockResolvedValue(undefined)
76+
});
77+
vi.mocked(PKCMock).mockImplementation(async () => {
78+
// Auto-emit communitieschange after a tick
79+
setTimeout(() => fakePkc.emit("communitieschange"), 0);
80+
return fakePkc as any;
81+
});
82+
83+
const cmd = await createInstallCommand();
84+
await cmd.run();
85+
86+
const joined = logOutput.join("\n");
87+
expect(joined).toContain("2 communities started");
88+
});
89+
90+
it("prints partial count when some communities are still loading", async () => {
91+
const { getAliveDaemonStates } = await import("../../src/common-utils/daemon-state.js");
92+
vi.mocked(getAliveDaemonStates).mockResolvedValue([
93+
{ pid: 12345, startedAt: "2026-01-01", argv: [], pkcRpcUrl: "ws://localhost:39123" }
94+
]);
95+
96+
const { default: PKCMock } = await import("@pkcprotocol/pkc-js");
97+
let callCount = 0;
98+
const fakePkc = Object.assign(new EventEmitter(), {
99+
communities: ["community1.bso", "community2.bso", "community3.bso"],
100+
createCommunity: vi.fn().mockImplementation(async () => {
101+
callCount++;
102+
return { started: callCount <= 1 };
103+
}),
104+
destroy: vi.fn().mockResolvedValue(undefined)
105+
});
106+
vi.mocked(PKCMock).mockImplementation(async () => {
107+
setTimeout(() => fakePkc.emit("communitieschange"), 0);
108+
return fakePkc as any;
109+
});
110+
111+
const cmd = await createInstallCommand();
112+
await cmd.run();
113+
114+
const joined = logOutput.join("\n");
115+
expect(joined).toContain("1 of 3 communities started (remaining still loading)");
116+
});
117+
118+
it("prints still loading message when no communities are started yet", async () => {
119+
const { getAliveDaemonStates } = await import("../../src/common-utils/daemon-state.js");
120+
vi.mocked(getAliveDaemonStates).mockResolvedValue([
121+
{ pid: 12345, startedAt: "2026-01-01", argv: [], pkcRpcUrl: "ws://localhost:39123" }
122+
]);
123+
124+
const { default: PKCMock } = await import("@pkcprotocol/pkc-js");
125+
const fakePkc = Object.assign(new EventEmitter(), {
126+
communities: ["community1.bso", "community2.bso"],
127+
createCommunity: vi.fn().mockResolvedValue({ started: false }),
128+
destroy: vi.fn().mockResolvedValue(undefined)
129+
});
130+
vi.mocked(PKCMock).mockImplementation(async () => {
131+
setTimeout(() => fakePkc.emit("communitieschange"), 0);
132+
return fakePkc as any;
133+
});
134+
135+
const cmd = await createInstallCommand();
136+
await cmd.run();
137+
138+
const joined = logOutput.join("\n");
139+
expect(joined).toContain("2 communities in data path (still loading)");
140+
expect(joined).toContain("bitsocial community list");
141+
});
142+
143+
it("prints nothing when there are no communities", async () => {
144+
const { getAliveDaemonStates } = await import("../../src/common-utils/daemon-state.js");
145+
vi.mocked(getAliveDaemonStates).mockResolvedValue([
146+
{ pid: 12345, startedAt: "2026-01-01", argv: [], pkcRpcUrl: "ws://localhost:39123" }
147+
]);
148+
149+
const { default: PKCMock } = await import("@pkcprotocol/pkc-js");
150+
const fakePkc = Object.assign(new EventEmitter(), {
151+
communities: [],
152+
createCommunity: vi.fn(),
153+
destroy: vi.fn().mockResolvedValue(undefined)
154+
});
155+
vi.mocked(PKCMock).mockImplementation(async () => {
156+
setTimeout(() => fakePkc.emit("communitieschange"), 0);
157+
return fakePkc as any;
158+
});
159+
160+
const cmd = await createInstallCommand();
161+
await cmd.run();
162+
163+
const joined = logOutput.join("\n");
164+
expect(joined).not.toContain("communities");
165+
expect(joined).not.toContain("community");
166+
});
167+
168+
it("warns but does not crash when RPC connection fails", async () => {
169+
const { getAliveDaemonStates } = await import("../../src/common-utils/daemon-state.js");
170+
vi.mocked(getAliveDaemonStates).mockResolvedValue([
171+
{ pid: 12345, startedAt: "2026-01-01", argv: [], pkcRpcUrl: "ws://localhost:39123" }
172+
]);
173+
174+
const { default: PKCMock } = await import("@pkcprotocol/pkc-js");
175+
vi.mocked(PKCMock).mockRejectedValue(new Error("Connection refused"));
176+
177+
const cmd = await createInstallCommand();
178+
// Should not throw
179+
await cmd.run();
180+
181+
const joinedWarns = warnOutput.join("\n");
182+
expect(joinedWarns).toContain("Could not check community status");
183+
});
184+
185+
it("prints singular 'community' for a single community", async () => {
186+
const { getAliveDaemonStates } = await import("../../src/common-utils/daemon-state.js");
187+
vi.mocked(getAliveDaemonStates).mockResolvedValue([
188+
{ pid: 12345, startedAt: "2026-01-01", argv: [], pkcRpcUrl: "ws://localhost:39123" }
189+
]);
190+
191+
const { default: PKCMock } = await import("@pkcprotocol/pkc-js");
192+
const fakePkc = Object.assign(new EventEmitter(), {
193+
communities: ["community1.bso"],
194+
createCommunity: vi.fn().mockResolvedValue({ started: true }),
195+
destroy: vi.fn().mockResolvedValue(undefined)
196+
});
197+
vi.mocked(PKCMock).mockImplementation(async () => {
198+
setTimeout(() => fakePkc.emit("communitieschange"), 0);
199+
return fakePkc as any;
200+
});
201+
202+
const cmd = await createInstallCommand();
203+
await cmd.run();
204+
205+
const joined = logOutput.join("\n");
206+
expect(joined).toContain("1 community started");
207+
expect(joined).not.toContain("communities started");
208+
});
209+
});

0 commit comments

Comments
 (0)