@serfab/cadre-host 0.7.1 → 0.9.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 (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  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/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/dist/bin/host.js CHANGED
@@ -17,14 +17,18 @@ import { dirname, join, resolve } from 'node:path';
17
17
  import { Command } from 'commander';
18
18
  import { parseDuration } from '../auth/duration.js';
19
19
  import { Installer } from '../installer/index.js';
20
- import { readHostConfig, updateHostConfig } from '../installer/config.js';
20
+ import { readHostConfig, updateHostConfig, hostOwnsCadre } from '../installer/config.js';
21
21
  import { configPath as resolveConfigPath, defaultDataDir, defaultHostJs, defaultServiceDir, identityPath as resolveIdentityPath, } from '../installer/paths.js';
22
22
  import { detectPlatform } from '../installer/platform.js';
23
23
  import { createServiceHost } from '../installer/service-host/index.js';
24
24
  import { UpdateService } from '../update/index.js';
25
25
  import { HostProcessOrchestrator } from '../orchestrator/index.js';
26
26
  import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
27
+ import { GrantService, GrantStore, DonationService, DonationStore, DONATION_AWAITING_SEED_TTL_MS, DONATION_REAP_SWEEP_MS, } from '../donation/index.js';
27
28
  import { NatService } from '../nat/index.js';
29
+ import { createSecretsStore } from '../nat/secrets/index.js';
30
+ import { resolvePushCredentials, setFcmSecret, setApnsSecret, clearPushSecret, pushStatus, } from '../push/index.js';
31
+ import { OwnerNodeClient } from '../owner/index.js';
28
32
  import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
29
33
  import { openBrowser } from '../installer/browser.js';
30
34
  const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
@@ -36,7 +40,6 @@ function looksLikePeerId(id) {
36
40
  function resolvePort(raw) {
37
41
  const n = Number(raw);
38
42
  if (!Number.isFinite(n) || n <= 0 || n > 65535) {
39
- // eslint-disable-next-line no-console
40
43
  console.error(`Invalid --port: ${raw}`);
41
44
  process.exit(1);
42
45
  }
@@ -60,6 +63,7 @@ program
60
63
  .option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
61
64
  .option('--no-browser', 'Do not open a browser after install')
62
65
  .option('--no-invite', 'Skip generating the first enrollment invite')
66
+ .option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
63
67
  .option('--system', 'System-wide install (not yet supported in v1)')
64
68
  .option('--node-path <path>', 'Override the node binary embedded in the service unit')
65
69
  .action(async (opts) => {
@@ -73,16 +77,13 @@ program
73
77
  noUpnp: opts.upnp === false,
74
78
  openBrowser: opts.browser !== false,
75
79
  noInvite: opts.invite === false,
80
+ ownCadre: opts.ownCadre === true,
76
81
  system: Boolean(opts.system),
77
82
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
78
83
  });
79
- // eslint-disable-next-line no-console
80
84
  console.log(`cadre-host installed.`);
81
- // eslint-disable-next-line no-console
82
85
  console.log(` Data dir: ${result.dataDir}`);
83
- // eslint-disable-next-line no-console
84
86
  console.log(` UI: ${result.uiUrl}`);
85
- // eslint-disable-next-line no-console
86
87
  console.log(` Service: ${result.serviceName}`);
87
88
  if (result.enrollmentInvite) {
88
89
  printEnrollmentInvite(result.enrollmentInvite);
@@ -90,7 +91,6 @@ program
90
91
  process.exit(0);
91
92
  }
92
93
  catch (err) {
93
- // eslint-disable-next-line no-console
94
94
  console.error(`install failed: ${err.message}`);
95
95
  process.exit(1);
96
96
  }
@@ -129,12 +129,10 @@ program
129
129
  removeData: Boolean(opts.removeData),
130
130
  ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
131
131
  });
132
- // eslint-disable-next-line no-console
133
132
  console.log('cadre-host uninstalled.');
134
133
  process.exit(0);
135
134
  }
136
135
  catch (err) {
137
- // eslint-disable-next-line no-console
138
136
  console.error(`uninstall failed: ${err.message}`);
139
137
  process.exit(1);
140
138
  }
@@ -149,14 +147,11 @@ program
149
147
  const installer = new Installer();
150
148
  try {
151
149
  const status = await installer.status();
152
- // eslint-disable-next-line no-console
153
150
  console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
154
- // eslint-disable-next-line no-console
155
151
  console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
156
152
  process.exit(0);
157
153
  }
158
154
  catch (err) {
159
- // eslint-disable-next-line no-console
160
155
  console.error(`status failed: ${err.message}`);
161
156
  process.exit(1);
162
157
  }
@@ -176,7 +171,6 @@ program
176
171
  const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
177
172
  const cfgPath = resolveConfigPath(dataDir);
178
173
  if (!existsSync(cfgPath)) {
179
- // eslint-disable-next-line no-console
180
174
  console.error(`cadre-host start: ${cfgPath} not found. ` +
181
175
  `Run \`cadre-host install\` first (or pass --data-dir to an existing install).`);
182
176
  process.exit(1);
@@ -185,12 +179,10 @@ program
185
179
  const cfg = readHostConfig(cfgPath);
186
180
  const idPath = resolveIdentityPath(cfg.dataDir);
187
181
  if (!existsSync(idPath)) {
188
- // eslint-disable-next-line no-console
189
182
  console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
190
183
  process.exit(1);
191
184
  return;
192
185
  }
193
- // eslint-disable-next-line no-console
194
186
  console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
195
187
  // Update flow: notify-by-default; auto-apply opt-in via host.config.json.
196
188
  // The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
@@ -223,72 +215,163 @@ program
223
215
  updateHostConfig(cfgPath, { updates: next });
224
216
  }
225
217
  catch (err) {
226
- // eslint-disable-next-line no-console
227
218
  console.error(`failed to persist updated settings to ${cfgPath}: ${err.message}`);
228
219
  }
229
220
  });
230
221
  // Don't block startup on the network round-trip — call and forget.
231
222
  void updateService.check();
232
223
  updateService.start();
233
- // Wire the long-lived HTTP management server. The trust-circle and
234
- // NAT services depend on a libp2p `CadreNodeLike`; v1 has no node-
235
- // spawning path yet (that's a follow-up ticket), so we inject minimal
236
- // stubs that fail loudly if a real call is attempted. The /auth/* and
237
- // /nat/* routes are still mounted so the CLI's existing HTTP contract
238
- // is intact — operations that genuinely need the libp2p node will
239
- // surface a NatError / TrustCircleError, mapped to 500 by the server.
240
- const orchestrator = new HostProcessOrchestrator({ rootDir: join(cfg.dataDir, 'orchestrator') });
241
- await orchestrator.init();
242
- const trustCircle = new TrustCircleService({
243
- cadreNode: missingCadreNodeStub('trust-circle'),
244
- store: new TrustCircleStore(cfg.dataDir),
224
+ // Wire the long-lived HTTP management server. cadre-host's primary role
225
+ // is **node donor**: it spawns generic cadre nodes for *other people's*
226
+ // cadres on request (the donation grant layer below) and never needs an
227
+ // owner node of its own for that. Running the host's **own** personal
228
+ // cadre here — the "founder" persona — is opt-in via `ownCadre.enabled`
229
+ // (see docs/cadre-host.md § Two roles: donor and founder). Only when it
230
+ // is enabled do we spawn the owner node and bring up trust-circle + NAT.
231
+ // The manager never joins the control network (docs/cadre-host.md
232
+ // § Control-plane separation).
233
+ // Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
234
+ // restart re-reads the secret store (rotated keys) and nothing raw is
235
+ // persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
236
+ // cooldown/debounce) are re-read from host.config.json each call too.
237
+ const pushSecrets = await createSecretsStore(cfg.dataDir);
238
+ const orchestrator = new HostProcessOrchestrator({
239
+ rootDir: join(cfg.dataDir, 'orchestrator'),
240
+ pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
245
241
  });
246
- const natService = new NatService({
247
- rootDir: cfg.dataDir,
248
- cadreNode: missingNatNodeStub(),
242
+ await orchestrator.init();
243
+ // Donation grant layer — the always-on donor surface. Local-only:
244
+ // issue/validate/revoke are pure store ops (no node round-trip), so it
245
+ // needs no owner-node handle. The loopback `/grants-admin` surface is
246
+ // mounted by createLocalUiServer regardless of the founder persona.
247
+ const grantService = new GrantService({ store: new GrantStore(cfg.dataDir) });
248
+ // Donation lifecycle service — consumes a validated grant to actually
249
+ // spawn a donated node into the requester's cadre. Drives the
250
+ // grantee-facing `/grants` surface (mounted by createLocalUiServer below).
251
+ const donationService = new DonationService({
252
+ orchestrator,
253
+ grants: grantService,
254
+ store: new DonationStore(cfg.dataDir),
249
255
  });
250
- // Best-effort NAT start. Failures here aren't fatal — the local UI
251
- // can still serve the trust circle, settings, etc.
252
- try {
253
- await natService.start();
256
+ // Reap orphaned donations: a requester that provisioned a node but never
257
+ // presented a seed leaves an `awaiting_seed` child holding host ports.
258
+ // Sweep once at startup (for records recovered from disk by
259
+ // orchestrator.init()), then periodically.
260
+ const reapStale = () => {
261
+ void donationService
262
+ .reapStaleAwaitingSeed(DONATION_AWAITING_SEED_TTL_MS)
263
+ .catch((err) => console.error(`donation reap failed: ${err.message}`));
264
+ };
265
+ reapStale();
266
+ const reapTimer = setInterval(reapStale, DONATION_REAP_SWEEP_MS);
267
+ reapTimer.unref();
268
+ // Founder stack (opt-in). Absent `ownCadre.enabled`, cadre-host is a pure
269
+ // donor: no owner node, and the /auth + /nat surfaces stay unmounted
270
+ // (they 404). Per-donated-node WAN reachability is deferred to
271
+ // backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
272
+ // loopback-only — nothing for NatService to map without an owner node.
273
+ let trustCircle;
274
+ let natService;
275
+ if (hostOwnsCadre(cfg)) {
276
+ // Spawn the owner node. Best-effort: a spawn failure leaves the
277
+ // management API up (trust-circle listing degrades to local labels,
278
+ // owner ops return 503) rather than taking down the whole process.
279
+ try {
280
+ await orchestrator.ensureOwnerNode({
281
+ identityPath: idPath,
282
+ partyId: cfg.installId,
283
+ libp2pPort: cfg.libp2pPort,
284
+ });
285
+ console.log('cadre-host: owner node spawned (host-own-cadre enabled)');
286
+ }
287
+ catch (err) {
288
+ console.error(`owner node spawn failed: ${err.message}`);
289
+ }
290
+ // The client reads the admin endpoint lazily so a node restart's fresh
291
+ // bearer token is picked up automatically.
292
+ const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
293
+ trustCircle = new TrustCircleService({
294
+ cadreNode: owner,
295
+ store: new TrustCircleStore(cfg.dataDir),
296
+ });
297
+ natService = new NatService({
298
+ rootDir: cfg.dataDir,
299
+ cadreNode: owner,
300
+ });
301
+ // Push NAT-resolved invite addresses to the node on every NAT change.
302
+ // NatService.start() also fires this once as an initial push, retried
303
+ // until the freshly spawned owner node accepts it (bounded; the
304
+ // management API that mints invites comes up only after start() resolves).
305
+ natService.onAddressesChanged(async (addresses) => {
306
+ if (addresses.length === 0)
307
+ return;
308
+ try {
309
+ await owner.pushInviteAddresses(addresses);
310
+ }
311
+ catch (err) {
312
+ console.error(`invite-address push failed: ${err.message}`);
313
+ }
314
+ });
315
+ // Best-effort NAT start. Failures here aren't fatal — the local UI
316
+ // can still serve the trust circle, settings, etc.
317
+ try {
318
+ await natService.start();
319
+ }
320
+ catch (err) {
321
+ console.error(`NAT start failed: ${err.message}`);
322
+ }
254
323
  }
255
- catch (err) {
256
- // eslint-disable-next-line no-console
257
- console.error(`NAT start failed: ${err.message}`);
324
+ else {
325
+ // Donor-only. If ownCadre was toggled off after a prior founder run,
326
+ // orchestrator.init() re-attaches the still-running owner child (it would
327
+ // otherwise linger in listNodes with no trustCircle/nat wired, serving
328
+ // the host's own cadre despite being disabled). Reap it now so a disabled
329
+ // own-cadre is actually stopped — its workdir + control-DB persist on
330
+ // disk, so toggling ownCadre back on re-spawns it from saved config.
331
+ try {
332
+ await orchestrator.stopOwnerNode();
333
+ }
334
+ catch (err) {
335
+ console.error(`owner node reap failed: ${err.message}`);
336
+ }
337
+ console.log('cadre-host: node-donor mode (host-own-cadre disabled — no owner node; /auth + /nat inactive)');
258
338
  }
259
339
  const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
260
340
  const server = createLocalUiServer({
261
341
  uiPort: cfg.uiPort,
262
342
  dataDir: cfg.dataDir,
263
343
  orchestrator,
264
- trustCircle,
265
- nat: natService,
344
+ ...(trustCircle ? { trustCircle } : {}),
345
+ ...(natService ? { nat: natService } : {}),
266
346
  update: updateService,
347
+ grants: grantService,
348
+ donations: donationService,
267
349
  settingsStore,
268
350
  });
269
351
  const { url, port } = await server.start();
270
352
  if (port !== cfg.uiPort) {
271
- // eslint-disable-next-line no-console
272
353
  console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
273
354
  }
274
- // eslint-disable-next-line no-console
275
355
  console.log(`cadre-host local UI: ${url}`);
276
356
  await waitForTermination();
357
+ clearInterval(reapTimer);
277
358
  try {
278
359
  await server.stop();
279
360
  }
280
361
  catch { /* ignore */ }
281
362
  try {
282
- await natService.stop();
363
+ await natService?.stop();
364
+ }
365
+ catch { /* ignore */ }
366
+ try {
367
+ await orchestrator.stopOwnerNode();
283
368
  }
284
369
  catch { /* ignore */ }
285
370
  updateService.stop();
286
- // eslint-disable-next-line no-console
287
371
  console.log('cadre-host stopped.');
288
372
  process.exit(0);
289
373
  }
290
374
  catch (err) {
291
- // eslint-disable-next-line no-console
292
375
  console.error(`start failed: ${err.message}`);
293
376
  process.exit(1);
294
377
  }
@@ -307,30 +390,25 @@ program
307
390
  const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
308
391
  const cfgPath = resolveConfigPath(dataDir);
309
392
  if (!existsSync(cfgPath)) {
310
- // eslint-disable-next-line no-console
311
393
  console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
312
394
  process.exit(1);
313
395
  return;
314
396
  }
315
397
  const cfg = readHostConfig(cfgPath);
316
398
  const url = `http://127.0.0.1:${cfg.uiPort}`;
317
- // eslint-disable-next-line no-console
318
399
  console.log(url);
319
400
  if (opts.browser !== false) {
320
401
  const res = openBrowser(url);
321
402
  if (res.spawned) {
322
- // eslint-disable-next-line no-console
323
403
  console.log('(Opening in your default browser...)');
324
404
  }
325
405
  else if (res.error) {
326
- // eslint-disable-next-line no-console
327
406
  console.error(`(unable to launch browser: ${res.error})`);
328
407
  }
329
408
  }
330
409
  process.exit(0);
331
410
  }
332
411
  catch (err) {
333
- // eslint-disable-next-line no-console
334
412
  console.error(`ui failed: ${err.message}`);
335
413
  process.exit(1);
336
414
  }
@@ -355,28 +433,6 @@ function readPackageVersionForStart() {
355
433
  return '0.0.0-unknown';
356
434
  }
357
435
  }
358
- /**
359
- * Returns a `CadreNodeLike` for TrustCircleService that fails loudly on any
360
- * libp2p call. cadre-host v1 doesn't yet spawn the host's own cadre node —
361
- * that ticket is separate. The trust-circle HTTP endpoints still load the
362
- * local labels/pending file (listing works), but issuing/redeeming an invite
363
- * requires a real node and surfaces a 500.
364
- */
365
- function missingCadreNodeStub(name) {
366
- return {
367
- async createInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot issue invite`); },
368
- async acceptPhone() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot redeem`); },
369
- async removePeer() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot remove peer`); },
370
- encodeInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot encode invite`); },
371
- getControlDatabase() { return null; },
372
- };
373
- }
374
- function missingNatNodeStub() {
375
- return {
376
- getPeerId() { return ''; },
377
- getMultiaddrs() { return []; },
378
- };
379
- }
380
436
  function waitForTermination() {
381
437
  return new Promise((resolveTerm) => {
382
438
  const done = () => resolveTerm();
@@ -398,26 +454,37 @@ async function confirmDestructive(message) {
398
454
  const requireForQr = createRequire(import.meta.url);
399
455
  function printEnrollmentInvite(invite) {
400
456
  // Best-effort QR render — fall back to the bare token if the lib chokes.
401
- // eslint-disable-next-line no-console
402
457
  console.log('\nEnroll your first device with this invite:');
403
458
  try {
404
459
  const qr = requireForQr('qrcode-terminal');
405
460
  qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
406
- // eslint-disable-next-line no-console
407
461
  console.log(rendered);
408
462
  });
409
463
  }
410
464
  catch (err) {
411
- // eslint-disable-next-line no-console
412
465
  console.error(`(qrcode-terminal unavailable: ${err.message})`);
413
466
  }
414
- // eslint-disable-next-line no-console
415
467
  console.log(` ${invite.encodedInvite}`);
416
468
  if (invite.expiresAt) {
417
- // eslint-disable-next-line no-console
418
469
  console.log(` (expires ${invite.expiresAt})`);
419
470
  }
420
471
  }
472
+ function printGrantToken(token, withQr) {
473
+ if (withQr) {
474
+ // Best-effort QR render — fall back to the bare token if the lib chokes.
475
+ try {
476
+ const qr = requireForQr('qrcode-terminal');
477
+ qr.generate(token, { small: true }, (rendered) => {
478
+ console.error(rendered);
479
+ });
480
+ }
481
+ catch (err) {
482
+ console.error(`(qrcode-terminal unavailable: ${err.message})`);
483
+ }
484
+ }
485
+ // The token goes to stdout alone so it can be piped/copied; metadata to stderr.
486
+ console.log(token);
487
+ }
421
488
  program
422
489
  .command('invite')
423
490
  .description('Generate an invite to add a member to the trust circle')
@@ -431,7 +498,6 @@ program
431
498
  ttlMs = parseDuration(opts.ttl);
432
499
  }
433
500
  catch (err) {
434
- // eslint-disable-next-line no-console
435
501
  console.error(`Invalid --ttl: ${err.message}`);
436
502
  process.exit(1);
437
503
  return;
@@ -447,7 +513,6 @@ program
447
513
  });
448
514
  }
449
515
  catch (err) {
450
- // eslint-disable-next-line no-console
451
516
  console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
452
517
  `Hint: is cadre-host running? Try \`cadre-host start\`.`);
453
518
  process.exit(2);
@@ -455,22 +520,18 @@ program
455
520
  }
456
521
  if (!response.ok) {
457
522
  const text = await response.text().catch(() => '');
458
- // eslint-disable-next-line no-console
459
523
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
460
524
  process.exit(1);
461
525
  return;
462
526
  }
463
527
  const body = await response.json();
464
528
  if (!body.encodedInvite) {
465
- // eslint-disable-next-line no-console
466
529
  console.error('cadre-host returned malformed response (missing encodedInvite)');
467
530
  process.exit(1);
468
531
  return;
469
532
  }
470
- // eslint-disable-next-line no-console
471
533
  console.log(body.encodedInvite);
472
534
  if (body.expiresAt) {
473
- // eslint-disable-next-line no-console
474
535
  console.error(`(expires at ${body.expiresAt})`);
475
536
  }
476
537
  process.exit(0);
@@ -490,41 +551,33 @@ trust
490
551
  response = await fetch(url);
491
552
  }
492
553
  catch (err) {
493
- // eslint-disable-next-line no-console
494
554
  console.error(`Failed to reach cadre-host at ${url}: ${err.message}`);
495
555
  process.exit(2);
496
556
  return;
497
557
  }
498
558
  if (!response.ok) {
499
- // eslint-disable-next-line no-console
500
559
  console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
501
560
  process.exit(1);
502
561
  return;
503
562
  }
504
563
  const body = await response.json();
505
- // eslint-disable-next-line no-console
506
564
  console.log('Members:');
507
565
  if (body.members.length === 0) {
508
- // eslint-disable-next-line no-console
509
566
  console.log(' (none)');
510
567
  }
511
568
  else {
512
569
  for (const m of body.members) {
513
570
  const self = m.self ? ' [self]' : '';
514
- // eslint-disable-next-line no-console
515
571
  console.log(` ${m.peerId} ${m.label}${self}`);
516
572
  }
517
573
  }
518
- // eslint-disable-next-line no-console
519
574
  console.log('\nPending invites:');
520
575
  if (body.pending.length === 0) {
521
- // eslint-disable-next-line no-console
522
576
  console.log(' (none)');
523
577
  }
524
578
  else {
525
579
  for (const p of body.pending) {
526
580
  const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
527
- // eslint-disable-next-line no-console
528
581
  console.log(` ${p.token} ${p.label}${expires}`);
529
582
  }
530
583
  }
@@ -550,23 +603,147 @@ trust
550
603
  response = await fetch(`${base}${path}`, { method: 'DELETE' });
551
604
  }
552
605
  catch (err) {
553
- // eslint-disable-next-line no-console
554
606
  console.error(`Failed to reach cadre-host at ${base}: ${err.message}`);
555
607
  process.exit(2);
556
608
  return;
557
609
  }
558
610
  if (!response.ok) {
559
611
  const text = await response.text().catch(() => '');
560
- // eslint-disable-next-line no-console
561
612
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
562
613
  process.exit(1);
563
614
  return;
564
615
  }
565
- // eslint-disable-next-line no-console
566
616
  console.log(`revoked ${kind}: ${id}`);
567
617
  process.exit(0);
568
618
  });
569
619
  // ============================================================================
620
+ // grant subcommands — donation grant tokens (who may ask this host for a node)
621
+ // ============================================================================
622
+ //
623
+ // A grant token lets one grantee (friend/family) present a Bearer credential to
624
+ // ask this host to donate cadre nodes, up to a per-grantee cap. These commands
625
+ // are thin HTTP clients of the loopback `/grants-admin` admin surface — no
626
+ // bearer (same-machine admin), same posture as `invite` / `trust`. The
627
+ // grantee-facing provisioning surface lands in the donation-service ticket.
628
+ const grant = program
629
+ .command('grant')
630
+ .description('Manage donation grant tokens (who may ask this host to donate a node)');
631
+ grant
632
+ .command('issue')
633
+ .description('Issue a grant token for one grantee (prints the token + QR)')
634
+ .argument('<label>', 'Display label for the grantee (e.g. "Alice\'s cadre")')
635
+ .option('--max-nodes <n>', 'Max concurrently-live donated nodes this grant may hold', parseIntArg)
636
+ .option('--ttl <duration>', 'Grant lifetime (e.g. 30d, 12h); omit for no expiry')
637
+ .option('--no-qr', 'Print only the token, no QR code')
638
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
639
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
640
+ .action(async (label, opts) => {
641
+ const payload = { label };
642
+ if (typeof opts.maxNodes === 'number')
643
+ payload.maxNodes = opts.maxNodes;
644
+ if (opts.ttl) {
645
+ try {
646
+ payload.ttlMs = parseDuration(opts.ttl);
647
+ }
648
+ catch (err) {
649
+ console.error(`Invalid --ttl: ${err.message}`);
650
+ process.exit(1);
651
+ return;
652
+ }
653
+ }
654
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
655
+ let response;
656
+ try {
657
+ response = await fetch(url, {
658
+ method: 'POST',
659
+ headers: { 'content-type': 'application/json' },
660
+ body: JSON.stringify(payload),
661
+ });
662
+ }
663
+ catch (err) {
664
+ console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
665
+ `Hint: is cadre-host running? Try \`cadre-host start\`.`);
666
+ process.exit(2);
667
+ return;
668
+ }
669
+ if (!response.ok) {
670
+ const text = await response.text().catch(() => '');
671
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
672
+ process.exit(1);
673
+ return;
674
+ }
675
+ const body = await response.json();
676
+ const issued = body.grant;
677
+ if (!issued?.token) {
678
+ console.error('cadre-host returned malformed response (missing grant token)');
679
+ process.exit(1);
680
+ return;
681
+ }
682
+ printGrantToken(issued.token, opts.qr !== false);
683
+ console.error(`(maxNodes ${issued.maxNodes ?? '?'}${issued.expiresAt ? `, expires ${issued.expiresAt}` : ', no expiry'})`);
684
+ process.exit(0);
685
+ });
686
+ grant
687
+ .command('list')
688
+ .description('List issued grant tokens')
689
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
690
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
691
+ .action(async (opts) => {
692
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
693
+ let response;
694
+ try {
695
+ response = await fetch(url);
696
+ }
697
+ catch (err) {
698
+ console.error(`Failed to reach cadre-host at ${url}: ${err.message}`);
699
+ process.exit(2);
700
+ return;
701
+ }
702
+ if (!response.ok) {
703
+ console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
704
+ process.exit(1);
705
+ return;
706
+ }
707
+ const body = await response.json();
708
+ console.log('Grants:');
709
+ if (body.grants.length === 0) {
710
+ console.log(' (none)');
711
+ }
712
+ else {
713
+ for (const g of body.grants) {
714
+ const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
715
+ console.log(` ${g.token} ${g.label} max=${g.maxNodes}${state}`);
716
+ }
717
+ }
718
+ process.exit(0);
719
+ });
720
+ grant
721
+ .command('revoke')
722
+ .description('Revoke a grant token (blocks future requests; live nodes are not torn down)')
723
+ .argument('<token>', 'Grant token to revoke')
724
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
725
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
726
+ .action(async (token, opts) => {
727
+ const base = `http://${opts.host}:${resolvePort(opts.port)}`;
728
+ let response;
729
+ try {
730
+ response = await fetch(`${base}/grants-admin/${encodeURIComponent(token)}`, { method: 'DELETE' });
731
+ }
732
+ catch (err) {
733
+ console.error(`Failed to reach cadre-host at ${base}: ${err.message}`);
734
+ process.exit(2);
735
+ return;
736
+ }
737
+ if (!response.ok) {
738
+ const text = await response.text().catch(() => '');
739
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
740
+ process.exit(1);
741
+ return;
742
+ }
743
+ console.log(`revoked grant: ${token}`);
744
+ process.exit(0);
745
+ });
746
+ // ============================================================================
570
747
  // nat subcommands
571
748
  // ============================================================================
572
749
  const nat = program
@@ -582,7 +759,6 @@ nat
582
759
  const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
583
760
  const body = await getJson(url);
584
761
  if (opts.json) {
585
- // eslint-disable-next-line no-console
586
762
  console.log(JSON.stringify(body, null, 2));
587
763
  }
588
764
  else {
@@ -600,7 +776,6 @@ nat
600
776
  const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
601
777
  const body = await postJson(url, {});
602
778
  if (opts.json) {
603
- // eslint-disable-next-line no-console
604
779
  console.log(JSON.stringify(body, null, 2));
605
780
  }
606
781
  else {
@@ -645,7 +820,6 @@ ddns
645
820
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
646
821
  .action(async (provider, opts) => {
647
822
  if (!opts.hostname) {
648
- // eslint-disable-next-line no-console
649
823
  console.error('--hostname is required');
650
824
  process.exit(1);
651
825
  return;
@@ -655,7 +829,6 @@ ddns
655
829
  config.token = opts.token;
656
830
  if (!config.token) {
657
831
  if (!process.stdin.isTTY) {
658
- // eslint-disable-next-line no-console
659
832
  console.error('Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
660
833
  'Re-run with --token <secret>.');
661
834
  process.exit(1);
@@ -665,7 +838,6 @@ ddns
665
838
  config.token = await readSecretLine('DDNS token: ');
666
839
  }
667
840
  catch (err) {
668
- // eslint-disable-next-line no-console
669
841
  console.error('Failed to read token: ' + err.message);
670
842
  process.exit(1);
671
843
  return;
@@ -699,6 +871,160 @@ ddns
699
871
  printNatStatus(body);
700
872
  process.exit(0);
701
873
  });
874
+ // ============================================================================
875
+ // push subcommands — provision FCM/APNs credentials for strand-wake delivery
876
+ // ============================================================================
877
+ //
878
+ // These operate directly on the data dir's secret store + host.config.json (they
879
+ // do NOT go through the running management API). Private keys land in the OS
880
+ // keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
881
+ // bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
882
+ // credentials take effect on the next owner-node (re)spawn — run
883
+ // `cadre-host` restart (or restart the service) to apply them immediately.
884
+ const push = program
885
+ .command('push')
886
+ .description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
887
+ push
888
+ .command('fcm')
889
+ .description('Store Firebase Cloud Messaging (Android) service-account credentials')
890
+ .requiredOption('--project-id <id>', 'GCP / Firebase project id')
891
+ .requiredOption('--client-email <email>', 'Service-account email')
892
+ .option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
893
+ .option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
894
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
895
+ .action(async (opts) => {
896
+ const { dataDir } = resolveHostPaths(opts.dataDir);
897
+ const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
898
+ const secrets = await createSecretsStore(dataDir);
899
+ await setFcmSecret(secrets, {
900
+ projectId: opts.projectId,
901
+ clientEmail: opts.clientEmail,
902
+ privateKey,
903
+ });
904
+ console.log('✓ FCM credentials stored. Restart cadre-host to apply on the owner node.');
905
+ process.exit(0);
906
+ });
907
+ push
908
+ .command('apns')
909
+ .description('Store Apple Push Notification service (iOS) auth-key credentials')
910
+ .requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
911
+ .requiredOption('--team-id <id>', 'Apple developer team id')
912
+ .requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
913
+ .option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
914
+ .option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
915
+ .option('--production', 'Target the production APNs host (default: sandbox)')
916
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
917
+ .action(async (opts) => {
918
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
919
+ const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
920
+ const secrets = await createSecretsStore(dataDir);
921
+ // Secret bits → secret store; non-secret app-config → host.config.json.
922
+ await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
923
+ const current = readHostConfig(cfgPath);
924
+ updateHostConfig(cfgPath, {
925
+ push: {
926
+ ...current.push,
927
+ apns: { bundleId: opts.bundleId, production: opts.production === true },
928
+ },
929
+ });
930
+ console.log(`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
931
+ `Restart cadre-host to apply on the owner node.`);
932
+ process.exit(0);
933
+ });
934
+ push
935
+ .command('options')
936
+ .description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
937
+ .option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
938
+ .option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
939
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
940
+ .action((opts) => {
941
+ const { cfgPath } = resolveHostPaths(opts.dataDir);
942
+ const current = readHostConfig(cfgPath);
943
+ const nextPush = { ...current.push };
944
+ if (typeof opts.cooldownMs === 'number')
945
+ nextPush.cooldownMs = opts.cooldownMs;
946
+ if (typeof opts.debounceMs === 'number')
947
+ nextPush.debounceMs = opts.debounceMs;
948
+ updateHostConfig(cfgPath, { push: nextPush });
949
+ console.log('✓ Push options updated.');
950
+ process.exit(0);
951
+ });
952
+ push
953
+ .command('clear')
954
+ .description('Remove stored push credentials')
955
+ .argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
956
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
957
+ .action(async (target, opts) => {
958
+ if (!['fcm', 'apns', 'all'].includes(target)) {
959
+ console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
960
+ process.exit(1);
961
+ return;
962
+ }
963
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
964
+ const secrets = await createSecretsStore(dataDir);
965
+ if (target === 'fcm' || target === 'all')
966
+ await clearPushSecret(secrets, 'fcm');
967
+ if (target === 'apns' || target === 'all') {
968
+ await clearPushSecret(secrets, 'apns');
969
+ const current = readHostConfig(cfgPath);
970
+ if (current.push?.apns) {
971
+ const { apns: _drop, ...rest } = current.push;
972
+ updateHostConfig(cfgPath, { push: rest });
973
+ }
974
+ }
975
+ console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
976
+ process.exit(0);
977
+ });
978
+ push
979
+ .command('status')
980
+ .description('Show which push platforms are configured (no secret material)')
981
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
982
+ .action(async (opts) => {
983
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
984
+ const secrets = await createSecretsStore(dataDir);
985
+ const status = await pushStatus(secrets);
986
+ const cfg = readHostConfig(cfgPath);
987
+ console.log('Push credentials:');
988
+ console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
989
+ const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
990
+ const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
991
+ console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
992
+ if (cfg.push?.cooldownMs !== undefined)
993
+ console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
994
+ if (cfg.push?.debounceMs !== undefined)
995
+ console.log(` debounceMs: ${cfg.push.debounceMs}`);
996
+ process.exit(0);
997
+ });
998
+ /** Resolve the data dir + config path for a direct-on-disk push subcommand. */
999
+ function resolveHostPaths(dataDirOpt) {
1000
+ const platform = detectPlatform();
1001
+ const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
1002
+ const cfgPath = resolveConfigPath(dataDir);
1003
+ if (!existsSync(cfgPath)) {
1004
+ console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
1005
+ process.exit(1);
1006
+ }
1007
+ // Resolve the real data dir from the persisted config (handles default-dir installs).
1008
+ const cfg = readHostConfig(cfgPath);
1009
+ return { dataDir: cfg.dataDir, cfgPath };
1010
+ }
1011
+ /** Read a private key from a file or inline flag; exit with a clear error if neither. */
1012
+ function readPrivateKeyArg(file, inline, label) {
1013
+ if (file) {
1014
+ try {
1015
+ return readFileSync(file, 'utf8');
1016
+ }
1017
+ catch (err) {
1018
+ console.error(`Failed to read ${label} private key file ${file}: ${err.message}`);
1019
+ process.exit(1);
1020
+ }
1021
+ }
1022
+ if (inline)
1023
+ return inline;
1024
+ console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
1025
+ process.exit(1);
1026
+ throw new Error('unreachable');
1027
+ }
702
1028
  async function getJson(url) {
703
1029
  return await callJson(url, 'GET');
704
1030
  }
@@ -719,7 +1045,6 @@ async function callJson(url, method, body) {
719
1045
  response = await fetch(url, init);
720
1046
  }
721
1047
  catch (err) {
722
- // eslint-disable-next-line no-console
723
1048
  console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
724
1049
  `Hint: is cadre-host running? Try \`cadre-host start\`.`);
725
1050
  process.exit(2);
@@ -727,7 +1052,6 @@ async function callJson(url, method, body) {
727
1052
  }
728
1053
  if (!response.ok) {
729
1054
  const text = await response.text().catch(() => '');
730
- // eslint-disable-next-line no-console
731
1055
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
732
1056
  process.exit(1);
733
1057
  throw new Error('non-ok');
@@ -737,36 +1061,26 @@ async function callJson(url, method, body) {
737
1061
  return await response.json();
738
1062
  }
739
1063
  function printNatStatus(s) {
740
- // eslint-disable-next-line no-console
741
1064
  console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
742
1065
  if (s.routerExternalIp) {
743
- // eslint-disable-next-line no-console
744
1066
  console.log(`Router IP: ${s.routerExternalIp}`);
745
1067
  }
746
- // eslint-disable-next-line no-console
747
1068
  console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
748
- // eslint-disable-next-line no-console
749
1069
  console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
750
1070
  if (s.mappingLeaseExpiresAt) {
751
- // eslint-disable-next-line no-console
752
1071
  console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
753
1072
  }
754
1073
  const ddns = s.ddns ?? {};
755
1074
  if (ddns.providerId || ddns.hostname) {
756
- // eslint-disable-next-line no-console
757
1075
  console.log(`\nDDNS:`);
758
- // eslint-disable-next-line no-console
759
1076
  console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
760
- // eslint-disable-next-line no-console
761
1077
  console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
762
1078
  if (ddns.lastUpdateAt) {
763
1079
  const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
764
- // eslint-disable-next-line no-console
765
1080
  console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
766
1081
  }
767
1082
  }
768
1083
  else {
769
- // eslint-disable-next-line no-console
770
1084
  console.log('\nDDNS: not configured');
771
1085
  }
772
1086
  }
@@ -822,5 +1136,5 @@ function readSecretLine(prompt) {
822
1136
  stdin.on('data', onData);
823
1137
  });
824
1138
  }
825
- program.parseAsync();
1139
+ void program.parseAsync();
826
1140
  //# sourceMappingURL=host.js.map