@serfab/cadre-host 0.7.1 → 0.8.1

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 (135) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +44 -6
  3. package/dist/auth/trust-circle.js +115 -58
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/authority/authority-node-client.d.ts +77 -0
  8. package/dist/authority/authority-node-client.js +143 -0
  9. package/dist/authority/authority-node-client.js.map +1 -0
  10. package/dist/authority/index.d.ts +11 -0
  11. package/dist/authority/index.js +11 -0
  12. package/dist/authority/index.js.map +1 -0
  13. package/dist/bin/host.js +210 -97
  14. package/dist/bin/host.js.map +1 -1
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/installer/browser.js +16 -17
  19. package/dist/installer/browser.js.map +1 -1
  20. package/dist/installer/config.d.ts +25 -0
  21. package/dist/installer/config.js +1 -1
  22. package/dist/installer/config.js.map +1 -1
  23. package/dist/installer/index.d.ts +5 -2
  24. package/dist/installer/index.js +14 -10
  25. package/dist/installer/index.js.map +1 -1
  26. package/dist/installer/service-host/launchd.js +1 -1
  27. package/dist/installer/service-host/launchd.js.map +1 -1
  28. package/dist/installer/service-host/systemd.js +1 -1
  29. package/dist/installer/service-host/systemd.js.map +1 -1
  30. package/dist/nat/index.d.ts +1 -1
  31. package/dist/nat/nat-service.d.ts +45 -3
  32. package/dist/nat/nat-service.js +116 -3
  33. package/dist/nat/nat-service.js.map +1 -1
  34. package/dist/nat/secrets/index.d.ts +7 -0
  35. package/dist/nat/secrets/index.js +28 -2
  36. package/dist/nat/secrets/index.js.map +1 -1
  37. package/dist/nat/types.d.ts +3 -1
  38. package/dist/nat/types.js.map +1 -1
  39. package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
  40. package/dist/orchestrator/host-process-orchestrator.js +246 -39
  41. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  42. package/dist/orchestrator/index.d.ts +3 -2
  43. package/dist/orchestrator/index.js +1 -1
  44. package/dist/orchestrator/index.js.map +1 -1
  45. package/dist/orchestrator/state-store.d.ts +6 -5
  46. package/dist/orchestrator/state-store.js +10 -2
  47. package/dist/orchestrator/state-store.js.map +1 -1
  48. package/dist/orchestrator/types.d.ts +45 -10
  49. package/dist/orchestrator/types.js.map +1 -1
  50. package/dist/push/index.d.ts +67 -0
  51. package/dist/push/index.js +101 -0
  52. package/dist/push/index.js.map +1 -0
  53. package/dist/server/error-handler.d.ts +2 -0
  54. package/dist/server/error-handler.js +4 -0
  55. package/dist/server/error-handler.js.map +1 -1
  56. package/dist/server/routes/nodes.d.ts +4 -3
  57. package/dist/server/routes/nodes.js +38 -24
  58. package/dist/server/routes/nodes.js.map +1 -1
  59. package/dist/server/static.d.ts +12 -0
  60. package/dist/server/static.js +24 -7
  61. package/dist/server/static.js.map +1 -1
  62. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  63. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  64. package/dist/ui/index.html +1 -1
  65. package/dist/update/apply.d.ts +23 -1
  66. package/dist/update/apply.js +46 -4
  67. package/dist/update/apply.js.map +1 -1
  68. package/dist/update/index.d.ts +14 -3
  69. package/dist/update/index.js +35 -2
  70. package/dist/update/index.js.map +1 -1
  71. package/dist/update/manifest.d.ts +28 -9
  72. package/dist/update/manifest.js +56 -29
  73. package/dist/update/manifest.js.map +1 -1
  74. package/dist/update/release-key.d.ts +18 -7
  75. package/dist/update/release-key.js +28 -15
  76. package/dist/update/release-key.js.map +1 -1
  77. package/dist/update/sign.d.ts +46 -0
  78. package/dist/update/sign.js +53 -0
  79. package/dist/update/sign.js.map +1 -0
  80. package/package.json +9 -4
  81. package/service/README.md +95 -86
  82. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  83. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  84. package/src/__tests__/cli.smoke.test.ts +6 -1
  85. package/src/__tests__/host-authority.smoke.test.ts +123 -0
  86. package/src/__tests__/orchestrator-authority.test.ts +198 -0
  87. package/src/__tests__/orchestrator-push.test.ts +145 -0
  88. package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
  89. package/src/auth/__tests__/trust-circle.test.ts +90 -23
  90. package/src/auth/trust-circle.ts +363 -304
  91. package/src/auth/types.ts +4 -1
  92. package/src/authority/__tests__/authority-node-client.test.ts +166 -0
  93. package/src/authority/authority-node-client.ts +199 -0
  94. package/src/authority/index.ts +12 -0
  95. package/src/bin/host.ts +1038 -905
  96. package/src/index.ts +34 -3
  97. package/src/installer/__tests__/installer.smoke.test.ts +64 -0
  98. package/src/installer/browser.ts +40 -42
  99. package/src/installer/config.ts +27 -1
  100. package/src/installer/index.ts +18 -12
  101. package/src/installer/service-host/launchd.ts +1 -1
  102. package/src/installer/service-host/systemd.ts +1 -1
  103. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  104. package/src/nat/__tests__/nat-service.test.ts +192 -0
  105. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  106. package/src/nat/__tests__/secrets.test.ts +23 -1
  107. package/src/nat/index.ts +1 -1
  108. package/src/nat/nat-service.ts +131 -5
  109. package/src/nat/secrets/index.ts +32 -2
  110. package/src/nat/types.ts +3 -1
  111. package/src/orchestrator/host-process-orchestrator.ts +294 -54
  112. package/src/orchestrator/index.ts +9 -2
  113. package/src/orchestrator/state-store.ts +21 -3
  114. package/src/orchestrator/types.ts +48 -2
  115. package/src/push/__tests__/push.test.ts +90 -0
  116. package/src/push/index.ts +146 -0
  117. package/src/server/__tests__/error-handler.test.ts +4 -4
  118. package/src/server/__tests__/nodes-route.test.ts +90 -10
  119. package/src/server/__tests__/publishers.test.ts +1 -1
  120. package/src/server/__tests__/static.test.ts +145 -0
  121. package/src/server/__tests__/status-route.test.ts +2 -2
  122. package/src/server/error-handler.ts +4 -0
  123. package/src/server/routes/nodes.ts +48 -26
  124. package/src/server/static.ts +24 -8
  125. package/src/update/__tests__/apply.test.ts +58 -1
  126. package/src/update/__tests__/manifest.test.ts +41 -6
  127. package/src/update/__tests__/release-key.test.ts +29 -0
  128. package/src/update/__tests__/sign.test.ts +78 -0
  129. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  130. package/src/update/apply.ts +46 -4
  131. package/src/update/index.ts +41 -3
  132. package/src/update/manifest.ts +214 -172
  133. package/src/update/release-key.ts +30 -16
  134. package/src/update/sign.ts +74 -0
  135. package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/src/bin/host.ts CHANGED
@@ -1,905 +1,1038 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * CLI entrypoint for cadre-host — the self-hosted cadre node manager.
5
- *
6
- * Most subcommands are still stubs at this stage (real implementations land
7
- * in the sibling tickets cadre-host-nat, cadre-host-installer,
8
- * cadre-host-local-ui).
9
- *
10
- * The `invite` and `trust` commands talk to the running cadre-host
11
- * management API over loopback. They are thin HTTP clients — they don't
12
- * spin up an inline service, so cadre-host must be running.
13
- */
14
-
15
- import { existsSync, readFileSync } from 'node:fs';
16
- import { createRequire } from 'node:module';
17
- import { fileURLToPath } from 'node:url';
18
- import { dirname, join, resolve } from 'node:path';
19
-
20
- import { Command } from 'commander';
21
-
22
- import { parseDuration } from '../auth/duration.js';
23
- import { Installer } from '../installer/index.js';
24
- import { readHostConfig, updateHostConfig } from '../installer/config.js';
25
- import {
26
- configPath as resolveConfigPath,
27
- defaultDataDir,
28
- defaultHostJs,
29
- defaultServiceDir,
30
- identityPath as resolveIdentityPath,
31
- } from '../installer/paths.js';
32
- import { detectPlatform } from '../installer/platform.js';
33
- import { createServiceHost } from '../installer/service-host/index.js';
34
- import { UpdateService } from '../update/index.js';
35
- import { HostProcessOrchestrator } from '../orchestrator/index.js';
36
- import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
37
- import { NatService } from '../nat/index.js';
38
- import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
39
- import { openBrowser } from '../installer/browser.js';
40
-
41
- const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
42
-
43
- /** libp2p peer-ID prefixes by key type: Ed25519, secp256k1, legacy RSA. */
44
- const PEER_ID_PREFIXES = ['12D3Koo', '16Uiu2HAm', 'Qm'] as const;
45
-
46
- function looksLikePeerId(id: string): boolean {
47
- return PEER_ID_PREFIXES.some((p) => id.startsWith(p));
48
- }
49
-
50
- function resolvePort(raw: string): number {
51
- const n = Number(raw);
52
- if (!Number.isFinite(n) || n <= 0 || n > 65535) {
53
- // eslint-disable-next-line no-console
54
- console.error(`Invalid --port: ${raw}`);
55
- process.exit(1);
56
- }
57
- return n;
58
- }
59
-
60
- const program = new Command();
61
-
62
- program
63
- .name('cadre-host')
64
- .description('Sereus cadre node manager for self-hosted basement-PC deployments')
65
- .version('0.6.0');
66
-
67
- // ============================================================================
68
- // install — run the first-run wizard + register the system service
69
- // ============================================================================
70
-
71
- program
72
- .command('install')
73
- .description('Install cadre-host as a system service and run first-run setup')
74
- .option('--non-interactive', 'Use defaults / CLI flags only; never prompt')
75
- .option('--data-dir <path>', 'Override the data directory')
76
- .option('--ui-port <port>', 'Override the management UI port', parseIntArg)
77
- .option('--libp2p-port <port>', 'Override the cadre libp2p port', parseIntArg)
78
- .option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
79
- .option('--no-browser', 'Do not open a browser after install')
80
- .option('--no-invite', 'Skip generating the first enrollment invite')
81
- .option('--system', 'System-wide install (not yet supported in v1)')
82
- .option('--node-path <path>', 'Override the node binary embedded in the service unit')
83
- .action(async (opts: {
84
- nonInteractive?: boolean;
85
- dataDir?: string;
86
- uiPort?: number;
87
- libp2pPort?: number;
88
- upnp?: boolean;
89
- browser?: boolean;
90
- invite?: boolean;
91
- system?: boolean;
92
- nodePath?: string;
93
- }) => {
94
- const installer = new Installer();
95
- try {
96
- const result = await installer.install({
97
- nonInteractive: Boolean(opts.nonInteractive),
98
- ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
99
- ...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
100
- ...(typeof opts.libp2pPort === 'number' ? { libp2pPort: opts.libp2pPort } : {}),
101
- noUpnp: opts.upnp === false,
102
- openBrowser: opts.browser !== false,
103
- noInvite: opts.invite === false,
104
- system: Boolean(opts.system),
105
- ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
106
- });
107
- // eslint-disable-next-line no-console
108
- console.log(`cadre-host installed.`);
109
- // eslint-disable-next-line no-console
110
- console.log(` Data dir: ${result.dataDir}`);
111
- // eslint-disable-next-line no-console
112
- console.log(` UI: ${result.uiUrl}`);
113
- // eslint-disable-next-line no-console
114
- console.log(` Service: ${result.serviceName}`);
115
- if (result.enrollmentInvite) {
116
- printEnrollmentInvite(result.enrollmentInvite);
117
- }
118
- process.exit(0);
119
- } catch (err) {
120
- // eslint-disable-next-line no-console
121
- console.error(`install failed: ${(err as Error).message}`);
122
- process.exit(1);
123
- }
124
- });
125
-
126
- // ============================================================================
127
- // uninstall — stop + deregister the system service
128
- // ============================================================================
129
-
130
- program
131
- .command('uninstall')
132
- .description('Stop and uninstall the cadre-host service')
133
- .option('--yes', 'Skip the confirmation prompt (for scripts)')
134
- .option('--remove-data', 'Also remove the data directory (default: preserve)')
135
- .option('--data-dir <path>', 'Override the data directory to clean up')
136
- .action(async (opts: { yes?: boolean; removeData?: boolean; dataDir?: string }) => {
137
- const installer = new Installer();
138
- try {
139
- // --remove-data is destructive and irreversible (identity + trust circle
140
- // are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
141
- // confirmation when it is.
142
- if (opts.removeData && !opts.yes) {
143
- if (!process.stdin.isTTY) {
144
- console.error('uninstall: --remove-data requires --yes when stdin is not a TTY.');
145
- process.exit(1);
146
- return;
147
- }
148
- const dataDir = opts.dataDir ?? '(default data directory)';
149
- const confirmed = await confirmDestructive(
150
- `This will permanently delete ${dataDir} (identity, trust circle, NAT state). Continue? [y/N] `,
151
- );
152
- if (!confirmed) {
153
- console.error('uninstall aborted.');
154
- process.exit(1);
155
- return;
156
- }
157
- }
158
- await installer.uninstall({
159
- yes: Boolean(opts.yes),
160
- removeData: Boolean(opts.removeData),
161
- ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
162
- });
163
- // eslint-disable-next-line no-console
164
- console.log('cadre-host uninstalled.');
165
- process.exit(0);
166
- } catch (err) {
167
- // eslint-disable-next-line no-console
168
- console.error(`uninstall failed: ${(err as Error).message}`);
169
- process.exit(1);
170
- }
171
- });
172
-
173
- // ============================================================================
174
- // status — show service-host registration / running state
175
- // ============================================================================
176
-
177
- program
178
- .command('status')
179
- .description('Show running status of cadre-host and the cadre nodes it manages')
180
- .action(async () => {
181
- const installer = new Installer();
182
- try {
183
- const status = await installer.status();
184
- // eslint-disable-next-line no-console
185
- console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
186
- // eslint-disable-next-line no-console
187
- console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
188
- process.exit(0);
189
- } catch (err) {
190
- // eslint-disable-next-line no-console
191
- console.error(`status failed: ${(err as Error).message}`);
192
- process.exit(1);
193
- }
194
- });
195
-
196
- // ============================================================================
197
- // start — load config + identity, bind management API listener, wait
198
- // ============================================================================
199
-
200
- program
201
- .command('start')
202
- .description('Start cadre-host in the foreground')
203
- .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
204
- .option('--no-tui', 'Reserved — currently a no-op (no TUI implemented yet)')
205
- .action(async (opts: { dataDir?: string; tui?: boolean }) => {
206
- try {
207
- void opts.tui;
208
- const platform = detectPlatform();
209
- const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
210
- const cfgPath = resolveConfigPath(dataDir);
211
- if (!existsSync(cfgPath)) {
212
- // eslint-disable-next-line no-console
213
- console.error(
214
- `cadre-host start: ${cfgPath} not found. ` +
215
- `Run \`cadre-host install\` first (or pass --data-dir to an existing install).`,
216
- );
217
- process.exit(1);
218
- return;
219
- }
220
- const cfg = readHostConfig(cfgPath);
221
- const idPath = resolveIdentityPath(cfg.dataDir);
222
- if (!existsSync(idPath)) {
223
- // eslint-disable-next-line no-console
224
- console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
225
- process.exit(1);
226
- return;
227
- }
228
- // eslint-disable-next-line no-console
229
- console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
230
-
231
- // Update flow: notify-by-default; auto-apply opt-in via host.config.json.
232
- // The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
233
- // just construct the service so the in-process timer + state file are
234
- // populated.
235
- const serviceHost = createServiceHost(platform);
236
- const updateService = new UpdateService({
237
- dataDir: cfg.dataDir,
238
- currentVersion: readPackageVersionForStart(),
239
- settings: cfg.updates,
240
- ...(cfg.updates.manifestUrl ? { manifestUrl: cfg.updates.manifestUrl } : {}),
241
- restart: async () => {
242
- try {
243
- await serviceHost.restart({
244
- nodePath: process.execPath,
245
- hostJs: defaultHostJs(),
246
- dataDir: cfg.dataDir,
247
- serviceDir: defaultServiceDir(),
248
- });
249
- return undefined;
250
- } catch (err) {
251
- return (err as Error).message;
252
- }
253
- },
254
- });
255
- // Persist settings changes back to host.config.json on PUT /update/settings.
256
- updateService.onSettingsChanged((next) => {
257
- try {
258
- updateHostConfig(cfgPath, { updates: next });
259
- } catch (err) {
260
- // eslint-disable-next-line no-console
261
- console.error(`failed to persist updated settings to ${cfgPath}: ${(err as Error).message}`);
262
- }
263
- });
264
- // Don't block startup on the network round-trip — call and forget.
265
- void updateService.check();
266
- updateService.start();
267
-
268
- // Wire the long-lived HTTP management server. The trust-circle and
269
- // NAT services depend on a libp2p `CadreNodeLike`; v1 has no node-
270
- // spawning path yet (that's a follow-up ticket), so we inject minimal
271
- // stubs that fail loudly if a real call is attempted. The /auth/* and
272
- // /nat/* routes are still mounted so the CLI's existing HTTP contract
273
- // is intact — operations that genuinely need the libp2p node will
274
- // surface a NatError / TrustCircleError, mapped to 500 by the server.
275
- const orchestrator = new HostProcessOrchestrator({ rootDir: join(cfg.dataDir, 'orchestrator') });
276
- await orchestrator.init();
277
-
278
- const trustCircle = new TrustCircleService({
279
- cadreNode: missingCadreNodeStub('trust-circle'),
280
- store: new TrustCircleStore(cfg.dataDir),
281
- });
282
- const natService = new NatService({
283
- rootDir: cfg.dataDir,
284
- cadreNode: missingNatNodeStub(),
285
- });
286
- // Best-effort NAT start. Failures here aren't fatal — the local UI
287
- // can still serve the trust circle, settings, etc.
288
- try {
289
- await natService.start();
290
- } catch (err) {
291
- // eslint-disable-next-line no-console
292
- console.error(`NAT start failed: ${(err as Error).message}`);
293
- }
294
-
295
- const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
296
- const server = createLocalUiServer({
297
- uiPort: cfg.uiPort,
298
- dataDir: cfg.dataDir,
299
- orchestrator,
300
- trustCircle,
301
- nat: natService,
302
- update: updateService,
303
- settingsStore,
304
- });
305
- const { url, port } = await server.start();
306
- if (port !== cfg.uiPort) {
307
- // eslint-disable-next-line no-console
308
- console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
309
- }
310
- // eslint-disable-next-line no-console
311
- console.log(`cadre-host local UI: ${url}`);
312
-
313
- await waitForTermination();
314
- try { await server.stop(); } catch { /* ignore */ }
315
- try { await natService.stop(); } catch { /* ignore */ }
316
- updateService.stop();
317
- // eslint-disable-next-line no-console
318
- console.log('cadre-host stopped.');
319
- process.exit(0);
320
- } catch (err) {
321
- // eslint-disable-next-line no-console
322
- console.error(`start failed: ${(err as Error).message}`);
323
- process.exit(1);
324
- }
325
- });
326
-
327
- // ============================================================================
328
- // ui — print the local-UI URL and open it in a browser
329
- // ============================================================================
330
-
331
- program
332
- .command('ui')
333
- .description('Print the local-UI URL and open it in the default browser')
334
- .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
335
- .option('--no-browser', 'Print the URL but do not open a browser')
336
- .action((opts: { dataDir?: string; browser?: boolean }) => {
337
- try {
338
- const platform = detectPlatform();
339
- const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
340
- const cfgPath = resolveConfigPath(dataDir);
341
- if (!existsSync(cfgPath)) {
342
- // eslint-disable-next-line no-console
343
- console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
344
- process.exit(1);
345
- return;
346
- }
347
- const cfg = readHostConfig(cfgPath);
348
- const url = `http://127.0.0.1:${cfg.uiPort}`;
349
- // eslint-disable-next-line no-console
350
- console.log(url);
351
- if (opts.browser !== false) {
352
- const res = openBrowser(url);
353
- if (res.spawned) {
354
- // eslint-disable-next-line no-console
355
- console.log('(Opening in your default browser...)');
356
- } else if (res.error) {
357
- // eslint-disable-next-line no-console
358
- console.error(`(unable to launch browser: ${res.error})`);
359
- }
360
- }
361
- process.exit(0);
362
- } catch (err) {
363
- // eslint-disable-next-line no-console
364
- console.error(`ui failed: ${(err as Error).message}`);
365
- process.exit(1);
366
- }
367
- });
368
-
369
- function parseIntArg(raw: string): number {
370
- const n = Number(raw);
371
- if (!Number.isFinite(n) || !Number.isInteger(n)) {
372
- throw new Error(`Invalid integer: ${raw}`);
373
- }
374
- return n;
375
- }
376
-
377
- function readPackageVersionForStart(): string {
378
- try {
379
- // dist/bin/host.js -> ../../package.json
380
- const here = dirname(fileURLToPath(import.meta.url));
381
- const pkgPath = resolve(here, '..', '..', 'package.json');
382
- const raw = readFileSync(pkgPath, 'utf8');
383
- const parsed = JSON.parse(raw) as { version?: string };
384
- return parsed.version ?? '0.0.0-unknown';
385
- } catch {
386
- return '0.0.0-unknown';
387
- }
388
- }
389
-
390
- /**
391
- * Returns a `CadreNodeLike` for TrustCircleService that fails loudly on any
392
- * libp2p call. cadre-host v1 doesn't yet spawn the host's own cadre node —
393
- * that ticket is separate. The trust-circle HTTP endpoints still load the
394
- * local labels/pending file (listing works), but issuing/redeeming an invite
395
- * requires a real node and surfaces a 500.
396
- */
397
- function missingCadreNodeStub(name: string): import('../auth/trust-circle.js').CadreNodeLike {
398
- return {
399
- async createInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot issue invite`); },
400
- async acceptPhone() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot redeem`); },
401
- async removePeer() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot remove peer`); },
402
- encodeInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot encode invite`); },
403
- getControlDatabase() { return null; },
404
- };
405
- }
406
-
407
- function missingNatNodeStub(): import('../nat/nat-service.js').CadreNodeLike {
408
- return {
409
- getPeerId() { return ''; },
410
- getMultiaddrs() { return []; },
411
- };
412
- }
413
-
414
- function waitForTermination(): Promise<void> {
415
- return new Promise<void>((resolveTerm) => {
416
- const done = () => resolveTerm();
417
- process.once('SIGINT', done);
418
- process.once('SIGTERM', done);
419
- });
420
- }
421
-
422
- async function confirmDestructive(message: string): Promise<boolean> {
423
- const { createInterface } = await import('node:readline');
424
- const rl = createInterface({ input: process.stdin, output: process.stdout });
425
- try {
426
- const answer = await new Promise<string>((res) => rl.question(message, res));
427
- return /^(y|yes)$/i.test(answer.trim());
428
- } finally {
429
- rl.close();
430
- }
431
- }
432
-
433
- const requireForQr = createRequire(import.meta.url);
434
-
435
- function printEnrollmentInvite(invite: { encodedInvite: string; expiresAt?: string }): void {
436
- // Best-effort QR render — fall back to the bare token if the lib chokes.
437
- // eslint-disable-next-line no-console
438
- console.log('\nEnroll your first device with this invite:');
439
- try {
440
- const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
441
- qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
442
- // eslint-disable-next-line no-console
443
- console.log(rendered);
444
- });
445
- } catch (err) {
446
- // eslint-disable-next-line no-console
447
- console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
448
- }
449
- // eslint-disable-next-line no-console
450
- console.log(` ${invite.encodedInvite}`);
451
- if (invite.expiresAt) {
452
- // eslint-disable-next-line no-console
453
- console.log(` (expires ${invite.expiresAt})`);
454
- }
455
- }
456
-
457
- program
458
- .command('invite')
459
- .description('Generate an invite to add a member to the trust circle')
460
- .argument('<label>', 'Display label for the new member (e.g. "Mom\'s phone")')
461
- .option('--ttl <duration>', 'Invite lifetime (e.g. 24h, 7d, 30m)', '24h')
462
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
463
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
464
- .action(async (label: string, opts: { ttl: string; port: string; host: string }) => {
465
- let ttlMs: number;
466
- try {
467
- ttlMs = parseDuration(opts.ttl);
468
- } catch (err) {
469
- // eslint-disable-next-line no-console
470
- console.error(`Invalid --ttl: ${(err as Error).message}`);
471
- process.exit(1);
472
- return;
473
- }
474
-
475
- const port = resolvePort(opts.port);
476
-
477
- const url = `http://${opts.host}:${port}/auth/invites`;
478
- let response: Response;
479
- try {
480
- response = await fetch(url, {
481
- method: 'POST',
482
- headers: { 'content-type': 'application/json' },
483
- body: JSON.stringify({ label, ttlMs }),
484
- });
485
- } catch (err) {
486
- // eslint-disable-next-line no-console
487
- console.error(
488
- `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
489
- `Hint: is cadre-host running? Try \`cadre-host start\`.`,
490
- );
491
- process.exit(2);
492
- return;
493
- }
494
-
495
- if (!response.ok) {
496
- const text = await response.text().catch(() => '');
497
- // eslint-disable-next-line no-console
498
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
499
- process.exit(1);
500
- return;
501
- }
502
-
503
- const body = await response.json() as { encodedInvite?: string; expiresAt?: string; token?: string };
504
- if (!body.encodedInvite) {
505
- // eslint-disable-next-line no-console
506
- console.error('cadre-host returned malformed response (missing encodedInvite)');
507
- process.exit(1);
508
- return;
509
- }
510
-
511
- // eslint-disable-next-line no-console
512
- console.log(body.encodedInvite);
513
- if (body.expiresAt) {
514
- // eslint-disable-next-line no-console
515
- console.error(`(expires at ${body.expiresAt})`);
516
- }
517
- process.exit(0);
518
- });
519
-
520
- const trust = program
521
- .command('trust')
522
- .description('Manage the trust circle (list members, revoke invites/members)');
523
-
524
- trust
525
- .command('list')
526
- .description('List trust-circle members and pending invites')
527
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
528
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
529
- .action(async (opts: { port: string; host: string }) => {
530
- const url = `http://${opts.host}:${resolvePort(opts.port)}/auth/trust-circle`;
531
- let response: Response;
532
- try {
533
- response = await fetch(url);
534
- } catch (err) {
535
- // eslint-disable-next-line no-console
536
- console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
537
- process.exit(2);
538
- return;
539
- }
540
- if (!response.ok) {
541
- // eslint-disable-next-line no-console
542
- console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
543
- process.exit(1);
544
- return;
545
- }
546
- const body = await response.json() as {
547
- members: Array<{ peerId: string; label: string; addedAt: string; self?: boolean }>;
548
- pending: Array<{ token: string; label: string; createdAt: string; expiresAt?: string }>;
549
- };
550
- // eslint-disable-next-line no-console
551
- console.log('Members:');
552
- if (body.members.length === 0) {
553
- // eslint-disable-next-line no-console
554
- console.log(' (none)');
555
- } else {
556
- for (const m of body.members) {
557
- const self = m.self ? ' [self]' : '';
558
- // eslint-disable-next-line no-console
559
- console.log(` ${m.peerId} ${m.label}${self}`);
560
- }
561
- }
562
- // eslint-disable-next-line no-console
563
- console.log('\nPending invites:');
564
- if (body.pending.length === 0) {
565
- // eslint-disable-next-line no-console
566
- console.log(' (none)');
567
- } else {
568
- for (const p of body.pending) {
569
- const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
570
- // eslint-disable-next-line no-console
571
- console.log(` ${p.token} ${p.label}${expires}`);
572
- }
573
- }
574
- process.exit(0);
575
- });
576
-
577
- trust
578
- .command('revoke')
579
- .description('Revoke a pending invite (by token) or remove a member (by peerId)')
580
- .argument('<id>', 'Token (pending invite) or peerId (existing member) to remove')
581
- .option('--kind <kind>', 'Force interpretation: "invite" | "member" (default: auto)', 'auto')
582
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
583
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
584
- .action(async (id: string, opts: { kind: string; port: string; host: string }) => {
585
- const base = `http://${opts.host}:${resolvePort(opts.port)}`;
586
- const kind = opts.kind === 'auto'
587
- ? (looksLikePeerId(id) ? 'member' : 'invite')
588
- : opts.kind;
589
- const path = kind === 'member'
590
- ? `/auth/members/${encodeURIComponent(id)}`
591
- : `/auth/invites/${encodeURIComponent(id)}`;
592
-
593
- let response: Response;
594
- try {
595
- response = await fetch(`${base}${path}`, { method: 'DELETE' });
596
- } catch (err) {
597
- // eslint-disable-next-line no-console
598
- console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
599
- process.exit(2);
600
- return;
601
- }
602
- if (!response.ok) {
603
- const text = await response.text().catch(() => '');
604
- // eslint-disable-next-line no-console
605
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
606
- process.exit(1);
607
- return;
608
- }
609
- // eslint-disable-next-line no-console
610
- console.log(`revoked ${kind}: ${id}`);
611
- process.exit(0);
612
- });
613
-
614
- // ============================================================================
615
- // nat subcommands
616
- // ============================================================================
617
-
618
- const nat = program
619
- .command('nat')
620
- .description('Manage NAT traversal, port forwarding, and dynamic DNS');
621
-
622
- nat
623
- .command('status')
624
- .description('Show NAT, external-IP, and DDNS status')
625
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
626
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
627
- .option('--json', 'Output raw JSON instead of a human-readable summary')
628
- .action(async (opts: { port: string; host: string; json?: boolean }) => {
629
- const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
630
- const body = await getJson(url);
631
- if (opts.json) {
632
- // eslint-disable-next-line no-console
633
- console.log(JSON.stringify(body, null, 2));
634
- } else {
635
- printNatStatus(body);
636
- }
637
- process.exit(0);
638
- });
639
-
640
- nat
641
- .command('test')
642
- .description('Re-run reachability probes and print the result')
643
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
644
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
645
- .option('--json', 'Output raw JSON instead of a human-readable summary')
646
- .action(async (opts: { port: string; host: string; json?: boolean }) => {
647
- const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
648
- const body = await postJson(url, {});
649
- if (opts.json) {
650
- // eslint-disable-next-line no-console
651
- console.log(JSON.stringify(body, null, 2));
652
- } else {
653
- printNatStatus(body);
654
- }
655
- process.exit(0);
656
- });
657
-
658
- nat
659
- .command('settings')
660
- .description('Update NAT settings (external port, UPnP toggle)')
661
- .option('--external-port <port>', 'External port to map')
662
- .option('--internal-port <port>', 'Internal libp2p port (defaults to external)')
663
- .option('--no-upnp', 'Disable UPnP/NAT-PMP port mapping')
664
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
665
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
666
- .action(async (opts: {
667
- externalPort?: string;
668
- internalPort?: string;
669
- upnp?: boolean;
670
- port: string;
671
- host: string;
672
- }) => {
673
- const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
674
- const patch: Record<string, unknown> = {};
675
- if (opts.externalPort) patch.externalPort = resolvePort(opts.externalPort);
676
- if (opts.internalPort) patch.internalPort = resolvePort(opts.internalPort);
677
- // Commander assigns `upnp: false` when `--no-upnp` is passed.
678
- if (opts.upnp === false) patch.upnpEnabled = false;
679
- if (opts.upnp === true) patch.upnpEnabled = true;
680
-
681
- const body = await putJson(url, patch);
682
- printNatStatus(body);
683
- process.exit(0);
684
- });
685
-
686
- const ddns = nat
687
- .command('ddns')
688
- .description('Configure dynamic DNS');
689
-
690
- ddns
691
- .command('set')
692
- .description('Configure a DDNS provider (and store credentials in the keychain)')
693
- .argument('<provider>', 'Provider ID (e.g. "duckdns")')
694
- .option('--hostname <hostname>', 'Hostname to publish (e.g. foo.duckdns.org)')
695
- .option('--token <token>', 'Provider token (a secret value; prompts if omitted)')
696
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
697
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
698
- .action(async (provider: string, opts: {
699
- hostname?: string;
700
- token?: string;
701
- port: string;
702
- host: string;
703
- }) => {
704
- if (!opts.hostname) {
705
- // eslint-disable-next-line no-console
706
- console.error('--hostname is required');
707
- process.exit(1);
708
- return;
709
- }
710
- const config: Record<string, string> = {};
711
- if (opts.token) config.token = opts.token;
712
-
713
- if (!config.token) {
714
- if (!process.stdin.isTTY) {
715
- // eslint-disable-next-line no-console
716
- console.error(
717
- 'Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
718
- 'Re-run with --token <secret>.',
719
- );
720
- process.exit(1);
721
- return;
722
- }
723
- try {
724
- config.token = await readSecretLine('DDNS token: ');
725
- } catch (err) {
726
- // eslint-disable-next-line no-console
727
- console.error('Failed to read token: ' + (err as Error).message);
728
- process.exit(1);
729
- return;
730
- }
731
- }
732
-
733
- const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/ddns`;
734
- const body = await putJson(url, {
735
- providerId: provider,
736
- hostname: opts.hostname,
737
- config,
738
- externallyManaged: false,
739
- });
740
- printNatStatus(body);
741
- process.exit(0);
742
- });
743
-
744
- ddns
745
- .command('external')
746
- .description('Declare an externally-managed DDNS hostname (cadre-host does not update it)')
747
- .requiredOption('--hostname <hostname>', 'Hostname (no provider; cadre-host will not push updates)')
748
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
749
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
750
- .action(async (opts: { hostname: string; port: string; host: string }) => {
751
- const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
752
- const body = await putJson(url, {
753
- ddns: {
754
- providerId: 'external',
755
- hostname: opts.hostname,
756
- externallyManaged: true,
757
- },
758
- });
759
- printNatStatus(body);
760
- process.exit(0);
761
- });
762
-
763
- async function getJson(url: string): Promise<NatStatusLike> {
764
- return await callJson(url, 'GET');
765
- }
766
-
767
- async function postJson(url: string, body: unknown): Promise<NatStatusLike> {
768
- return await callJson(url, 'POST', body);
769
- }
770
-
771
- async function putJson(url: string, body: unknown): Promise<NatStatusLike> {
772
- return await callJson(url, 'PUT', body);
773
- }
774
-
775
- interface NatStatusLike {
776
- portMode?: string;
777
- externalPort?: number;
778
- internalPort?: number;
779
- externalIp?: string | null;
780
- routerExternalIp?: string | null;
781
- cgnatDetected?: boolean;
782
- directReachability?: string;
783
- lastTestedAt?: string | null;
784
- mappingLeaseExpiresAt?: string | null;
785
- ddns?: {
786
- providerId?: string | null;
787
- hostname?: string | null;
788
- externallyManaged?: boolean;
789
- lastUpdateAt?: string | null;
790
- lastUpdateOk?: boolean | null;
791
- lastError?: string | null;
792
- };
793
- }
794
-
795
- async function callJson(url: string, method: string, body?: unknown): Promise<NatStatusLike> {
796
- let response: Response;
797
- try {
798
- const init: RequestInit = { method };
799
- if (body !== undefined) {
800
- init.headers = { 'content-type': 'application/json' };
801
- init.body = JSON.stringify(body);
802
- }
803
- response = await fetch(url, init);
804
- } catch (err) {
805
- // eslint-disable-next-line no-console
806
- console.error(
807
- `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
808
- `Hint: is cadre-host running? Try \`cadre-host start\`.`,
809
- );
810
- process.exit(2);
811
- throw err;
812
- }
813
- if (!response.ok) {
814
- const text = await response.text().catch(() => '');
815
- // eslint-disable-next-line no-console
816
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
817
- process.exit(1);
818
- throw new Error('non-ok');
819
- }
820
- if (response.status === 204) return {} as NatStatusLike;
821
- return await response.json() as NatStatusLike;
822
- }
823
-
824
- function printNatStatus(s: NatStatusLike): void {
825
- // eslint-disable-next-line no-console
826
- console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
827
- if (s.routerExternalIp) {
828
- // eslint-disable-next-line no-console
829
- console.log(`Router IP: ${s.routerExternalIp}`);
830
- }
831
- // eslint-disable-next-line no-console
832
- console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
833
- // eslint-disable-next-line no-console
834
- console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
835
- if (s.mappingLeaseExpiresAt) {
836
- // eslint-disable-next-line no-console
837
- console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
838
- }
839
- const ddns = s.ddns ?? {};
840
- if (ddns.providerId || ddns.hostname) {
841
- // eslint-disable-next-line no-console
842
- console.log(`\nDDNS:`);
843
- // eslint-disable-next-line no-console
844
- console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
845
- // eslint-disable-next-line no-console
846
- console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
847
- if (ddns.lastUpdateAt) {
848
- const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
849
- // eslint-disable-next-line no-console
850
- console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
851
- }
852
- } else {
853
- // eslint-disable-next-line no-console
854
- console.log('\nDDNS: not configured');
855
- }
856
- }
857
-
858
- /** Read a single line from stdin with echo suppressed (TTY only). */
859
- function readSecretLine(prompt: string): Promise<string> {
860
- return new Promise<string>((resolveLine, rejectLine) => {
861
- const stdin = process.stdin;
862
- const stdout = process.stdout;
863
- const wasRaw = stdin.isRaw;
864
- try {
865
- stdout.write(prompt);
866
- stdin.setRawMode(true);
867
- } catch (err) {
868
- rejectLine(err);
869
- return;
870
- }
871
- stdin.resume();
872
- stdin.setEncoding('utf8');
873
-
874
- let buf = '';
875
- const onData = (chunk: string) => {
876
- for (const ch of chunk) {
877
- if (ch === '\n' || ch === '\r' || ch === '') {
878
- stdin.removeListener('data', onData);
879
- try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
880
- stdin.pause();
881
- stdout.write('\n');
882
- resolveLine(buf);
883
- return;
884
- }
885
- if (ch === '') {
886
- // Ctrl-C
887
- stdin.removeListener('data', onData);
888
- try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
889
- stdin.pause();
890
- stdout.write('\n');
891
- rejectLine(new Error('cancelled'));
892
- return;
893
- }
894
- if (ch === '' || ch === '\b') {
895
- buf = buf.slice(0, -1);
896
- continue;
897
- }
898
- buf += ch;
899
- }
900
- };
901
- stdin.on('data', onData);
902
- });
903
- }
904
-
905
- program.parseAsync();
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entrypoint for cadre-host — the self-hosted cadre node manager.
5
+ *
6
+ * Most subcommands are still stubs at this stage (real implementations land
7
+ * in the sibling tickets cadre-host-nat, cadre-host-installer,
8
+ * cadre-host-local-ui).
9
+ *
10
+ * The `invite` and `trust` commands talk to the running cadre-host
11
+ * management API over loopback. They are thin HTTP clients — they don't
12
+ * spin up an inline service, so cadre-host must be running.
13
+ */
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, join, resolve } from 'node:path';
19
+
20
+ import { Command } from 'commander';
21
+
22
+ import { parseDuration } from '../auth/duration.js';
23
+ import { Installer } from '../installer/index.js';
24
+ import { readHostConfig, updateHostConfig } from '../installer/config.js';
25
+ import {
26
+ configPath as resolveConfigPath,
27
+ defaultDataDir,
28
+ defaultHostJs,
29
+ defaultServiceDir,
30
+ identityPath as resolveIdentityPath,
31
+ } from '../installer/paths.js';
32
+ import { detectPlatform } from '../installer/platform.js';
33
+ import { createServiceHost } from '../installer/service-host/index.js';
34
+ import { UpdateService } from '../update/index.js';
35
+ import { HostProcessOrchestrator } from '../orchestrator/index.js';
36
+ import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
37
+ import { NatService } from '../nat/index.js';
38
+ import { createSecretsStore } from '../nat/secrets/index.js';
39
+ import {
40
+ resolvePushCredentials,
41
+ setFcmSecret,
42
+ setApnsSecret,
43
+ clearPushSecret,
44
+ pushStatus,
45
+ } from '../push/index.js';
46
+ import { AuthorityNodeClient } from '../authority/index.js';
47
+ import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
48
+ import { openBrowser } from '../installer/browser.js';
49
+
50
+ const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
51
+
52
+ /** libp2p peer-ID prefixes by key type: Ed25519, secp256k1, legacy RSA. */
53
+ const PEER_ID_PREFIXES = ['12D3Koo', '16Uiu2HAm', 'Qm'] as const;
54
+
55
+ function looksLikePeerId(id: string): boolean {
56
+ return PEER_ID_PREFIXES.some((p) => id.startsWith(p));
57
+ }
58
+
59
+ function resolvePort(raw: string): number {
60
+ const n = Number(raw);
61
+ if (!Number.isFinite(n) || n <= 0 || n > 65535) {
62
+ console.error(`Invalid --port: ${raw}`);
63
+ process.exit(1);
64
+ }
65
+ return n;
66
+ }
67
+
68
+ const program = new Command();
69
+
70
+ program
71
+ .name('cadre-host')
72
+ .description('Sereus cadre node manager for self-hosted basement-PC deployments')
73
+ .version('0.6.0');
74
+
75
+ // ============================================================================
76
+ // install — run the first-run wizard + register the system service
77
+ // ============================================================================
78
+
79
+ program
80
+ .command('install')
81
+ .description('Install cadre-host as a system service and run first-run setup')
82
+ .option('--non-interactive', 'Use defaults / CLI flags only; never prompt')
83
+ .option('--data-dir <path>', 'Override the data directory')
84
+ .option('--ui-port <port>', 'Override the management UI port', parseIntArg)
85
+ .option('--libp2p-port <port>', 'Override the cadre libp2p port', parseIntArg)
86
+ .option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
87
+ .option('--no-browser', 'Do not open a browser after install')
88
+ .option('--no-invite', 'Skip generating the first enrollment invite')
89
+ .option('--system', 'System-wide install (not yet supported in v1)')
90
+ .option('--node-path <path>', 'Override the node binary embedded in the service unit')
91
+ .action(async (opts: {
92
+ nonInteractive?: boolean;
93
+ dataDir?: string;
94
+ uiPort?: number;
95
+ libp2pPort?: number;
96
+ upnp?: boolean;
97
+ browser?: boolean;
98
+ invite?: boolean;
99
+ system?: boolean;
100
+ nodePath?: string;
101
+ }) => {
102
+ const installer = new Installer();
103
+ try {
104
+ const result = await installer.install({
105
+ nonInteractive: Boolean(opts.nonInteractive),
106
+ ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
107
+ ...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
108
+ ...(typeof opts.libp2pPort === 'number' ? { libp2pPort: opts.libp2pPort } : {}),
109
+ noUpnp: opts.upnp === false,
110
+ openBrowser: opts.browser !== false,
111
+ noInvite: opts.invite === false,
112
+ system: Boolean(opts.system),
113
+ ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
114
+ });
115
+ console.log(`cadre-host installed.`);
116
+ console.log(` Data dir: ${result.dataDir}`);
117
+ console.log(` UI: ${result.uiUrl}`);
118
+ console.log(` Service: ${result.serviceName}`);
119
+ if (result.enrollmentInvite) {
120
+ printEnrollmentInvite(result.enrollmentInvite);
121
+ }
122
+ process.exit(0);
123
+ } catch (err) {
124
+ console.error(`install failed: ${(err as Error).message}`);
125
+ process.exit(1);
126
+ }
127
+ });
128
+
129
+ // ============================================================================
130
+ // uninstall — stop + deregister the system service
131
+ // ============================================================================
132
+
133
+ program
134
+ .command('uninstall')
135
+ .description('Stop and uninstall the cadre-host service')
136
+ .option('--yes', 'Skip the confirmation prompt (for scripts)')
137
+ .option('--remove-data', 'Also remove the data directory (default: preserve)')
138
+ .option('--data-dir <path>', 'Override the data directory to clean up')
139
+ .action(async (opts: { yes?: boolean; removeData?: boolean; dataDir?: string }) => {
140
+ const installer = new Installer();
141
+ try {
142
+ // --remove-data is destructive and irreversible (identity + trust circle
143
+ // are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
144
+ // confirmation when it is.
145
+ if (opts.removeData && !opts.yes) {
146
+ if (!process.stdin.isTTY) {
147
+ console.error('uninstall: --remove-data requires --yes when stdin is not a TTY.');
148
+ process.exit(1);
149
+ return;
150
+ }
151
+ const dataDir = opts.dataDir ?? '(default data directory)';
152
+ const confirmed = await confirmDestructive(
153
+ `This will permanently delete ${dataDir} (identity, trust circle, NAT state). Continue? [y/N] `,
154
+ );
155
+ if (!confirmed) {
156
+ console.error('uninstall aborted.');
157
+ process.exit(1);
158
+ return;
159
+ }
160
+ }
161
+ await installer.uninstall({
162
+ yes: Boolean(opts.yes),
163
+ removeData: Boolean(opts.removeData),
164
+ ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
165
+ });
166
+ console.log('cadre-host uninstalled.');
167
+ process.exit(0);
168
+ } catch (err) {
169
+ console.error(`uninstall failed: ${(err as Error).message}`);
170
+ process.exit(1);
171
+ }
172
+ });
173
+
174
+ // ============================================================================
175
+ // status — show service-host registration / running state
176
+ // ============================================================================
177
+
178
+ program
179
+ .command('status')
180
+ .description('Show running status of cadre-host and the cadre nodes it manages')
181
+ .action(async () => {
182
+ const installer = new Installer();
183
+ try {
184
+ const status = await installer.status();
185
+ console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
186
+ console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
187
+ process.exit(0);
188
+ } catch (err) {
189
+ console.error(`status failed: ${(err as Error).message}`);
190
+ process.exit(1);
191
+ }
192
+ });
193
+
194
+ // ============================================================================
195
+ // start — load config + identity, bind management API listener, wait
196
+ // ============================================================================
197
+
198
+ program
199
+ .command('start')
200
+ .description('Start cadre-host in the foreground')
201
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
202
+ .option('--no-tui', 'Reserved — currently a no-op (no TUI implemented yet)')
203
+ .action(async (opts: { dataDir?: string; tui?: boolean }) => {
204
+ try {
205
+ void opts.tui;
206
+ const platform = detectPlatform();
207
+ const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
208
+ const cfgPath = resolveConfigPath(dataDir);
209
+ if (!existsSync(cfgPath)) {
210
+ console.error(
211
+ `cadre-host start: ${cfgPath} not found. ` +
212
+ `Run \`cadre-host install\` first (or pass --data-dir to an existing install).`,
213
+ );
214
+ process.exit(1);
215
+ return;
216
+ }
217
+ const cfg = readHostConfig(cfgPath);
218
+ const idPath = resolveIdentityPath(cfg.dataDir);
219
+ if (!existsSync(idPath)) {
220
+ console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
221
+ process.exit(1);
222
+ return;
223
+ }
224
+ console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
225
+
226
+ // Update flow: notify-by-default; auto-apply opt-in via host.config.json.
227
+ // The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
228
+ // just construct the service so the in-process timer + state file are
229
+ // populated.
230
+ const serviceHost = createServiceHost(platform);
231
+ const updateService = new UpdateService({
232
+ dataDir: cfg.dataDir,
233
+ currentVersion: readPackageVersionForStart(),
234
+ settings: cfg.updates,
235
+ ...(cfg.updates.manifestUrl ? { manifestUrl: cfg.updates.manifestUrl } : {}),
236
+ restart: async () => {
237
+ try {
238
+ await serviceHost.restart({
239
+ nodePath: process.execPath,
240
+ hostJs: defaultHostJs(),
241
+ dataDir: cfg.dataDir,
242
+ serviceDir: defaultServiceDir(),
243
+ });
244
+ return undefined;
245
+ } catch (err) {
246
+ return (err as Error).message;
247
+ }
248
+ },
249
+ });
250
+ // Persist settings changes back to host.config.json on PUT /update/settings.
251
+ updateService.onSettingsChanged((next) => {
252
+ try {
253
+ updateHostConfig(cfgPath, { updates: next });
254
+ } catch (err) {
255
+ console.error(`failed to persist updated settings to ${cfgPath}: ${(err as Error).message}`);
256
+ }
257
+ });
258
+ // Don't block startup on the network round-trip — call and forget.
259
+ void updateService.check();
260
+ updateService.start();
261
+
262
+ // Wire the long-lived HTTP management server. The manager holds no
263
+ // in-process cadre node: it spawns the admin's **authority node** as a
264
+ // managed child and delegates authority/membership/identity operations
265
+ // to it over the node's loopback admin channel (the 6.6 contract). The
266
+ // manager never joins the control network (see docs/cadre-host.md
267
+ // § Control-plane separation).
268
+ // Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
269
+ // restart re-reads the secret store (rotated keys) and nothing raw is
270
+ // persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
271
+ // cooldown/debounce) are re-read from host.config.json each call too.
272
+ const pushSecrets = await createSecretsStore(cfg.dataDir);
273
+ const orchestrator = new HostProcessOrchestrator({
274
+ rootDir: join(cfg.dataDir, 'orchestrator'),
275
+ pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
276
+ });
277
+ await orchestrator.init();
278
+
279
+ // Spawn the authority node. Best-effort: a spawn failure leaves the
280
+ // management API up (trust-circle listing degrades to local labels,
281
+ // authority ops return 503) rather than taking down the whole process.
282
+ try {
283
+ await orchestrator.ensureAuthorityNode({
284
+ identityPath: idPath,
285
+ partyId: cfg.installId,
286
+ libp2pPort: cfg.libp2pPort,
287
+ });
288
+ console.log('cadre-host: authority node spawned');
289
+ } catch (err) {
290
+ console.error(`authority node spawn failed: ${(err as Error).message}`);
291
+ }
292
+
293
+ // The client reads the admin endpoint lazily so a node restart's fresh
294
+ // bearer token is picked up automatically.
295
+ const authority = new AuthorityNodeClient(() => orchestrator.getAuthorityAdminEndpoint());
296
+
297
+ const trustCircle = new TrustCircleService({
298
+ cadreNode: authority,
299
+ store: new TrustCircleStore(cfg.dataDir),
300
+ });
301
+ const natService = new NatService({
302
+ rootDir: cfg.dataDir,
303
+ cadreNode: authority,
304
+ });
305
+
306
+ // Push NAT-resolved invite addresses to the node on every NAT change.
307
+ // NatService.start() also fires this once as an initial push, retried
308
+ // until the freshly spawned authority node accepts it (bounded; the
309
+ // management API that mints invites comes up only after start() resolves).
310
+ natService.onAddressesChanged(async (addresses) => {
311
+ if (addresses.length === 0) return;
312
+ try {
313
+ await authority.pushInviteAddresses(addresses);
314
+ } catch (err) {
315
+ console.error(`invite-address push failed: ${(err as Error).message}`);
316
+ }
317
+ });
318
+
319
+ // Best-effort NAT start. Failures here aren't fatal — the local UI
320
+ // can still serve the trust circle, settings, etc.
321
+ try {
322
+ await natService.start();
323
+ } catch (err) {
324
+ console.error(`NAT start failed: ${(err as Error).message}`);
325
+ }
326
+
327
+ const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
328
+ const server = createLocalUiServer({
329
+ uiPort: cfg.uiPort,
330
+ dataDir: cfg.dataDir,
331
+ orchestrator,
332
+ trustCircle,
333
+ nat: natService,
334
+ update: updateService,
335
+ settingsStore,
336
+ });
337
+ const { url, port } = await server.start();
338
+ if (port !== cfg.uiPort) {
339
+ console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
340
+ }
341
+ console.log(`cadre-host local UI: ${url}`);
342
+
343
+ await waitForTermination();
344
+ try { await server.stop(); } catch { /* ignore */ }
345
+ try { await natService.stop(); } catch { /* ignore */ }
346
+ try { await orchestrator.stopAuthorityNode(); } catch { /* ignore */ }
347
+ updateService.stop();
348
+ console.log('cadre-host stopped.');
349
+ process.exit(0);
350
+ } catch (err) {
351
+ console.error(`start failed: ${(err as Error).message}`);
352
+ process.exit(1);
353
+ }
354
+ });
355
+
356
+ // ============================================================================
357
+ // ui — print the local-UI URL and open it in a browser
358
+ // ============================================================================
359
+
360
+ program
361
+ .command('ui')
362
+ .description('Print the local-UI URL and open it in the default browser')
363
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
364
+ .option('--no-browser', 'Print the URL but do not open a browser')
365
+ .action((opts: { dataDir?: string; browser?: boolean }) => {
366
+ try {
367
+ const platform = detectPlatform();
368
+ const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
369
+ const cfgPath = resolveConfigPath(dataDir);
370
+ if (!existsSync(cfgPath)) {
371
+ console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
372
+ process.exit(1);
373
+ return;
374
+ }
375
+ const cfg = readHostConfig(cfgPath);
376
+ const url = `http://127.0.0.1:${cfg.uiPort}`;
377
+ console.log(url);
378
+ if (opts.browser !== false) {
379
+ const res = openBrowser(url);
380
+ if (res.spawned) {
381
+ console.log('(Opening in your default browser...)');
382
+ } else if (res.error) {
383
+ console.error(`(unable to launch browser: ${res.error})`);
384
+ }
385
+ }
386
+ process.exit(0);
387
+ } catch (err) {
388
+ console.error(`ui failed: ${(err as Error).message}`);
389
+ process.exit(1);
390
+ }
391
+ });
392
+
393
+ function parseIntArg(raw: string): number {
394
+ const n = Number(raw);
395
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
396
+ throw new Error(`Invalid integer: ${raw}`);
397
+ }
398
+ return n;
399
+ }
400
+
401
+ function readPackageVersionForStart(): string {
402
+ try {
403
+ // dist/bin/host.js -> ../../package.json
404
+ const here = dirname(fileURLToPath(import.meta.url));
405
+ const pkgPath = resolve(here, '..', '..', 'package.json');
406
+ const raw = readFileSync(pkgPath, 'utf8');
407
+ const parsed = JSON.parse(raw) as { version?: string };
408
+ return parsed.version ?? '0.0.0-unknown';
409
+ } catch {
410
+ return '0.0.0-unknown';
411
+ }
412
+ }
413
+
414
+ function waitForTermination(): Promise<void> {
415
+ return new Promise<void>((resolveTerm) => {
416
+ const done = () => resolveTerm();
417
+ process.once('SIGINT', done);
418
+ process.once('SIGTERM', done);
419
+ });
420
+ }
421
+
422
+ async function confirmDestructive(message: string): Promise<boolean> {
423
+ const { createInterface } = await import('node:readline');
424
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
425
+ try {
426
+ const answer = await new Promise<string>((res) => rl.question(message, res));
427
+ return /^(y|yes)$/i.test(answer.trim());
428
+ } finally {
429
+ rl.close();
430
+ }
431
+ }
432
+
433
+ const requireForQr = createRequire(import.meta.url);
434
+
435
+ function printEnrollmentInvite(invite: { encodedInvite: string; expiresAt?: string }): void {
436
+ // Best-effort QR render — fall back to the bare token if the lib chokes.
437
+ console.log('\nEnroll your first device with this invite:');
438
+ try {
439
+ const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
440
+ qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
441
+ console.log(rendered);
442
+ });
443
+ } catch (err) {
444
+ console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
445
+ }
446
+ console.log(` ${invite.encodedInvite}`);
447
+ if (invite.expiresAt) {
448
+ console.log(` (expires ${invite.expiresAt})`);
449
+ }
450
+ }
451
+
452
+ program
453
+ .command('invite')
454
+ .description('Generate an invite to add a member to the trust circle')
455
+ .argument('<label>', 'Display label for the new member (e.g. "Mom\'s phone")')
456
+ .option('--ttl <duration>', 'Invite lifetime (e.g. 24h, 7d, 30m)', '24h')
457
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
458
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
459
+ .action(async (label: string, opts: { ttl: string; port: string; host: string }) => {
460
+ let ttlMs: number;
461
+ try {
462
+ ttlMs = parseDuration(opts.ttl);
463
+ } catch (err) {
464
+ console.error(`Invalid --ttl: ${(err as Error).message}`);
465
+ process.exit(1);
466
+ return;
467
+ }
468
+
469
+ const port = resolvePort(opts.port);
470
+
471
+ const url = `http://${opts.host}:${port}/auth/invites`;
472
+ let response: Response;
473
+ try {
474
+ response = await fetch(url, {
475
+ method: 'POST',
476
+ headers: { 'content-type': 'application/json' },
477
+ body: JSON.stringify({ label, ttlMs }),
478
+ });
479
+ } catch (err) {
480
+ console.error(
481
+ `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
482
+ `Hint: is cadre-host running? Try \`cadre-host start\`.`,
483
+ );
484
+ process.exit(2);
485
+ return;
486
+ }
487
+
488
+ if (!response.ok) {
489
+ const text = await response.text().catch(() => '');
490
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
491
+ process.exit(1);
492
+ return;
493
+ }
494
+
495
+ const body = await response.json() as { encodedInvite?: string; expiresAt?: string; token?: string };
496
+ if (!body.encodedInvite) {
497
+ console.error('cadre-host returned malformed response (missing encodedInvite)');
498
+ process.exit(1);
499
+ return;
500
+ }
501
+
502
+ console.log(body.encodedInvite);
503
+ if (body.expiresAt) {
504
+ console.error(`(expires at ${body.expiresAt})`);
505
+ }
506
+ process.exit(0);
507
+ });
508
+
509
+ const trust = program
510
+ .command('trust')
511
+ .description('Manage the trust circle (list members, revoke invites/members)');
512
+
513
+ trust
514
+ .command('list')
515
+ .description('List trust-circle members and pending invites')
516
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
517
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
518
+ .action(async (opts: { port: string; host: string }) => {
519
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/auth/trust-circle`;
520
+ let response: Response;
521
+ try {
522
+ response = await fetch(url);
523
+ } catch (err) {
524
+ console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
525
+ process.exit(2);
526
+ return;
527
+ }
528
+ if (!response.ok) {
529
+ console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
530
+ process.exit(1);
531
+ return;
532
+ }
533
+ const body = await response.json() as {
534
+ members: Array<{ peerId: string; label: string; addedAt: string; self?: boolean }>;
535
+ pending: Array<{ token: string; label: string; createdAt: string; expiresAt?: string }>;
536
+ };
537
+ console.log('Members:');
538
+ if (body.members.length === 0) {
539
+ console.log(' (none)');
540
+ } else {
541
+ for (const m of body.members) {
542
+ const self = m.self ? ' [self]' : '';
543
+ console.log(` ${m.peerId} ${m.label}${self}`);
544
+ }
545
+ }
546
+ console.log('\nPending invites:');
547
+ if (body.pending.length === 0) {
548
+ console.log(' (none)');
549
+ } else {
550
+ for (const p of body.pending) {
551
+ const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
552
+ console.log(` ${p.token} ${p.label}${expires}`);
553
+ }
554
+ }
555
+ process.exit(0);
556
+ });
557
+
558
+ trust
559
+ .command('revoke')
560
+ .description('Revoke a pending invite (by token) or remove a member (by peerId)')
561
+ .argument('<id>', 'Token (pending invite) or peerId (existing member) to remove')
562
+ .option('--kind <kind>', 'Force interpretation: "invite" | "member" (default: auto)', 'auto')
563
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
564
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
565
+ .action(async (id: string, opts: { kind: string; port: string; host: string }) => {
566
+ const base = `http://${opts.host}:${resolvePort(opts.port)}`;
567
+ const kind = opts.kind === 'auto'
568
+ ? (looksLikePeerId(id) ? 'member' : 'invite')
569
+ : opts.kind;
570
+ const path = kind === 'member'
571
+ ? `/auth/members/${encodeURIComponent(id)}`
572
+ : `/auth/invites/${encodeURIComponent(id)}`;
573
+
574
+ let response: Response;
575
+ try {
576
+ response = await fetch(`${base}${path}`, { method: 'DELETE' });
577
+ } catch (err) {
578
+ console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
579
+ process.exit(2);
580
+ return;
581
+ }
582
+ if (!response.ok) {
583
+ const text = await response.text().catch(() => '');
584
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
585
+ process.exit(1);
586
+ return;
587
+ }
588
+ console.log(`revoked ${kind}: ${id}`);
589
+ process.exit(0);
590
+ });
591
+
592
+ // ============================================================================
593
+ // nat subcommands
594
+ // ============================================================================
595
+
596
+ const nat = program
597
+ .command('nat')
598
+ .description('Manage NAT traversal, port forwarding, and dynamic DNS');
599
+
600
+ nat
601
+ .command('status')
602
+ .description('Show NAT, external-IP, and DDNS status')
603
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
604
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
605
+ .option('--json', 'Output raw JSON instead of a human-readable summary')
606
+ .action(async (opts: { port: string; host: string; json?: boolean }) => {
607
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
608
+ const body = await getJson(url);
609
+ if (opts.json) {
610
+ console.log(JSON.stringify(body, null, 2));
611
+ } else {
612
+ printNatStatus(body);
613
+ }
614
+ process.exit(0);
615
+ });
616
+
617
+ nat
618
+ .command('test')
619
+ .description('Re-run reachability probes and print the result')
620
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
621
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
622
+ .option('--json', 'Output raw JSON instead of a human-readable summary')
623
+ .action(async (opts: { port: string; host: string; json?: boolean }) => {
624
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
625
+ const body = await postJson(url, {});
626
+ if (opts.json) {
627
+ console.log(JSON.stringify(body, null, 2));
628
+ } else {
629
+ printNatStatus(body);
630
+ }
631
+ process.exit(0);
632
+ });
633
+
634
+ nat
635
+ .command('settings')
636
+ .description('Update NAT settings (external port, UPnP toggle)')
637
+ .option('--external-port <port>', 'External port to map')
638
+ .option('--internal-port <port>', 'Internal libp2p port (defaults to external)')
639
+ .option('--no-upnp', 'Disable UPnP/NAT-PMP port mapping')
640
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
641
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
642
+ .action(async (opts: {
643
+ externalPort?: string;
644
+ internalPort?: string;
645
+ upnp?: boolean;
646
+ port: string;
647
+ host: string;
648
+ }) => {
649
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
650
+ const patch: Record<string, unknown> = {};
651
+ if (opts.externalPort) patch.externalPort = resolvePort(opts.externalPort);
652
+ if (opts.internalPort) patch.internalPort = resolvePort(opts.internalPort);
653
+ // Commander assigns `upnp: false` when `--no-upnp` is passed.
654
+ if (opts.upnp === false) patch.upnpEnabled = false;
655
+ if (opts.upnp === true) patch.upnpEnabled = true;
656
+
657
+ const body = await putJson(url, patch);
658
+ printNatStatus(body);
659
+ process.exit(0);
660
+ });
661
+
662
+ const ddns = nat
663
+ .command('ddns')
664
+ .description('Configure dynamic DNS');
665
+
666
+ ddns
667
+ .command('set')
668
+ .description('Configure a DDNS provider (and store credentials in the keychain)')
669
+ .argument('<provider>', 'Provider ID (e.g. "duckdns")')
670
+ .option('--hostname <hostname>', 'Hostname to publish (e.g. foo.duckdns.org)')
671
+ .option('--token <token>', 'Provider token (a secret value; prompts if omitted)')
672
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
673
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
674
+ .action(async (provider: string, opts: {
675
+ hostname?: string;
676
+ token?: string;
677
+ port: string;
678
+ host: string;
679
+ }) => {
680
+ if (!opts.hostname) {
681
+ console.error('--hostname is required');
682
+ process.exit(1);
683
+ return;
684
+ }
685
+ const config: Record<string, string> = {};
686
+ if (opts.token) config.token = opts.token;
687
+
688
+ if (!config.token) {
689
+ if (!process.stdin.isTTY) {
690
+ console.error(
691
+ 'Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
692
+ 'Re-run with --token <secret>.',
693
+ );
694
+ process.exit(1);
695
+ return;
696
+ }
697
+ try {
698
+ config.token = await readSecretLine('DDNS token: ');
699
+ } catch (err) {
700
+ console.error('Failed to read token: ' + (err as Error).message);
701
+ process.exit(1);
702
+ return;
703
+ }
704
+ }
705
+
706
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/ddns`;
707
+ const body = await putJson(url, {
708
+ providerId: provider,
709
+ hostname: opts.hostname,
710
+ config,
711
+ externallyManaged: false,
712
+ });
713
+ printNatStatus(body);
714
+ process.exit(0);
715
+ });
716
+
717
+ ddns
718
+ .command('external')
719
+ .description('Declare an externally-managed DDNS hostname (cadre-host does not update it)')
720
+ .requiredOption('--hostname <hostname>', 'Hostname (no provider; cadre-host will not push updates)')
721
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
722
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
723
+ .action(async (opts: { hostname: string; port: string; host: string }) => {
724
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
725
+ const body = await putJson(url, {
726
+ ddns: {
727
+ providerId: 'external',
728
+ hostname: opts.hostname,
729
+ externallyManaged: true,
730
+ },
731
+ });
732
+ printNatStatus(body);
733
+ process.exit(0);
734
+ });
735
+
736
+ // ============================================================================
737
+ // push subcommands — provision FCM/APNs credentials for strand-wake delivery
738
+ // ============================================================================
739
+ //
740
+ // These operate directly on the data dir's secret store + host.config.json (they
741
+ // do NOT go through the running management API). Private keys land in the OS
742
+ // keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
743
+ // bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
744
+ // credentials take effect on the next authority-node (re)spawn — run
745
+ // `cadre-host` restart (or restart the service) to apply them immediately.
746
+
747
+ const push = program
748
+ .command('push')
749
+ .description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
750
+
751
+ push
752
+ .command('fcm')
753
+ .description('Store Firebase Cloud Messaging (Android) service-account credentials')
754
+ .requiredOption('--project-id <id>', 'GCP / Firebase project id')
755
+ .requiredOption('--client-email <email>', 'Service-account email')
756
+ .option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
757
+ .option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
758
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
759
+ .action(async (opts: {
760
+ projectId: string;
761
+ clientEmail: string;
762
+ privateKeyFile?: string;
763
+ privateKey?: string;
764
+ dataDir?: string;
765
+ }) => {
766
+ const { dataDir } = resolveHostPaths(opts.dataDir);
767
+ const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
768
+ const secrets = await createSecretsStore(dataDir);
769
+ await setFcmSecret(secrets, {
770
+ projectId: opts.projectId,
771
+ clientEmail: opts.clientEmail,
772
+ privateKey,
773
+ });
774
+ console.log('✓ FCM credentials stored. Restart cadre-host to apply on the authority node.');
775
+ process.exit(0);
776
+ });
777
+
778
+ push
779
+ .command('apns')
780
+ .description('Store Apple Push Notification service (iOS) auth-key credentials')
781
+ .requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
782
+ .requiredOption('--team-id <id>', 'Apple developer team id')
783
+ .requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
784
+ .option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
785
+ .option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
786
+ .option('--production', 'Target the production APNs host (default: sandbox)')
787
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
788
+ .action(async (opts: {
789
+ keyId: string;
790
+ teamId: string;
791
+ bundleId: string;
792
+ privateKeyFile?: string;
793
+ privateKey?: string;
794
+ production?: boolean;
795
+ dataDir?: string;
796
+ }) => {
797
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
798
+ const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
799
+ const secrets = await createSecretsStore(dataDir);
800
+ // Secret bits → secret store; non-secret app-config → host.config.json.
801
+ await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
802
+ const current = readHostConfig(cfgPath);
803
+ updateHostConfig(cfgPath, {
804
+ push: {
805
+ ...current.push,
806
+ apns: { bundleId: opts.bundleId, production: opts.production === true },
807
+ },
808
+ });
809
+ console.log(
810
+ `✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
811
+ `Restart cadre-host to apply on the authority node.`,
812
+ );
813
+ process.exit(0);
814
+ });
815
+
816
+ push
817
+ .command('options')
818
+ .description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
819
+ .option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
820
+ .option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
821
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
822
+ .action((opts: { cooldownMs?: number; debounceMs?: number; dataDir?: string }) => {
823
+ const { cfgPath } = resolveHostPaths(opts.dataDir);
824
+ const current = readHostConfig(cfgPath);
825
+ const nextPush = { ...current.push };
826
+ if (typeof opts.cooldownMs === 'number') nextPush.cooldownMs = opts.cooldownMs;
827
+ if (typeof opts.debounceMs === 'number') nextPush.debounceMs = opts.debounceMs;
828
+ updateHostConfig(cfgPath, { push: nextPush });
829
+ console.log('✓ Push options updated.');
830
+ process.exit(0);
831
+ });
832
+
833
+ push
834
+ .command('clear')
835
+ .description('Remove stored push credentials')
836
+ .argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
837
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
838
+ .action(async (target: string, opts: { dataDir?: string }) => {
839
+ if (!['fcm', 'apns', 'all'].includes(target)) {
840
+ console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
841
+ process.exit(1);
842
+ return;
843
+ }
844
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
845
+ const secrets = await createSecretsStore(dataDir);
846
+ if (target === 'fcm' || target === 'all') await clearPushSecret(secrets, 'fcm');
847
+ if (target === 'apns' || target === 'all') {
848
+ await clearPushSecret(secrets, 'apns');
849
+ const current = readHostConfig(cfgPath);
850
+ if (current.push?.apns) {
851
+ const { apns: _drop, ...rest } = current.push;
852
+ updateHostConfig(cfgPath, { push: rest });
853
+ }
854
+ }
855
+ console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
856
+ process.exit(0);
857
+ });
858
+
859
+ push
860
+ .command('status')
861
+ .description('Show which push platforms are configured (no secret material)')
862
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
863
+ .action(async (opts: { dataDir?: string }) => {
864
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
865
+ const secrets = await createSecretsStore(dataDir);
866
+ const status = await pushStatus(secrets);
867
+ const cfg = readHostConfig(cfgPath);
868
+ console.log('Push credentials:');
869
+ console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
870
+ const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
871
+ const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
872
+ console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
873
+ if (cfg.push?.cooldownMs !== undefined) console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
874
+ if (cfg.push?.debounceMs !== undefined) console.log(` debounceMs: ${cfg.push.debounceMs}`);
875
+ process.exit(0);
876
+ });
877
+
878
+ /** Resolve the data dir + config path for a direct-on-disk push subcommand. */
879
+ function resolveHostPaths(dataDirOpt?: string): { dataDir: string; cfgPath: string } {
880
+ const platform = detectPlatform();
881
+ const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
882
+ const cfgPath = resolveConfigPath(dataDir);
883
+ if (!existsSync(cfgPath)) {
884
+ console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
885
+ process.exit(1);
886
+ }
887
+ // Resolve the real data dir from the persisted config (handles default-dir installs).
888
+ const cfg = readHostConfig(cfgPath);
889
+ return { dataDir: cfg.dataDir, cfgPath };
890
+ }
891
+
892
+ /** Read a private key from a file or inline flag; exit with a clear error if neither. */
893
+ function readPrivateKeyArg(file: string | undefined, inline: string | undefined, label: string): string {
894
+ if (file) {
895
+ try {
896
+ return readFileSync(file, 'utf8');
897
+ } catch (err) {
898
+ console.error(`Failed to read ${label} private key file ${file}: ${(err as Error).message}`);
899
+ process.exit(1);
900
+ }
901
+ }
902
+ if (inline) return inline;
903
+ console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
904
+ process.exit(1);
905
+ throw new Error('unreachable');
906
+ }
907
+
908
+ async function getJson(url: string): Promise<NatStatusLike> {
909
+ return await callJson(url, 'GET');
910
+ }
911
+
912
+ async function postJson(url: string, body: unknown): Promise<NatStatusLike> {
913
+ return await callJson(url, 'POST', body);
914
+ }
915
+
916
+ async function putJson(url: string, body: unknown): Promise<NatStatusLike> {
917
+ return await callJson(url, 'PUT', body);
918
+ }
919
+
920
+ interface NatStatusLike {
921
+ portMode?: string;
922
+ externalPort?: number;
923
+ internalPort?: number;
924
+ externalIp?: string | null;
925
+ routerExternalIp?: string | null;
926
+ cgnatDetected?: boolean;
927
+ directReachability?: string;
928
+ lastTestedAt?: string | null;
929
+ mappingLeaseExpiresAt?: string | null;
930
+ ddns?: {
931
+ providerId?: string | null;
932
+ hostname?: string | null;
933
+ externallyManaged?: boolean;
934
+ lastUpdateAt?: string | null;
935
+ lastUpdateOk?: boolean | null;
936
+ lastError?: string | null;
937
+ };
938
+ }
939
+
940
+ async function callJson(url: string, method: string, body?: unknown): Promise<NatStatusLike> {
941
+ let response: Response;
942
+ try {
943
+ const init: RequestInit = { method };
944
+ if (body !== undefined) {
945
+ init.headers = { 'content-type': 'application/json' };
946
+ init.body = JSON.stringify(body);
947
+ }
948
+ response = await fetch(url, init);
949
+ } catch (err) {
950
+ console.error(
951
+ `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
952
+ `Hint: is cadre-host running? Try \`cadre-host start\`.`,
953
+ );
954
+ process.exit(2);
955
+ throw err;
956
+ }
957
+ if (!response.ok) {
958
+ const text = await response.text().catch(() => '');
959
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
960
+ process.exit(1);
961
+ throw new Error('non-ok');
962
+ }
963
+ if (response.status === 204) return {} as NatStatusLike;
964
+ return await response.json() as NatStatusLike;
965
+ }
966
+
967
+ function printNatStatus(s: NatStatusLike): void {
968
+ console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
969
+ if (s.routerExternalIp) {
970
+ console.log(`Router IP: ${s.routerExternalIp}`);
971
+ }
972
+ console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
973
+ console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
974
+ if (s.mappingLeaseExpiresAt) {
975
+ console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
976
+ }
977
+ const ddns = s.ddns ?? {};
978
+ if (ddns.providerId || ddns.hostname) {
979
+ console.log(`\nDDNS:`);
980
+ console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
981
+ console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
982
+ if (ddns.lastUpdateAt) {
983
+ const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
984
+ console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
985
+ }
986
+ } else {
987
+ console.log('\nDDNS: not configured');
988
+ }
989
+ }
990
+
991
+ /** Read a single line from stdin with echo suppressed (TTY only). */
992
+ function readSecretLine(prompt: string): Promise<string> {
993
+ return new Promise<string>((resolveLine, rejectLine) => {
994
+ const stdin = process.stdin;
995
+ const stdout = process.stdout;
996
+ const wasRaw = stdin.isRaw;
997
+ try {
998
+ stdout.write(prompt);
999
+ stdin.setRawMode(true);
1000
+ } catch (err) {
1001
+ rejectLine(err);
1002
+ return;
1003
+ }
1004
+ stdin.resume();
1005
+ stdin.setEncoding('utf8');
1006
+
1007
+ let buf = '';
1008
+ const onData = (chunk: string) => {
1009
+ for (const ch of chunk) {
1010
+ if (ch === '\n' || ch === '\r' || ch === '') {
1011
+ stdin.removeListener('data', onData);
1012
+ try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
1013
+ stdin.pause();
1014
+ stdout.write('\n');
1015
+ resolveLine(buf);
1016
+ return;
1017
+ }
1018
+ if (ch === '') {
1019
+ // Ctrl-C
1020
+ stdin.removeListener('data', onData);
1021
+ try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
1022
+ stdin.pause();
1023
+ stdout.write('\n');
1024
+ rejectLine(new Error('cancelled'));
1025
+ return;
1026
+ }
1027
+ if (ch === '' || ch === '\b') {
1028
+ buf = buf.slice(0, -1);
1029
+ continue;
1030
+ }
1031
+ buf += ch;
1032
+ }
1033
+ };
1034
+ stdin.on('data', onData);
1035
+ });
1036
+ }
1037
+
1038
+ void program.parseAsync();