@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
@@ -220,6 +220,6 @@ function toNodeInfo(dockerId: string, child: FakeChild): ManagedNodeInfo {
220
220
  status: child.running ? 'running' : 'stopped',
221
221
  spawnedAt: new Date(0).toISOString(),
222
222
  workdir: `/fake/${child.containerId}`,
223
- ports: { health: 0, metrics: 0, p2p: 0, admin: 0 },
223
+ ports: { health: 0, metrics: 0, p2p: 0, admin: 0, ws: 0 },
224
224
  };
225
225
  }
@@ -78,7 +78,12 @@ export interface DonationProvisionRequest {
78
78
  grantToken: string;
79
79
  /** The REQUESTER's cadre — the party the donated node joins. */
80
80
  partyId: string;
81
- /** Requester control-network bootstrap multiaddrs (dialable, with `/p2p/`). */
81
+ /**
82
+ * Requester control-network bootstrap multiaddrs (dialable, with `/p2p/`). Empty
83
+ * means the requester dials the node itself: a phone has no address to give, so it
84
+ * reaches the node at the WebSocket address {@link DonationService.getPeer} reports
85
+ * rather than being dialed by it.
86
+ */
82
87
  bootstrapNodes: string[];
83
88
  /**
84
89
  * The requester's owner public key(s), base64url. Pinned as cold-start
@@ -450,6 +455,18 @@ export class DonationService {
450
455
  } catch (err) {
451
456
  throw new DonationError('seed_failed', `Donated node unreachable: ${errorMessage(err)}`);
452
457
  }
458
+ if (res.status === 401) {
459
+ // The node refused the host's bearer, not the requester's seed — the node
460
+ // never looked at the seed. So the fault is the host's own token
461
+ // bookkeeping (the record names a token the running child was not started
462
+ // with), and the message must not read as a trust-policy rejection.
463
+ //
464
+ // Still `seed_failed`, deliberately: the phone retries that code, and a
465
+ // bookkeeping gap that is mid-repair (a respawn about to write its new
466
+ // token) can clear inside that retry window.
467
+ log('donated node for donation %s refused the host seed credential (401)', id);
468
+ throw new DonationError('seed_failed', 'Donated node rejected the host\'s seed credential (401)');
469
+ }
453
470
  if (!res.ok) {
454
471
  const body = await res.text();
455
472
  throw new DonationError('seed_failed', `Donated node /seed returned ${res.status}: ${body}`);
@@ -524,7 +541,9 @@ export class DonationService {
524
541
  `Donation ${id} cannot be respawned in status ${donation.status}`,
525
542
  );
526
543
  }
527
- if (!donation.bootstrapNodes?.length || !donation.ownerKeys?.length) {
544
+ // An empty `bootstrapNodes` is a real spawn input (a requester that dials the node
545
+ // itself); only a missing field marks a record written before inputs were persisted.
546
+ if (donation.bootstrapNodes === undefined || !donation.ownerKeys?.length) {
528
547
  log('donation %s is not respawnable (record predates persisted spawn inputs)', id);
529
548
  return { outcome: 'not_respawnable' };
530
549
  }
@@ -613,7 +632,7 @@ export class DonationService {
613
632
  * (`<rootDir>/<containerId>`), reclaiming here would delete exactly what
614
633
  * `giveUp` meant to keep.
615
634
  *
616
- * NOTE: that skipped reclaim leaks the new spawn's four ports — the record
635
+ * NOTE: that skipped reclaim leaks the new spawn's ports — the record
617
636
  * still names the *previous* `dockerId`, so a later `terminate` cleans up the
618
637
  * old handle, not this one. Unreachable today (`giveUp` is only called from
619
638
  * the supervisor's serialized pass, so it cannot overlap a respawn); if a
@@ -23,7 +23,8 @@ export const DONATION_RESPAWN_BACKOFF_BASE_MS = 5_000;
23
23
  *
24
24
  * NOTE: not reachable at the current {@link DONATION_RESPAWN_MAX_ATTEMPTS} of 5
25
25
  * — the longest wait actually served is the one before the 5th attempt,
26
- * `5s * 2^4` = 80s, and the 5th failure gives up. The cap only starts clamping
26
+ * `5s * 2^4` = 80s, and once that attempt fails or its node dies the supervisor
27
+ * gives up rather than waiting again. The cap only starts clamping
27
28
  * once a record can reach 6 recorded attempts (`5s * 2^6` = 320s), so raising
28
29
  * the attempt cap without revisiting this one changes nothing.
29
30
  */
@@ -33,6 +34,12 @@ export const DONATION_RESPAWN_BACKOFF_MAX_MS = 5 * 60_000;
33
34
  * Consecutive respawn attempts after which the host stops trying and marks the
34
35
  * donation `error` — a node that will not stay up is a host-side fault the
35
36
  * borrower cannot see, so surface it instead of spinning forever.
37
+ *
38
+ * Every attempt counts, whether its spawn failed or succeeded: a node that spawns
39
+ * and then dies before {@link DONATION_RESPAWN_HEALTHY_MS} refills the budget is
40
+ * as much a crash loop as one that never spawns. Give-up happens on whichever
41
+ * comes first — an attempt that throws with the cap reached, or a pass that finds
42
+ * the node down with the cap already reached.
36
43
  */
37
44
  export const DONATION_RESPAWN_MAX_ATTEMPTS = 5;
38
45
 
@@ -232,6 +239,19 @@ export class DonationSupervisor {
232
239
  this.refillBudgetIfHealthy(donation);
233
240
  return undefined;
234
241
  }
242
+ // The budget is spent, and the last attempt did not stay up long enough to
243
+ // refill it. Checked here and not only when `respawn` throws: a spawn that
244
+ // succeeds and then dies at once (a crash on boot, a port clash) never
245
+ // throws, so a catch-only check would respawn it forever.
246
+ // NOTE: a record at the cap whose last respawn was still up but not yet
247
+ // refilled when the host went down is given up on host restart rather than
248
+ // tried once more; if that is ever seen, skip this check when the host
249
+ // started after `respawn.lastAttemptAt`.
250
+ const attempts = donation.respawn?.attempts ?? 0;
251
+ if (attempts >= DONATION_RESPAWN_MAX_ATTEMPTS) {
252
+ await this.giveUp(donation.id, attempts, 'the node did not stay running after its last respawn');
253
+ return undefined;
254
+ }
235
255
  if (!this.backoffElapsed(donation)) {
236
256
  log('donation %s is down but still inside its respawn backoff', donation.id);
237
257
  return undefined;
@@ -294,7 +314,10 @@ export class DonationSupervisor {
294
314
  return this.now().getTime() - last > DONATION_RESPAWN_HEALTHY_MS;
295
315
  }
296
316
 
297
- /** One respawn attempt, with the give-up check on failure. */
317
+ /**
318
+ * One respawn attempt, with the give-up check on failure. (The check for an
319
+ * attempt that spawned but then died lives in {@link reconcileOne}.)
320
+ */
298
321
  private async attemptRespawn(donation: Donation): Promise<string | undefined> {
299
322
  try {
300
323
  const result = await this.service.respawn(donation.id);
@@ -149,7 +149,8 @@ export interface Donation {
149
149
  /** The REQUESTER's cadre (a foreign party this host merely hosts a node for). */
150
150
  partyId: string;
151
151
  /**
152
- * Requester control-network bootstrap multiaddrs — replayed on respawn.
152
+ * Requester control-network bootstrap multiaddrs — replayed on respawn. Empty when
153
+ * the requester dials the node itself (see `DonationProvisionRequest.bootstrapNodes`).
153
154
  * Absent on records written before respawn support; those are not respawnable.
154
155
  */
155
156
  bootstrapNodes?: string[];
@@ -1,218 +1,263 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import { Installer } from '../index.js';
7
- import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from '../service-host/types.js';
8
-
9
- class FakeServiceHost implements ServiceHost {
10
- readonly name = 'cadre-host';
11
- installCalls: ServiceHostContext[] = [];
12
- uninstallCalls: ServiceHostContext[] = [];
13
- installed = false;
14
- running = false;
15
-
16
- async install(ctx: ServiceHostContext): Promise<void> {
17
- this.installCalls.push(ctx);
18
- this.installed = true;
19
- this.running = true;
20
- }
21
- async uninstall(ctx: ServiceHostContext): Promise<void> {
22
- this.uninstallCalls.push(ctx);
23
- this.installed = false;
24
- this.running = false;
25
- }
26
- async restart(_ctx: ServiceHostContext): Promise<void> {
27
- this.running = true;
28
- }
29
- async status(_ctx: ServiceHostContext): Promise<ServiceHostStatus> {
30
- return { installed: this.installed, running: this.running };
31
- }
32
- renderUnit(_ctx: ServiceHostContext): string | null {
33
- return 'rendered-unit';
34
- }
35
- }
36
-
37
- describe('Installer smoke', () => {
38
- let tmp: string;
39
-
40
- beforeEach(() => {
41
- tmp = mkdtempSync(join(tmpdir(), 'cadre-host-installer-smoke-'));
42
- });
43
-
44
- afterEach(() => {
45
- rmSync(tmp, { recursive: true, force: true });
46
- });
47
-
48
- it('install creates config + identity + invokes service-host', async () => {
49
- const fake = new FakeServiceHost();
50
- const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
51
- const result = await installer.install({
52
- nonInteractive: true,
53
- dataDir: tmp,
54
- uiPort: 19999,
55
- libp2pPort: 14001,
56
- openBrowser: false,
57
- noInvite: true,
58
- serviceHost: fake,
59
- });
60
-
61
- expect(result.dataDir).toBe(tmp);
62
- expect(result.uiUrl).toBe('http://127.0.0.1:19999/');
63
- expect(result.serviceName).toBe('cadre-host');
64
- expect(result.configPath).toBe(join(tmp, 'host.config.json'));
65
-
66
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
67
- expect(existsSync(join(tmp, 'identity.key'))).toBe(true);
68
- expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
69
- expect(existsSync(join(tmp, 'logs'))).toBe(true);
70
-
71
- expect(fake.installCalls).toHaveLength(1);
72
- expect(fake.installCalls[0]!.dataDir).toBe(tmp);
73
- });
74
-
75
- it('uninstall removes data only when --remove-data is set', async () => {
76
- const fake = new FakeServiceHost();
77
- const installer = new Installer({ platform: 'linux' });
78
- await installer.install({
79
- nonInteractive: true,
80
- dataDir: tmp,
81
- uiPort: 19998,
82
- libp2pPort: 14002,
83
- openBrowser: false,
84
- noInvite: true,
85
- serviceHost: fake,
86
- });
87
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
88
-
89
- await installer.uninstall({ yes: true, removeData: false, dataDir: tmp, serviceHost: fake });
90
- expect(fake.uninstallCalls).toHaveLength(1);
91
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
92
-
93
- await installer.uninstall({ yes: true, removeData: true, dataDir: tmp, serviceHost: fake });
94
- expect(existsSync(tmp)).toBe(false);
95
- });
96
-
97
- it('rejects --system in v1', async () => {
98
- const fake = new FakeServiceHost();
99
- const installer = new Installer({ platform: 'linux' });
100
- await expect(installer.install({
101
- nonInteractive: true,
102
- dataDir: tmp,
103
- system: true,
104
- serviceHost: fake,
105
- openBrowser: false,
106
- noInvite: true,
107
- })).rejects.toThrow(/system.*not yet supported/);
108
- expect(fake.installCalls).toHaveLength(0);
109
- });
110
-
111
- it('passes CLI overrides to the wizard as suggested defaults', async () => {
112
- // Regression: the interactive wizard previously showed the platform
113
- // default (`~/.local/share/cadre-host`) in the prompt even when the
114
- // caller passed `--data-dir <path>`. Whatever the user typed was then
115
- // silently discarded because the CLI flag won post-hoc. The fix: CLI
116
- // overrides become the wizard's suggested defaults, and the wizard's
117
- // returned answers stand on their own.
118
- const fake = new FakeServiceHost();
119
- const installer = new Installer({ platform: 'linux' });
120
- let seenDefaults: { dataDir: string; uiPort: number; libp2pPort: number; upnpEnabled: boolean; ownCadre: boolean } | undefined;
121
- await installer.install({
122
- nonInteractive: false,
123
- dataDir: tmp,
124
- uiPort: 19997,
125
- libp2pPort: 14003,
126
- noUpnp: true,
127
- openBrowser: false,
128
- noInvite: true,
129
- serviceHost: fake,
130
- wizard: async (defaults) => {
131
- seenDefaults = { ...defaults };
132
- // Simulate the user pressing Enter at every prompt.
133
- return {
134
- dataDir: defaults.dataDir,
135
- uiPort: defaults.uiPort,
136
- libp2pPort: defaults.libp2pPort,
137
- upnpEnabled: defaults.upnpEnabled,
138
- configureDdns: false,
139
- ownCadre: defaults.ownCadre,
140
- };
141
- },
142
- });
143
-
144
- expect(seenDefaults).toEqual({
145
- dataDir: tmp,
146
- uiPort: 19997,
147
- libp2pPort: 14003,
148
- upnpEnabled: false,
149
- ownCadre: false,
150
- });
151
- expect(fake.installCalls).toHaveLength(1);
152
- expect(fake.installCalls[0]!.dataDir).toBe(tmp);
153
- });
154
-
155
- it('honors wizard-typed input when no CLI override is passed', async () => {
156
- // Mirror of the previous test: with no `--data-dir`, the wizard's
157
- // returned value is what the installer uses.
158
- const fake = new FakeServiceHost();
159
- const installer = new Installer({ platform: 'linux' });
160
- await installer.install({
161
- nonInteractive: false,
162
- openBrowser: false,
163
- noInvite: true,
164
- serviceHost: fake,
165
- wizard: async () => ({
166
- dataDir: tmp,
167
- uiPort: 19996,
168
- libp2pPort: 14004,
169
- upnpEnabled: true,
170
- configureDdns: false,
171
- ownCadre: false,
172
- }),
173
- });
174
- expect(fake.installCalls).toHaveLength(1);
175
- expect(fake.installCalls[0]!.dataDir).toBe(tmp);
176
- });
177
-
178
- it('writes ownCadre.enabled=false by default and true when opted in', async () => {
179
- const { readHostConfig } = await import('../config.js');
180
-
181
- // Default (donor-only): --own-cadre absent → ownCadre.enabled false.
182
- const fakeA = new FakeServiceHost();
183
- const installerA = new Installer({ platform: 'linux' });
184
- await installerA.install({
185
- nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fakeA,
186
- });
187
- const donorCfg = readHostConfig(join(tmp, 'host.config.json'));
188
- expect(donorCfg.ownCadre).toEqual({ enabled: false });
189
-
190
- // Opt-in: ownCadre:true (the --own-cadre flag) → ownCadre.enabled true.
191
- const tmp2 = mkdtempSync(join(tmpdir(), 'cadre-host-installer-owncadre-'));
192
- try {
193
- const fakeB = new FakeServiceHost();
194
- const installerB = new Installer({ platform: 'linux' });
195
- await installerB.install({
196
- nonInteractive: true, dataDir: tmp2, ownCadre: true, openBrowser: false, noInvite: true, serviceHost: fakeB,
197
- });
198
- const founderCfg = readHostConfig(join(tmp2, 'host.config.json'));
199
- expect(founderCfg.ownCadre).toEqual({ enabled: true });
200
- } finally {
201
- rmSync(tmp2, { recursive: true, force: true });
202
- }
203
- });
204
-
205
- it('re-running install preserves the existing identity', async () => {
206
- const fake = new FakeServiceHost();
207
- const installer = new Installer({ platform: 'linux' });
208
- await installer.install({
209
- nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fake,
210
- });
211
- const idBefore = (await import('node:fs')).readFileSync(join(tmp, 'identity.key'));
212
- await installer.install({
213
- nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fake,
214
- });
215
- const idAfter = (await import('node:fs')).readFileSync(join(tmp, 'identity.key'));
216
- expect(Buffer.from(idBefore).equals(Buffer.from(idAfter))).toBe(true);
217
- });
218
- });
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { Installer } from '../index.js';
7
+ import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from '../service-host/types.js';
8
+
9
+ class FakeServiceHost implements ServiceHost {
10
+ readonly name = 'cadre-host';
11
+ installCalls: ServiceHostContext[] = [];
12
+ uninstallCalls: ServiceHostContext[] = [];
13
+ installed = false;
14
+ running = false;
15
+
16
+ async install(ctx: ServiceHostContext): Promise<void> {
17
+ this.installCalls.push(ctx);
18
+ this.installed = true;
19
+ this.running = true;
20
+ }
21
+ async uninstall(ctx: ServiceHostContext): Promise<void> {
22
+ this.uninstallCalls.push(ctx);
23
+ this.installed = false;
24
+ this.running = false;
25
+ }
26
+ async restart(_ctx: ServiceHostContext): Promise<void> {
27
+ this.running = true;
28
+ }
29
+ async status(_ctx: ServiceHostContext): Promise<ServiceHostStatus> {
30
+ return { installed: this.installed, running: this.running };
31
+ }
32
+ renderUnit(_ctx: ServiceHostContext): string | null {
33
+ return 'rendered-unit';
34
+ }
35
+ }
36
+
37
+ describe('Installer smoke', () => {
38
+ let tmp: string;
39
+
40
+ beforeEach(() => {
41
+ tmp = mkdtempSync(join(tmpdir(), 'cadre-host-installer-smoke-'));
42
+ });
43
+
44
+ afterEach(() => {
45
+ rmSync(tmp, { recursive: true, force: true });
46
+ });
47
+
48
+ it('install creates config + identity + invokes service-host', async () => {
49
+ const fake = new FakeServiceHost();
50
+ const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
51
+ const result = await installer.install({
52
+ nonInteractive: true,
53
+ dataDir: tmp,
54
+ uiPort: 19999,
55
+ libp2pPort: 14001,
56
+ openBrowser: false,
57
+ noInvite: true,
58
+ serviceHost: fake,
59
+ });
60
+
61
+ expect(result.dataDir).toBe(tmp);
62
+ expect(result.uiUrl).toBe('http://127.0.0.1:19999/');
63
+ expect(result.serviceName).toBe('cadre-host');
64
+ expect(result.configPath).toBe(join(tmp, 'host.config.json'));
65
+
66
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
67
+ expect(existsSync(join(tmp, 'identity.key'))).toBe(true);
68
+ expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
69
+ expect(existsSync(join(tmp, 'logs'))).toBe(true);
70
+
71
+ expect(fake.installCalls).toHaveLength(1);
72
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
73
+ });
74
+
75
+ it('noService writes the data dir without registering a service', async () => {
76
+ const fake = new FakeServiceHost();
77
+ const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
78
+ const result = await installer.install({
79
+ nonInteractive: true,
80
+ dataDir: tmp,
81
+ uiPort: 19995,
82
+ libp2pPort: 14005,
83
+ noService: true,
84
+ serviceHost: fake,
85
+ });
86
+
87
+ expect(fake.installCalls).toHaveLength(0);
88
+ expect(result.serviceName).toBeUndefined();
89
+ expect(result.uiUrl).toBe('http://127.0.0.1:19995/');
90
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
91
+ expect(existsSync(join(tmp, 'identity.key'))).toBe(true);
92
+ expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
93
+ });
94
+
95
+ it('noService on an interactive install skips the enrollment-invite fetch', async () => {
96
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
97
+ try {
98
+ const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
99
+ const result = await installer.install({
100
+ nonInteractive: false,
101
+ noService: true,
102
+ wizard: async () => ({
103
+ dataDir: tmp,
104
+ uiPort: 19994,
105
+ libp2pPort: 14006,
106
+ upnpEnabled: false,
107
+ configureDdns: false,
108
+ ownCadre: false,
109
+ }),
110
+ });
111
+
112
+ expect(fetchSpy).not.toHaveBeenCalled();
113
+ expect(result.enrollmentInvite).toBeUndefined();
114
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
115
+ } finally {
116
+ fetchSpy.mockRestore();
117
+ }
118
+ });
119
+
120
+ it('uninstall removes data only when --remove-data is set', async () => {
121
+ const fake = new FakeServiceHost();
122
+ const installer = new Installer({ platform: 'linux' });
123
+ await installer.install({
124
+ nonInteractive: true,
125
+ dataDir: tmp,
126
+ uiPort: 19998,
127
+ libp2pPort: 14002,
128
+ openBrowser: false,
129
+ noInvite: true,
130
+ serviceHost: fake,
131
+ });
132
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
133
+
134
+ await installer.uninstall({ yes: true, removeData: false, dataDir: tmp, serviceHost: fake });
135
+ expect(fake.uninstallCalls).toHaveLength(1);
136
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
137
+
138
+ await installer.uninstall({ yes: true, removeData: true, dataDir: tmp, serviceHost: fake });
139
+ expect(existsSync(tmp)).toBe(false);
140
+ });
141
+
142
+ it('rejects --system in v1', async () => {
143
+ const fake = new FakeServiceHost();
144
+ const installer = new Installer({ platform: 'linux' });
145
+ await expect(installer.install({
146
+ nonInteractive: true,
147
+ dataDir: tmp,
148
+ system: true,
149
+ serviceHost: fake,
150
+ openBrowser: false,
151
+ noInvite: true,
152
+ })).rejects.toThrow(/system.*not yet supported/);
153
+ expect(fake.installCalls).toHaveLength(0);
154
+ });
155
+
156
+ it('passes CLI overrides to the wizard as suggested defaults', async () => {
157
+ // Regression: the interactive wizard previously showed the platform
158
+ // default (`~/.local/share/cadre-host`) in the prompt even when the
159
+ // caller passed `--data-dir <path>`. Whatever the user typed was then
160
+ // silently discarded because the CLI flag won post-hoc. The fix: CLI
161
+ // overrides become the wizard's suggested defaults, and the wizard's
162
+ // returned answers stand on their own.
163
+ const fake = new FakeServiceHost();
164
+ const installer = new Installer({ platform: 'linux' });
165
+ let seenDefaults: { dataDir: string; uiPort: number; libp2pPort: number; upnpEnabled: boolean; ownCadre: boolean } | undefined;
166
+ await installer.install({
167
+ nonInteractive: false,
168
+ dataDir: tmp,
169
+ uiPort: 19997,
170
+ libp2pPort: 14003,
171
+ noUpnp: true,
172
+ openBrowser: false,
173
+ noInvite: true,
174
+ serviceHost: fake,
175
+ wizard: async (defaults) => {
176
+ seenDefaults = { ...defaults };
177
+ // Simulate the user pressing Enter at every prompt.
178
+ return {
179
+ dataDir: defaults.dataDir,
180
+ uiPort: defaults.uiPort,
181
+ libp2pPort: defaults.libp2pPort,
182
+ upnpEnabled: defaults.upnpEnabled,
183
+ configureDdns: false,
184
+ ownCadre: defaults.ownCadre,
185
+ };
186
+ },
187
+ });
188
+
189
+ expect(seenDefaults).toEqual({
190
+ dataDir: tmp,
191
+ uiPort: 19997,
192
+ libp2pPort: 14003,
193
+ upnpEnabled: false,
194
+ ownCadre: false,
195
+ });
196
+ expect(fake.installCalls).toHaveLength(1);
197
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
198
+ });
199
+
200
+ it('honors wizard-typed input when no CLI override is passed', async () => {
201
+ // Mirror of the previous test: with no `--data-dir`, the wizard's
202
+ // returned value is what the installer uses.
203
+ const fake = new FakeServiceHost();
204
+ const installer = new Installer({ platform: 'linux' });
205
+ await installer.install({
206
+ nonInteractive: false,
207
+ openBrowser: false,
208
+ noInvite: true,
209
+ serviceHost: fake,
210
+ wizard: async () => ({
211
+ dataDir: tmp,
212
+ uiPort: 19996,
213
+ libp2pPort: 14004,
214
+ upnpEnabled: true,
215
+ configureDdns: false,
216
+ ownCadre: false,
217
+ }),
218
+ });
219
+ expect(fake.installCalls).toHaveLength(1);
220
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
221
+ });
222
+
223
+ it('writes ownCadre.enabled=false by default and true when opted in', async () => {
224
+ const { readHostConfig } = await import('../config.js');
225
+
226
+ // Default (donor-only): --own-cadre absent → ownCadre.enabled false.
227
+ const fakeA = new FakeServiceHost();
228
+ const installerA = new Installer({ platform: 'linux' });
229
+ await installerA.install({
230
+ nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fakeA,
231
+ });
232
+ const donorCfg = readHostConfig(join(tmp, 'host.config.json'));
233
+ expect(donorCfg.ownCadre).toEqual({ enabled: false });
234
+
235
+ // Opt-in: ownCadre:true (the --own-cadre flag) → ownCadre.enabled true.
236
+ const tmp2 = mkdtempSync(join(tmpdir(), 'cadre-host-installer-owncadre-'));
237
+ try {
238
+ const fakeB = new FakeServiceHost();
239
+ const installerB = new Installer({ platform: 'linux' });
240
+ await installerB.install({
241
+ nonInteractive: true, dataDir: tmp2, ownCadre: true, openBrowser: false, noInvite: true, serviceHost: fakeB,
242
+ });
243
+ const founderCfg = readHostConfig(join(tmp2, 'host.config.json'));
244
+ expect(founderCfg.ownCadre).toEqual({ enabled: true });
245
+ } finally {
246
+ rmSync(tmp2, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ it('re-running install preserves the existing identity', async () => {
251
+ const fake = new FakeServiceHost();
252
+ const installer = new Installer({ platform: 'linux' });
253
+ await installer.install({
254
+ nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fake,
255
+ });
256
+ const idBefore = (await import('node:fs')).readFileSync(join(tmp, 'identity.key'));
257
+ await installer.install({
258
+ nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fake,
259
+ });
260
+ const idAfter = (await import('node:fs')).readFileSync(join(tmp, 'identity.key'));
261
+ expect(Buffer.from(idBefore).equals(Buffer.from(idAfter))).toBe(true);
262
+ });
263
+ });