@serfab/cadre-host 0.13.0 → 1.1.0

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.
Files changed (62) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/host.js +9 -1
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/__tests__/fake-orchestrator.js +1 -1
  5. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -1
  6. package/dist/donation/donation-service.d.ts +7 -2
  7. package/dist/donation/donation-service.js +16 -2
  8. package/dist/donation/donation-service.js.map +1 -1
  9. package/dist/donation/donation-supervisor.d.ts +12 -2
  10. package/dist/donation/donation-supervisor.js +25 -2
  11. package/dist/donation/donation-supervisor.js.map +1 -1
  12. package/dist/donation/types.d.ts +2 -1
  13. package/dist/donation/types.js.map +1 -1
  14. package/dist/installer/index.d.ts +9 -1
  15. package/dist/installer/index.js +5 -1
  16. package/dist/installer/index.js.map +1 -1
  17. package/dist/orchestrator/host-process-orchestrator.d.ts +71 -11
  18. package/dist/orchestrator/host-process-orchestrator.js +173 -80
  19. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  20. package/dist/orchestrator/port-allocator.d.ts +44 -2
  21. package/dist/orchestrator/port-allocator.js +63 -5
  22. package/dist/orchestrator/port-allocator.js.map +1 -1
  23. package/dist/orchestrator/port-probe.d.ts +15 -0
  24. package/dist/orchestrator/port-probe.js +22 -0
  25. package/dist/orchestrator/port-probe.js.map +1 -0
  26. package/dist/orchestrator/types.d.ts +13 -1
  27. package/dist/orchestrator/types.js.map +1 -1
  28. package/dist/server/routes/bootstrap-node-validation.d.ts +30 -11
  29. package/dist/server/routes/bootstrap-node-validation.js +32 -13
  30. package/dist/server/routes/bootstrap-node-validation.js.map +1 -1
  31. package/dist/server/server.d.ts +4 -1
  32. package/dist/server/server.js +36 -2
  33. package/dist/server/server.js.map +1 -1
  34. package/dist/ui/assets/{index-C-iks41M.js → index-CEoNRwM_.js} +3 -3
  35. package/dist/ui/assets/{index-C-iks41M.js.map → index-CEoNRwM_.js.map} +1 -1
  36. package/dist/ui/index.html +1 -1
  37. package/package.json +4 -4
  38. package/src/__tests__/orchestrator-env-scrub.test.ts +22 -0
  39. package/src/__tests__/orchestrator-node-identity.test.ts +4 -4
  40. package/src/__tests__/orchestrator-owner.test.ts +45 -2
  41. package/src/__tests__/orchestrator-ports.test.ts +172 -0
  42. package/src/__tests__/orchestrator.test.ts +190 -79
  43. package/src/bin/host.ts +9 -1
  44. package/src/donation/__tests__/donation-service.test.ts +1155 -1068
  45. package/src/donation/__tests__/donation-supervisor.test.ts +548 -510
  46. package/src/donation/__tests__/fake-orchestrator.ts +1 -1
  47. package/src/donation/donation-service.ts +22 -3
  48. package/src/donation/donation-supervisor.ts +25 -2
  49. package/src/donation/types.ts +2 -1
  50. package/src/installer/__tests__/installer.smoke.test.ts +263 -218
  51. package/src/installer/index.ts +327 -314
  52. package/src/orchestrator/host-process-orchestrator.ts +1250 -1143
  53. package/src/orchestrator/port-allocator.ts +148 -91
  54. package/src/orchestrator/port-probe.ts +31 -0
  55. package/src/orchestrator/types.ts +13 -1
  56. package/src/server/__tests__/bootstrap-node-validation.test.ts +12 -8
  57. package/src/server/__tests__/grants-route.test.ts +72 -4
  58. package/src/server/__tests__/nodes-route.test.ts +2 -2
  59. package/src/server/__tests__/publishers.test.ts +1 -1
  60. package/src/server/__tests__/status-route.test.ts +1 -1
  61. package/src/server/routes/bootstrap-node-validation.ts +32 -13
  62. package/src/server/server.ts +157 -122
@@ -1,314 +1,327 @@
1
- /**
2
- * Public installer surface for cadre-host.
3
- *
4
- * Orchestrates the install / uninstall flow described in
5
- * `tickets/implement/6.4.1-cadre-host-installer.md`.
6
- *
7
- * The class is purposefully thin — heavy lifting lives in the focused
8
- * subsystem modules (wizard.ts, identity.ts, config.ts,
9
- * service-host/{systemd,launchd,nssm}.ts, browser.ts).
10
- */
11
-
12
- import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
13
- import { randomBytes } from 'node:crypto';
14
- import debug from 'debug';
15
-
16
- import { NatStore } from '../nat/index.js';
17
-
18
- import { openBrowser } from './browser.js';
19
- import { writeHostConfig, type HostConfigFile } from './config.js';
20
- import { generateIdentity } from './identity.js';
21
- import {
22
- configPath,
23
- defaultHostJs,
24
- defaultServiceDir,
25
- identityPath,
26
- logsDir,
27
- } from './paths.js';
28
- import {
29
- detectPlatform,
30
- type SupportedPlatform,
31
- } from './platform.js';
32
- import { createServiceHost, type ServiceHost, type ServiceHostContext } from './service-host/index.js';
33
- import {
34
- defaultsForPlatform,
35
- runWizard,
36
- runWizardWith,
37
- DEFAULT_UI_PORT,
38
- DEFAULT_LIBP2P_PORT,
39
- type WizardAnswers,
40
- type WizardDefaults,
41
- } from './wizard.js';
42
-
43
- export type { HostConfigFile, PushSettings } from './config.js';
44
- export { readHostConfig, updateHostConfig, writeHostConfig } from './config.js';
45
- export type { WizardAnswers, WizardDefaults } from './wizard.js';
46
- export type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './service-host/index.js';
47
-
48
- const log = debug('cadre:host:installer');
49
-
50
- export interface InstallOptions {
51
- /** If true, all prompts use defaults; CLI flags override individual values. */
52
- nonInteractive: boolean;
53
- /** Override the data dir. */
54
- dataDir?: string;
55
- /** Override the UI port. */
56
- uiPort?: number;
57
- /** Override the libp2p port. */
58
- libp2pPort?: number;
59
- /** Disable UPnP probing on first run. */
60
- noUpnp?: boolean;
61
- /** Also run the host's own personal cadre here (founder persona). Default false. */
62
- ownCadre?: boolean;
63
- /** Open browser at end (default true; suppressed by --no-browser or non-TTY). */
64
- openBrowser?: boolean;
65
- /** Skip the enrollment-invite step (no QR printed). */
66
- noInvite?: boolean;
67
- /** System-wide install — v1 emits a not-yet-supported error. */
68
- system?: boolean;
69
- /** Path to node.exe / node binary used by the service-host unit. */
70
- nodePath?: string;
71
- /** Test-only: stub the service-host registration. */
72
- serviceHost?: ServiceHost;
73
- /** Test-only: stub the interactive wizard. Production callers leave unset. */
74
- wizard?: (defaults: WizardDefaults) => Promise<WizardAnswers>;
75
- }
76
-
77
- export interface InstallResult {
78
- dataDir: string;
79
- uiUrl: string;
80
- serviceName: string;
81
- /** Path to the rendered config file. */
82
- configPath: string;
83
- /** Generated enrollment invite, unless --no-invite. */
84
- enrollmentInvite?: {
85
- encodedInvite: string;
86
- expiresAt?: string;
87
- };
88
- }
89
-
90
- export interface UninstallOptions {
91
- /** Also remove the data dir (default false — preserve trust circle + identity). */
92
- removeData: boolean;
93
- /** Skip the confirmation prompt (for scripts). */
94
- yes: boolean;
95
- /** Override the data dir to remove (defaults to platform default). */
96
- dataDir?: string;
97
- /** Test-only: stub the service-host registration. */
98
- serviceHost?: ServiceHost;
99
- }
100
-
101
- export interface InstallerOptions {
102
- platform?: SupportedPlatform;
103
- /** Installer version string written into host.config.json. */
104
- installerVersion?: string;
105
- }
106
-
107
- export class Installer {
108
- private readonly platform: SupportedPlatform;
109
- private readonly installerVersion: string;
110
-
111
- constructor(opts: InstallerOptions = {}) {
112
- this.platform = opts.platform ?? detectPlatform();
113
- this.installerVersion = opts.installerVersion ?? readPackageVersion();
114
- }
115
-
116
- async install(opts: InstallOptions): Promise<InstallResult> {
117
- if (opts.system) {
118
- throw new Error(
119
- 'cadre-host install --system is not yet supported. Run without --system ' +
120
- 'for a per-user install; see packages/cadre-host/service/README.md.',
121
- );
122
- }
123
-
124
- // 1. Resolve answers — wizard for interactive, defaults+overrides otherwise.
125
- const answers = await this.resolveAnswers(opts);
126
-
127
- // 2. Make sure the data dir exists.
128
- mkdirSync(answers.dataDir, { recursive: true });
129
- mkdirSync(logsDir(answers.dataDir), { recursive: true });
130
-
131
- // 3. Identity + config. Don't overwrite an existing key — re-running
132
- // install on a populated data dir must preserve the host's peer ID.
133
- const idPath = identityPath(answers.dataDir);
134
- if (existsSync(idPath)) {
135
- log('identity already exists at %s — leaving it', idPath);
136
- } else {
137
- await generateIdentity(idPath);
138
- }
139
-
140
- const cfg: HostConfigFile = {
141
- version: 2,
142
- installId: randomInstallId(),
143
- uiPort: answers.uiPort,
144
- libp2pPort: answers.libp2pPort,
145
- dataDir: answers.dataDir,
146
- identityPath: idPath,
147
- upnpEnabled: answers.upnpEnabled,
148
- installedAt: new Date().toISOString(),
149
- installerVersion: this.installerVersion,
150
- updates: { autoApply: false },
151
- ownCadre: { enabled: answers.ownCadre },
152
- };
153
- const cfgPath = configPath(answers.dataDir);
154
- writeHostConfig(cfgPath, cfg);
155
-
156
- // 4. Seed nat.json with the chosen libp2p port + UPnP flag so the
157
- // NatService picks them up on first start.
158
- seedNatSettings(answers.dataDir, answers.libp2pPort, answers.upnpEnabled);
159
-
160
- // 5. Service-host registration.
161
- const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
162
- const ctx: ServiceHostContext = {
163
- nodePath: opts.nodePath ?? process.execPath,
164
- hostJs: defaultHostJs(),
165
- dataDir: answers.dataDir,
166
- serviceDir: defaultServiceDir(),
167
- };
168
- await serviceHost.install(ctx);
169
-
170
- // 6. Browser open (best-effort).
171
- const uiUrl = `http://127.0.0.1:${answers.uiPort}/`;
172
- const shouldOpenBrowser = opts.openBrowser !== false && !opts.nonInteractive && process.stdout.isTTY;
173
- if (shouldOpenBrowser) {
174
- openBrowser(uiUrl);
175
- }
176
-
177
- // 7. Enrollment invite (best-effort — the listener may not be up yet).
178
- let invite: InstallResult['enrollmentInvite'];
179
- if (!opts.noInvite && !opts.nonInteractive) {
180
- invite = await fetchEnrollmentInvite(answers.uiPort).catch((err) => {
181
- log('enrollment-invite fetch failed: %s', (err as Error).message);
182
- return undefined;
183
- });
184
- }
185
-
186
- return {
187
- dataDir: answers.dataDir,
188
- uiUrl,
189
- serviceName: serviceHost.name,
190
- configPath: cfgPath,
191
- ...(invite ? { enrollmentInvite: invite } : {}),
192
- };
193
- }
194
-
195
- async uninstall(opts: UninstallOptions): Promise<void> {
196
- const dataDir = opts.dataDir ?? defaultsForPlatform(this.platform).dataDir;
197
- const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
198
- const ctx: ServiceHostContext = {
199
- nodePath: process.execPath,
200
- hostJs: defaultHostJs(),
201
- dataDir,
202
- serviceDir: defaultServiceDir(),
203
- };
204
- await serviceHost.uninstall(ctx);
205
-
206
- if (opts.removeData) {
207
- if (existsSync(dataDir)) {
208
- rmSync(dataDir, { recursive: true, force: true });
209
- log('removed data dir %s', dataDir);
210
- }
211
- }
212
- }
213
-
214
- async status(opts: { serviceHost?: ServiceHost } = {}): Promise<{ installed: boolean; running: boolean }> {
215
- const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
216
- const ctx: ServiceHostContext = {
217
- nodePath: process.execPath,
218
- hostJs: defaultHostJs(),
219
- dataDir: defaultsForPlatform(this.platform).dataDir,
220
- serviceDir: defaultServiceDir(),
221
- };
222
- return await serviceHost.status(ctx);
223
- }
224
-
225
- /** Exposed for tests / unattended CI. Resolves the wizard answers without running prompts. */
226
- resolveAnswersFromOptions(opts: InstallOptions): WizardAnswers {
227
- const defaults = defaultsForPlatform(this.platform);
228
- return {
229
- dataDir: opts.dataDir ?? defaults.dataDir,
230
- uiPort: opts.uiPort ?? defaults.uiPort,
231
- libp2pPort: opts.libp2pPort ?? defaults.libp2pPort,
232
- upnpEnabled: opts.noUpnp ? false : defaults.upnpEnabled,
233
- configureDdns: false,
234
- ownCadre: opts.ownCadre ?? defaults.ownCadre,
235
- };
236
- }
237
-
238
- private async resolveAnswers(opts: InstallOptions): Promise<WizardAnswers> {
239
- if (opts.nonInteractive) {
240
- return this.resolveAnswersFromOptions(opts);
241
- }
242
- // CLI flags overlay the platform defaults so the wizard's `[...]` hint
243
- // shows the user's chosen value. Pressing Enter accepts it; typing a new
244
- // value also works (and now actually has an effect — the previous
245
- // implementation showed the platform default in the prompt and silently
246
- // discarded user input because the CLI flag won post-hoc).
247
- const platformDefaults = defaultsForPlatform(this.platform);
248
- const defaults: WizardDefaults = {
249
- dataDir: opts.dataDir ?? platformDefaults.dataDir,
250
- uiPort: opts.uiPort ?? platformDefaults.uiPort,
251
- libp2pPort: opts.libp2pPort ?? platformDefaults.libp2pPort,
252
- upnpEnabled: opts.noUpnp ? false : platformDefaults.upnpEnabled,
253
- ownCadre: opts.ownCadre ?? platformDefaults.ownCadre,
254
- };
255
- const wizard = opts.wizard ?? runWizard;
256
- return await wizard(defaults);
257
- }
258
- }
259
-
260
- export { runWizardWith, defaultsForPlatform, DEFAULT_UI_PORT, DEFAULT_LIBP2P_PORT };
261
-
262
- function seedNatSettings(dataDir: string, libp2pPort: number, upnpEnabled: boolean): void {
263
- const store = new NatStore(dataDir);
264
- // `update()` merges + persists.
265
- store.update({
266
- externalPort: libp2pPort,
267
- internalPort: libp2pPort,
268
- upnpEnabled,
269
- });
270
- }
271
-
272
- function randomInstallId(): string {
273
- return randomBytes(16).toString('hex');
274
- }
275
-
276
- function readPackageVersion(): string {
277
- // Best-effort: read the package.json next to dist/ at runtime.
278
- try {
279
- const here = new URL('../../package.json', import.meta.url);
280
- const raw = readFileSync(here, 'utf8');
281
- const parsed = JSON.parse(raw) as { version?: string };
282
- return parsed.version ?? '0.0.0-unknown';
283
- } catch {
284
- return '0.0.0-unknown';
285
- }
286
- }
287
-
288
- async function fetchEnrollmentInvite(uiPort: number): Promise<InstallResult['enrollmentInvite']> {
289
- const url = `http://127.0.0.1:${uiPort}/auth/invites`;
290
- // Node 18+'s fetch has no default timeout. Bound it so a slow / hung
291
- // local-UI listener can't wedge `cadre-host install` indefinitely.
292
- const controller = new AbortController();
293
- const timer = setTimeout(() => controller.abort(), 5000);
294
- try {
295
- const response = await fetch(url, {
296
- method: 'POST',
297
- headers: { 'content-type': 'application/json' },
298
- body: JSON.stringify({ label: 'Install — first device', ttlMs: 24 * 60 * 60 * 1000 }),
299
- signal: controller.signal,
300
- });
301
- if (!response.ok) return undefined;
302
- const body = await response.json() as { encodedInvite?: string; expiresAt?: string };
303
- if (!body.encodedInvite) return undefined;
304
- return {
305
- encodedInvite: body.encodedInvite,
306
- ...(body.expiresAt ? { expiresAt: body.expiresAt } : {}),
307
- };
308
- } finally {
309
- clearTimeout(timer);
310
- }
311
- }
312
-
313
- // Exposed for tests that drive the wizard with a custom prompt fn.
314
- export { runWizard };
1
+ /**
2
+ * Public installer surface for cadre-host.
3
+ *
4
+ * Orchestrates the install / uninstall flow described in
5
+ * `tickets/implement/6.4.1-cadre-host-installer.md`.
6
+ *
7
+ * The class is purposefully thin — heavy lifting lives in the focused
8
+ * subsystem modules (wizard.ts, identity.ts, config.ts,
9
+ * service-host/{systemd,launchd,nssm}.ts, browser.ts).
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
13
+ import { randomBytes } from 'node:crypto';
14
+ import debug from 'debug';
15
+
16
+ import { NatStore } from '../nat/index.js';
17
+
18
+ import { openBrowser } from './browser.js';
19
+ import { writeHostConfig, type HostConfigFile } from './config.js';
20
+ import { generateIdentity } from './identity.js';
21
+ import {
22
+ configPath,
23
+ defaultHostJs,
24
+ defaultServiceDir,
25
+ identityPath,
26
+ logsDir,
27
+ } from './paths.js';
28
+ import {
29
+ detectPlatform,
30
+ type SupportedPlatform,
31
+ } from './platform.js';
32
+ import { createServiceHost, type ServiceHost, type ServiceHostContext } from './service-host/index.js';
33
+ import {
34
+ defaultsForPlatform,
35
+ runWizard,
36
+ runWizardWith,
37
+ DEFAULT_UI_PORT,
38
+ DEFAULT_LIBP2P_PORT,
39
+ type WizardAnswers,
40
+ type WizardDefaults,
41
+ } from './wizard.js';
42
+
43
+ export type { HostConfigFile, PushSettings } from './config.js';
44
+ export { readHostConfig, updateHostConfig, writeHostConfig } from './config.js';
45
+ export type { WizardAnswers, WizardDefaults } from './wizard.js';
46
+ export type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './service-host/index.js';
47
+
48
+ const log = debug('cadre:host:installer');
49
+
50
+ export interface InstallOptions {
51
+ /** If true, all prompts use defaults; CLI flags override individual values. */
52
+ nonInteractive: boolean;
53
+ /** Override the data dir. */
54
+ dataDir?: string;
55
+ /** Override the UI port. */
56
+ uiPort?: number;
57
+ /** Override the libp2p port. */
58
+ libp2pPort?: number;
59
+ /** Disable UPnP probing on first run. */
60
+ noUpnp?: boolean;
61
+ /** Also run the host's own personal cadre here (founder persona). Default false. */
62
+ ownCadre?: boolean;
63
+ /** Open browser at end (default true; suppressed by --no-browser or non-TTY). */
64
+ openBrowser?: boolean;
65
+ /** Skip the enrollment-invite step (no QR printed). */
66
+ noInvite?: boolean;
67
+ /** System-wide install — v1 emits a not-yet-supported error. */
68
+ system?: boolean;
69
+ /** Path to node.exe / node binary used by the service-host unit. */
70
+ nodePath?: string;
71
+ /**
72
+ * Write the data dir (identity, config, NAT seed) but register no OS service.
73
+ * The host is then run by hand with `cadre-host start`. Nothing is listening
74
+ * after such an install, so the browser-open and enrollment-invite steps are
75
+ * skipped too.
76
+ */
77
+ noService?: boolean;
78
+ /** Test-only: stub the service-host registration. */
79
+ serviceHost?: ServiceHost;
80
+ /** Test-only: stub the interactive wizard. Production callers leave unset. */
81
+ wizard?: (defaults: WizardDefaults) => Promise<WizardAnswers>;
82
+ }
83
+
84
+ export interface InstallResult {
85
+ dataDir: string;
86
+ uiUrl: string;
87
+ /** The registered service's name; absent when `noService` skipped registration. */
88
+ serviceName?: string;
89
+ /** Path to the rendered config file. */
90
+ configPath: string;
91
+ /** Generated enrollment invite, unless --no-invite. */
92
+ enrollmentInvite?: {
93
+ encodedInvite: string;
94
+ expiresAt?: string;
95
+ };
96
+ }
97
+
98
+ export interface UninstallOptions {
99
+ /** Also remove the data dir (default false — preserve trust circle + identity). */
100
+ removeData: boolean;
101
+ /** Skip the confirmation prompt (for scripts). */
102
+ yes: boolean;
103
+ /** Override the data dir to remove (defaults to platform default). */
104
+ dataDir?: string;
105
+ /** Test-only: stub the service-host registration. */
106
+ serviceHost?: ServiceHost;
107
+ }
108
+
109
+ export interface InstallerOptions {
110
+ platform?: SupportedPlatform;
111
+ /** Installer version string written into host.config.json. */
112
+ installerVersion?: string;
113
+ }
114
+
115
+ export class Installer {
116
+ private readonly platform: SupportedPlatform;
117
+ private readonly installerVersion: string;
118
+
119
+ constructor(opts: InstallerOptions = {}) {
120
+ this.platform = opts.platform ?? detectPlatform();
121
+ this.installerVersion = opts.installerVersion ?? readPackageVersion();
122
+ }
123
+
124
+ async install(opts: InstallOptions): Promise<InstallResult> {
125
+ if (opts.system) {
126
+ throw new Error(
127
+ 'cadre-host install --system is not yet supported. Run without --system ' +
128
+ 'for a per-user install; see packages/cadre-host/service/README.md.',
129
+ );
130
+ }
131
+
132
+ // 1. Resolve answers — wizard for interactive, defaults+overrides otherwise.
133
+ const answers = await this.resolveAnswers(opts);
134
+
135
+ // 2. Make sure the data dir exists.
136
+ mkdirSync(answers.dataDir, { recursive: true });
137
+ mkdirSync(logsDir(answers.dataDir), { recursive: true });
138
+
139
+ // 3. Identity + config. Don't overwrite an existing key — re-running
140
+ // install on a populated data dir must preserve the host's peer ID.
141
+ const idPath = identityPath(answers.dataDir);
142
+ if (existsSync(idPath)) {
143
+ log('identity already exists at %s — leaving it', idPath);
144
+ } else {
145
+ await generateIdentity(idPath);
146
+ }
147
+
148
+ const cfg: HostConfigFile = {
149
+ version: 2,
150
+ installId: randomInstallId(),
151
+ uiPort: answers.uiPort,
152
+ libp2pPort: answers.libp2pPort,
153
+ dataDir: answers.dataDir,
154
+ identityPath: idPath,
155
+ upnpEnabled: answers.upnpEnabled,
156
+ installedAt: new Date().toISOString(),
157
+ installerVersion: this.installerVersion,
158
+ updates: { autoApply: false },
159
+ ownCadre: { enabled: answers.ownCadre },
160
+ };
161
+ const cfgPath = configPath(answers.dataDir);
162
+ writeHostConfig(cfgPath, cfg);
163
+
164
+ // 4. Seed nat.json with the chosen libp2p port + UPnP flag so the
165
+ // NatService picks them up on first start.
166
+ seedNatSettings(answers.dataDir, answers.libp2pPort, answers.upnpEnabled);
167
+
168
+ const uiUrl = `http://127.0.0.1:${answers.uiPort}/`;
169
+
170
+ // 5. Service-host registration.
171
+ if (opts.noService) {
172
+ log('--no-service: skipping service registration, browser open and enrollment invite');
173
+ return { dataDir: answers.dataDir, uiUrl, configPath: cfgPath };
174
+ }
175
+ const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
176
+ const ctx: ServiceHostContext = {
177
+ nodePath: opts.nodePath ?? process.execPath,
178
+ hostJs: defaultHostJs(),
179
+ dataDir: answers.dataDir,
180
+ serviceDir: defaultServiceDir(),
181
+ };
182
+ await serviceHost.install(ctx);
183
+
184
+ // 6. Browser open (best-effort).
185
+ const shouldOpenBrowser = opts.openBrowser !== false && !opts.nonInteractive && process.stdout.isTTY;
186
+ if (shouldOpenBrowser) {
187
+ openBrowser(uiUrl);
188
+ }
189
+
190
+ // 7. Enrollment invite (best-effort — the listener may not be up yet).
191
+ let invite: InstallResult['enrollmentInvite'];
192
+ if (!opts.noInvite && !opts.nonInteractive) {
193
+ invite = await fetchEnrollmentInvite(answers.uiPort).catch((err) => {
194
+ log('enrollment-invite fetch failed: %s', (err as Error).message);
195
+ return undefined;
196
+ });
197
+ }
198
+
199
+ return {
200
+ dataDir: answers.dataDir,
201
+ uiUrl,
202
+ serviceName: serviceHost.name,
203
+ configPath: cfgPath,
204
+ ...(invite ? { enrollmentInvite: invite } : {}),
205
+ };
206
+ }
207
+
208
+ async uninstall(opts: UninstallOptions): Promise<void> {
209
+ const dataDir = opts.dataDir ?? defaultsForPlatform(this.platform).dataDir;
210
+ const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
211
+ const ctx: ServiceHostContext = {
212
+ nodePath: process.execPath,
213
+ hostJs: defaultHostJs(),
214
+ dataDir,
215
+ serviceDir: defaultServiceDir(),
216
+ };
217
+ await serviceHost.uninstall(ctx);
218
+
219
+ if (opts.removeData) {
220
+ if (existsSync(dataDir)) {
221
+ rmSync(dataDir, { recursive: true, force: true });
222
+ log('removed data dir %s', dataDir);
223
+ }
224
+ }
225
+ }
226
+
227
+ async status(opts: { serviceHost?: ServiceHost } = {}): Promise<{ installed: boolean; running: boolean }> {
228
+ const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
229
+ const ctx: ServiceHostContext = {
230
+ nodePath: process.execPath,
231
+ hostJs: defaultHostJs(),
232
+ dataDir: defaultsForPlatform(this.platform).dataDir,
233
+ serviceDir: defaultServiceDir(),
234
+ };
235
+ return await serviceHost.status(ctx);
236
+ }
237
+
238
+ /** Exposed for tests / unattended CI. Resolves the wizard answers without running prompts. */
239
+ resolveAnswersFromOptions(opts: InstallOptions): WizardAnswers {
240
+ const defaults = defaultsForPlatform(this.platform);
241
+ return {
242
+ dataDir: opts.dataDir ?? defaults.dataDir,
243
+ uiPort: opts.uiPort ?? defaults.uiPort,
244
+ libp2pPort: opts.libp2pPort ?? defaults.libp2pPort,
245
+ upnpEnabled: opts.noUpnp ? false : defaults.upnpEnabled,
246
+ configureDdns: false,
247
+ ownCadre: opts.ownCadre ?? defaults.ownCadre,
248
+ };
249
+ }
250
+
251
+ private async resolveAnswers(opts: InstallOptions): Promise<WizardAnswers> {
252
+ if (opts.nonInteractive) {
253
+ return this.resolveAnswersFromOptions(opts);
254
+ }
255
+ // CLI flags overlay the platform defaults so the wizard's `[...]` hint
256
+ // shows the user's chosen value. Pressing Enter accepts it; typing a new
257
+ // value also works (and now actually has an effect — the previous
258
+ // implementation showed the platform default in the prompt and silently
259
+ // discarded user input because the CLI flag won post-hoc).
260
+ const platformDefaults = defaultsForPlatform(this.platform);
261
+ const defaults: WizardDefaults = {
262
+ dataDir: opts.dataDir ?? platformDefaults.dataDir,
263
+ uiPort: opts.uiPort ?? platformDefaults.uiPort,
264
+ libp2pPort: opts.libp2pPort ?? platformDefaults.libp2pPort,
265
+ upnpEnabled: opts.noUpnp ? false : platformDefaults.upnpEnabled,
266
+ ownCadre: opts.ownCadre ?? platformDefaults.ownCadre,
267
+ };
268
+ const wizard = opts.wizard ?? runWizard;
269
+ return await wizard(defaults);
270
+ }
271
+ }
272
+
273
+ export { runWizardWith, defaultsForPlatform, DEFAULT_UI_PORT, DEFAULT_LIBP2P_PORT };
274
+
275
+ function seedNatSettings(dataDir: string, libp2pPort: number, upnpEnabled: boolean): void {
276
+ const store = new NatStore(dataDir);
277
+ // `update()` merges + persists.
278
+ store.update({
279
+ externalPort: libp2pPort,
280
+ internalPort: libp2pPort,
281
+ upnpEnabled,
282
+ });
283
+ }
284
+
285
+ function randomInstallId(): string {
286
+ return randomBytes(16).toString('hex');
287
+ }
288
+
289
+ function readPackageVersion(): string {
290
+ // Best-effort: read the package.json next to dist/ at runtime.
291
+ try {
292
+ const here = new URL('../../package.json', import.meta.url);
293
+ const raw = readFileSync(here, 'utf8');
294
+ const parsed = JSON.parse(raw) as { version?: string };
295
+ return parsed.version ?? '0.0.0-unknown';
296
+ } catch {
297
+ return '0.0.0-unknown';
298
+ }
299
+ }
300
+
301
+ async function fetchEnrollmentInvite(uiPort: number): Promise<InstallResult['enrollmentInvite']> {
302
+ const url = `http://127.0.0.1:${uiPort}/auth/invites`;
303
+ // Node 18+'s fetch has no default timeout. Bound it so a slow / hung
304
+ // local-UI listener can't wedge `cadre-host install` indefinitely.
305
+ const controller = new AbortController();
306
+ const timer = setTimeout(() => controller.abort(), 5000);
307
+ try {
308
+ const response = await fetch(url, {
309
+ method: 'POST',
310
+ headers: { 'content-type': 'application/json' },
311
+ body: JSON.stringify({ label: 'Install — first device', ttlMs: 24 * 60 * 60 * 1000 }),
312
+ signal: controller.signal,
313
+ });
314
+ if (!response.ok) return undefined;
315
+ const body = await response.json() as { encodedInvite?: string; expiresAt?: string };
316
+ if (!body.encodedInvite) return undefined;
317
+ return {
318
+ encodedInvite: body.encodedInvite,
319
+ ...(body.expiresAt ? { expiresAt: body.expiresAt } : {}),
320
+ };
321
+ } finally {
322
+ clearTimeout(timer);
323
+ }
324
+ }
325
+
326
+ // Exposed for tests that drive the wizard with a custom prompt fn.
327
+ export { runWizard };