@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
@@ -14,9 +14,8 @@ import { dirname, join, resolve as resolvePath } from 'node:path';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
 
16
16
  import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
17
- import { allocateNodePorts, PortAllocator } from '../orchestrator/port-allocator.js';
18
17
  import { rotateOnDisk } from '../orchestrator/log-rotator.js';
19
- import { decodeDockerId, encodeDockerId } from '../orchestrator/types.js';
18
+ import { decodeDockerId, encodeDockerId, type NodePorts } from '../orchestrator/types.js';
20
19
  import { StateStore } from '../orchestrator/state-store.js';
21
20
  import { isPidAlive } from '../orchestrator/pid-liveness.js';
22
21
  import { loadIdentity } from '../installer/identity.js';
@@ -165,7 +164,7 @@ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
165
164
  // The donated-node respawn path calls createContainer again with the id of a
166
165
  // container the host still holds a (now dead) handle for. Handles are keyed by
167
166
  // the per-spawn dockerId, so without an explicit drop the old one would linger
168
- // forever and its four ports would never come back.
167
+ // forever and its ports would never come back.
169
168
  it('drops the stale handle, releases its ports, and keeps the identity', async () => {
170
169
  const orch = makeOrchestrator();
171
170
  const rootDir = (orch as unknown as { rootDir: string }).rootDir;
@@ -197,6 +196,58 @@ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
197
196
  // same node rather than a new one the borrower's cadre has never approved.
198
197
  expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
199
198
  });
199
+
200
+ // A requester that dials the node itself (a phone) knows it only by the address it
201
+ // was given, so a re-spawn must come back on the SAME ports even when the allocator
202
+ // has lower ones free. The test above cannot tell reuse from lowest-free: there the
203
+ // old ports are also the lowest free ones.
204
+ it('comes back on its own ports even when lower ones are free', async () => {
205
+ const orch = makeOrchestrator();
206
+
207
+ const lower = await orch.createContainer(makeRequest('c0'));
208
+ const first = await orch.createContainer(makeRequest('c1'));
209
+ await waitFor(() => orch.isRunning(first.dockerId));
210
+ const portsBefore = orch.getNode('c1')!.ports;
211
+
212
+ await orch.removeContainer(lower.dockerId);
213
+ const { pid } = decodeDockerId(first.dockerId);
214
+ process.kill(pid, 'SIGKILL');
215
+ await waitFor(() => !isPidAlive(pid));
216
+
217
+ await orch.createContainer(makeRequest('c1'));
218
+ expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
219
+ });
220
+
221
+ // A handle written to state.json before the WebSocket port existed has no `ws`.
222
+ // Rehydrating it must hold the ports it does have, and its re-spawn must keep those
223
+ // and gain a WebSocket port rather than trip over the missing key.
224
+ it('re-spawns a handle persisted without a ws port on its old ports plus a fresh ws', async () => {
225
+ const rootDir = join(tmpRoot, 'legacy');
226
+ const legacyPorts = { health: 18010, metrics: 18011, p2p: 18012, admin: 18013 };
227
+ new StateStore(rootDir).save({
228
+ version: 1,
229
+ handles: [{
230
+ containerId: 'c1',
231
+ dockerId: encodeDockerId(999999, 'deadbeef'),
232
+ pid: 999999,
233
+ startupToken: 'deadbeef',
234
+ workdir: join(rootDir, 'c1'),
235
+ ports: legacyPorts as NodePorts,
236
+ spawnedAt: new Date(0).toISOString(),
237
+ partyId: 'party-c1',
238
+ profile: 'transaction',
239
+ }],
240
+ });
241
+ const orch = makeOrchestrator({ rootDir });
242
+ await orch.init();
243
+
244
+ await orch.createContainer(makeRequest('c1'));
245
+
246
+ const ports = orch.getNode('c1')!.ports;
247
+ expect(ports).toMatchObject(legacyPorts);
248
+ // The lowest free port: nothing below 18010 was held, `undefined` included.
249
+ expect(ports.ws).toBe(18000);
250
+ });
200
251
  });
201
252
 
202
253
  /**
@@ -261,7 +312,7 @@ describe('HostProcessOrchestrator failed launch', () => {
261
312
  rmSync(storage, { force: true });
262
313
  const second = await orch.createContainer(makeRequest('c1'));
263
314
 
264
- // The restored handle held its four ports across the failure, and the
315
+ // The restored handle held its ports across the failure, and the
265
316
  // allocator hands back the lowest free port — so identical ports prove the
266
317
  // failed attempt neither leaked nor stole them.
267
318
  expect(second.p2pPort).toBe(first.p2pPort);
@@ -279,16 +330,16 @@ describe('HostProcessOrchestrator failed launch', () => {
279
330
  const rootDir = (orch as unknown as { rootDir: string }).rootDir;
280
331
 
281
332
  await orch.createContainer(makeRequest('c1'));
282
- expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003 });
333
+ expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003, ws: 12004 });
283
334
 
284
- // No prior handle to restore here — the four ports are simply released.
335
+ // No prior handle to restore here — the five ports are simply released.
285
336
  mkdirSync(join(rootDir, 'c2'), { recursive: true });
286
337
  writeFileSync(join(rootDir, 'c2', 'storage'), 'not-a-directory', 'utf8');
287
338
  await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow();
288
339
 
289
340
  await orch.createContainer(makeRequest('c3'));
290
- // With the leak these would start at 12008.
291
- expect(orch.getNode('c3')!.ports).toEqual({ health: 12004, metrics: 12005, p2p: 12006, admin: 12007 });
341
+ // With the leak these would start at 12010.
342
+ expect(orch.getNode('c3')!.ports).toEqual({ health: 12005, metrics: 12006, p2p: 12007, admin: 12008, ws: 12009 });
292
343
  });
293
344
 
294
345
  it('leaves the owner node addressable on its original ports', async () => {
@@ -348,7 +399,7 @@ describe('HostProcessOrchestrator failed launch', () => {
348
399
  // persist must not reach the unwind that would delete it.
349
400
  expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
350
401
 
351
- // Its ports are still held: a fresh container gets four different ones.
402
+ // Its ports are still held: a fresh container gets different ones.
352
403
  const other = await orch.createContainer(makeRequest('c2'));
353
404
  expect(other.p2pPort).not.toBe(second.p2pPort);
354
405
  expect(orch.getNode('c2')!.ports).not.toEqual(orch.getNode('c1')!.ports);
@@ -361,8 +412,8 @@ describe('HostProcessOrchestrator failed launch', () => {
361
412
  // unwind then correctly declines to delete it. Exhausting the port range
362
413
  // fails the very next step instead, with the workdir genuinely fresh.
363
414
  it('discards the workdir a failed first spawn created', async () => {
364
- // Exactly four ports — enough for one node and no more.
365
- const orch = makeOrchestrator({ portRange: { start: 13000, end: 13003 } });
415
+ // Exactly five ports — enough for one node and no more.
416
+ const orch = makeOrchestrator({ portRange: { start: 13000, end: 13004 } });
366
417
  const rootDir = (orch as unknown as { rootDir: string }).rootDir;
367
418
 
368
419
  await orch.createContainer(makeRequest('c1'));
@@ -494,7 +545,7 @@ describe('HostProcessOrchestrator.reclaimWorkdir', () => {
494
545
  });
495
546
 
496
547
  describe('HostProcessOrchestrator.isRunning', () => {
497
- it('is true after spawn, false after kill, false on tampered token', async () => {
548
+ it('is true after spawn and false after kill for a child this orchestrator spawned', async () => {
498
549
  const orch = makeOrchestrator();
499
550
  const { dockerId } = await orch.createContainer(makeRequest('c1'));
500
551
  const { pid } = decodeDockerId(dockerId);
@@ -502,20 +553,137 @@ describe('HostProcessOrchestrator.isRunning', () => {
502
553
  await waitFor(() => orch.isRunning(dockerId));
503
554
  expect(await orch.isRunning(dockerId)).toBe(true);
504
555
 
505
- // Tamper with the token file
556
+ // The token file is not consulted for a child this process spawned — its
557
+ // ChildProcess is exact, and the file has a start-up window it does not.
506
558
  const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
507
559
  writeFileSync(join(workdir, '.startup-token'), 'tampered', 'utf8');
508
- expect(await orch.isRunning(dockerId)).toBe(false);
509
-
510
- // Restore token, kill the process
511
- const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
512
- writeFileSync(join(workdir, '.startup-token'), persisted.handles[0]!.startupToken, 'utf8');
513
560
  expect(await orch.isRunning(dockerId)).toBe(true);
514
561
 
515
562
  process.kill(pid, 'SIGKILL');
516
563
  await waitFor(() => !isPidAlive(pid));
517
564
  expect(await orch.isRunning(dockerId)).toBe(false);
518
565
  });
566
+
567
+ it('checks a re-attached handle by pid plus startup token', async () => {
568
+ const rootDir = join(tmpRoot, 'reattach');
569
+ const a = makeOrchestrator({ rootDir });
570
+ const { dockerId } = await a.createContainer(makeRequest('c1'));
571
+ const { pid } = decodeDockerId(dockerId);
572
+ await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
573
+
574
+ // A new orchestrator has no ChildProcess for it — the host-restart case.
575
+ const b = makeOrchestrator({ rootDir });
576
+ await b.init();
577
+ expect(await b.isRunning(dockerId)).toBe(true);
578
+
579
+ // A live pid whose token does not match may be an unrelated process that
580
+ // inherited the pid, so it does not count as ours.
581
+ const tokenPath = join(rootDir, 'c1', '.startup-token');
582
+ const token = readFileSync(tokenPath, 'utf8');
583
+ writeFileSync(tokenPath, 'tampered', 'utf8');
584
+ expect(await b.isRunning(dockerId)).toBe(false);
585
+
586
+ writeFileSync(tokenPath, token, 'utf8');
587
+ expect(await b.isRunning(dockerId)).toBe(true);
588
+
589
+ process.kill(pid, 'SIGKILL');
590
+ await waitFor(() => !isPidAlive(pid));
591
+ expect(await b.isRunning(dockerId)).toBe(false);
592
+ });
593
+ });
594
+
595
+ /**
596
+ * A child that binds its health port at once but writes its startup token only
597
+ * after `TOKEN_DELAY_MS` — the shape of a real `cadre-cli start` built before the
598
+ * token write moved to the front, and of any child that is slow to reach it. Dies
599
+ * on a port clash the way `cadre-cli start` does.
600
+ *
601
+ * The incident this guards against: the donation supervisor asked `isRunning`
602
+ * during that window, got `false`, and re-spawned the container. The re-spawn
603
+ * released and reused the same ports, rotated the seed token, and returned
604
+ * success; the second child then died on the port the first still held, leaving
605
+ * the host's records naming a dead child and the live one refusing every seed.
606
+ */
607
+ describe('HostProcessOrchestrator re-spawn over a child that is still starting', () => {
608
+ const TOKEN_DELAY_MS = 3000;
609
+ const SLOW_TOKEN_CHILD = `
610
+ import fs from 'node:fs';
611
+ import net from 'node:net';
612
+ const args = process.argv.slice(2);
613
+ const tokenPath = args[args.indexOf('--startup-token-file') + 1];
614
+ const server = net.createServer();
615
+ server.on('error', (err) => { console.error('Failed to start cadre node: ' + err.message); process.exit(1); });
616
+ server.listen(Number(process.env.CADRE_HEALTH_PORT), '0.0.0.0', () => {
617
+ console.log('listening');
618
+ setTimeout(() => fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN), ${TOKEN_DELAY_MS});
619
+ });
620
+ process.on('SIGTERM', () => process.exit(0));
621
+ setInterval(() => {}, 1 << 30);
622
+ `;
623
+ const TEST_TIMEOUT_MS = 20_000;
624
+
625
+ let slowChildPath: string;
626
+ beforeEach(() => {
627
+ slowChildPath = join(tmpRoot, 'slow-token-child.mjs');
628
+ writeFileSync(slowChildPath, SLOW_TOKEN_CHILD, 'utf8');
629
+ });
630
+
631
+ it('reports the child running before its token is written, and refuses to launch over it', async () => {
632
+ const orch = makeOrchestrator({ spawn: { entrypoint: slowChildPath } });
633
+ const rootDir = (orch as unknown as { rootDir: string }).rootDir;
634
+ const tokenPath = join(rootDir, 'c1', '.startup-token');
635
+
636
+ const first = await orch.createContainer(makeRequest('c1'));
637
+ await waitFor(async () => (await orch.getLogs(first.dockerId)).includes('listening'));
638
+ const portsBefore = orch.getNode('c1')!.ports;
639
+
640
+ // Inside the start-up window: port bound, token not yet written.
641
+ expect(existsSync(tokenPath)).toBe(false);
642
+ expect(await orch.isRunning(first.dockerId)).toBe(true);
643
+
644
+ // Exactly what `DonationService.respawn` would do next.
645
+ await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/c1 is still running/);
646
+
647
+ // Nothing was dropped, released or rotated.
648
+ expect(orch.listNodes()).toHaveLength(1);
649
+ expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
650
+ expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
651
+ const other = await orch.createContainer(makeRequest('c2'));
652
+ expect(other.p2pPort).not.toBe(first.p2pPort);
653
+ expect(other.healthEndpoint).not.toBe(first.healthEndpoint);
654
+
655
+ // The first child finishes starting undisturbed, with the token it was given.
656
+ const { pid, token } = decodeDockerId(first.dockerId);
657
+ await waitFor(() => existsSync(tokenPath), TOKEN_DELAY_MS + 5000);
658
+ expect(readFileSync(tokenPath, 'utf8')).toBe(token);
659
+ expect(isPidAlive(pid)).toBe(true);
660
+ expect(await orch.getLogs(first.dockerId)).not.toContain('EADDRINUSE');
661
+ }, TEST_TIMEOUT_MS);
662
+
663
+ it('refuses to launch over a re-attached child whose ports are still bound', async () => {
664
+ const rootDir = join(tmpRoot, 'reattach-starting');
665
+ const a = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
666
+ const first = await a.createContainer(makeRequest('c1'));
667
+ await waitFor(async () => (await a.getLogs(first.dockerId)).includes('listening'));
668
+
669
+ // A host restart while the child is mid-start: no ChildProcess, no token yet.
670
+ const b = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
671
+ await b.init();
672
+ expect(await b.isRunning(first.dockerId)).toBe(false);
673
+
674
+ await expect(b.createContainer(makeRequest('c1')))
675
+ .rejects.toThrow(/c1 may still be running: port \d+ on 0\.0\.0\.0 is unavailable/);
676
+
677
+ // Nothing dropped: the record's dockerId still resolves on its ports, and
678
+ // the live child was not killed on unverified evidence.
679
+ expect(b.listNodes()).toHaveLength(1);
680
+ expect(b.getNode('c1')?.dockerId).toBe(first.dockerId);
681
+ const { pid } = decodeDockerId(first.dockerId);
682
+ expect(isPidAlive(pid)).toBe(true);
683
+
684
+ // Once it has written its token it is recognised as running.
685
+ await waitFor(() => b.isRunning(first.dockerId), TOKEN_DELAY_MS + 5000);
686
+ }, TEST_TIMEOUT_MS);
519
687
  });
520
688
 
521
689
  describe('HostProcessOrchestrator.stopContainer', () => {
@@ -704,73 +872,16 @@ describe('rotateOnDisk', () => {
704
872
  });
705
873
  });
706
874
 
707
- describe('PortAllocator', () => {
708
- it('allocates sequentially, releases, and reuses', () => {
709
- const a = new PortAllocator(100, 102);
710
- expect(a.allocate()).toBe(100);
711
- expect(a.allocate()).toBe(101);
712
- expect(a.allocate()).toBe(102);
713
- expect(() => a.allocate()).toThrow(/No available ports/);
714
- a.release(101);
715
- expect(a.allocate()).toBe(101);
716
- });
717
-
718
- it('markUsed reserves ports without allocating', () => {
719
- const a = new PortAllocator(100, 102);
720
- a.markUsed(101);
721
- expect(a.allocate()).toBe(100);
722
- expect(a.allocate()).toBe(102);
723
- expect(() => a.allocate()).toThrow();
724
- });
725
- });
726
-
727
- describe('allocateNodePorts', () => {
728
- it('allocates the four ports in a fixed order', () => {
729
- const a = new PortAllocator(10000, 10010);
730
- expect(allocateNodePorts(a)).toEqual({ health: 10000, metrics: 10001, p2p: 10002, admin: 10003 });
731
- });
732
-
733
- // A partial set left reserved would leak from a bounded range on every failed
734
- // provision — the whole reason this helper exists rather than four allocates.
735
- it('is all-or-nothing: an exhausted range releases everything it took', () => {
736
- const a = new PortAllocator(13000, 13002);
737
- expect(() => allocateNodePorts(a)).toThrow(/No available ports/);
738
- expect(a.has(13000)).toBe(false);
739
- expect(a.has(13001)).toBe(false);
740
- expect(a.has(13002)).toBe(false);
741
- });
742
-
743
- // The owner node's p2p port is pinned by the NAT mapping, not allocated.
744
- it('honours an override and reserves it', () => {
745
- const a = new PortAllocator(10000, 10010);
746
- expect(allocateNodePorts(a, { p2p: 10005 })).toEqual({
747
- health: 10000,
748
- metrics: 10001,
749
- p2p: 10005,
750
- admin: 10002,
751
- });
752
- // Reserved, so no later allocation can hand it out again.
753
- expect(a.has(10005)).toBe(true);
754
- });
755
-
756
- // An out-of-range override is the production case for the owner node: its
757
- // libp2p port is chosen by NAT config, not by the allocator, so reserving it
758
- // is a documented no-op rather than an error.
759
- it('accepts an override outside the managed range without reserving it', () => {
760
- const a = new PortAllocator(10000, 10010);
761
- expect(allocateNodePorts(a, { p2p: 40000 })).toMatchObject({ p2p: 40000, health: 10000 });
762
- expect(a.has(40000)).toBe(false);
763
- });
764
- });
765
-
766
875
  describe('Restart recovery (init)', () => {
767
876
  it('re-attaches to surviving children and reports dead ones', async () => {
768
877
  const rootDir = join(tmpRoot, 'restart');
769
878
  const a = makeOrchestrator({ rootDir });
770
879
  const r1 = await a.createContainer(makeRequest('c1'));
771
880
  const r2 = await a.createContainer(makeRequest('c2'));
772
- await waitFor(() => a.isRunning(r1.dockerId));
773
- await waitFor(() => a.isRunning(r2.dockerId));
881
+ // Not `a.isRunning`: that answers from the ChildProcess at once, but `b`
882
+ // below can only recognise c1 once c1 has written its startup token.
883
+ await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
884
+ await waitFor(() => existsSync(join(rootDir, 'c2', '.startup-token')));
774
885
 
775
886
  // Kill c2 externally
776
887
  const { pid: pid2 } = decodeDockerId(r2.dockerId);
package/src/bin/host.ts CHANGED
@@ -102,6 +102,7 @@ program
102
102
  .option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
103
103
  .option('--system', 'System-wide install (not yet supported in v1)')
104
104
  .option('--node-path <path>', 'Override the node binary embedded in the service unit')
105
+ .option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
105
106
  .action(async (opts: {
106
107
  nonInteractive?: boolean;
107
108
  dataDir?: string;
@@ -113,6 +114,7 @@ program
113
114
  ownCadre?: boolean;
114
115
  system?: boolean;
115
116
  nodePath?: string;
117
+ service?: boolean;
116
118
  }) => {
117
119
  const installer = new Installer();
118
120
  try {
@@ -127,11 +129,17 @@ program
127
129
  ownCadre: opts.ownCadre === true,
128
130
  system: Boolean(opts.system),
129
131
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
132
+ noService: opts.service === false,
130
133
  });
131
134
  console.log(`cadre-host installed.`);
132
135
  console.log(` Data dir: ${result.dataDir}`);
133
136
  console.log(` UI: ${result.uiUrl}`);
134
- console.log(` Service: ${result.serviceName}`);
137
+ if (result.serviceName) {
138
+ console.log(` Service: ${result.serviceName}`);
139
+ } else {
140
+ console.log(' Service: not registered (--no-service)');
141
+ console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
142
+ }
135
143
  if (result.enrollmentInvite) {
136
144
  printEnrollmentInvite(result.enrollmentInvite);
137
145
  }