@serfab/cadre-host 1.13.0 → 1.15.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 (352) hide show
  1. package/README.md +347 -423
  2. package/dist/bin/host.d.ts +6 -3
  3. package/dist/bin/host.js +332 -524
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/bin/join-output.d.ts +56 -0
  6. package/dist/bin/join-output.js +92 -0
  7. package/dist/bin/join-output.js.map +1 -0
  8. package/dist/bin/nat-output.d.ts +54 -0
  9. package/dist/bin/nat-output.js +138 -0
  10. package/dist/bin/nat-output.js.map +1 -0
  11. package/dist/bin/ui-access.d.ts +22 -0
  12. package/dist/bin/ui-access.js +44 -0
  13. package/dist/bin/ui-access.js.map +1 -0
  14. package/dist/hosted/claim-details.d.ts +66 -0
  15. package/dist/hosted/claim-details.js +60 -0
  16. package/dist/hosted/claim-details.js.map +1 -0
  17. package/dist/hosted/hosted-node-service.d.ts +325 -0
  18. package/dist/hosted/hosted-node-service.js +678 -0
  19. package/dist/hosted/hosted-node-service.js.map +1 -0
  20. package/dist/hosted/hosted-node-store.d.ts +40 -0
  21. package/dist/hosted/hosted-node-store.js +126 -0
  22. package/dist/hosted/hosted-node-store.js.map +1 -0
  23. package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
  24. package/dist/hosted/hosted-node-supervisor.js +396 -0
  25. package/dist/hosted/hosted-node-supervisor.js.map +1 -0
  26. package/dist/hosted/hosted-node-watcher.d.ts +95 -0
  27. package/dist/hosted/hosted-node-watcher.js +260 -0
  28. package/dist/hosted/hosted-node-watcher.js.map +1 -0
  29. package/dist/hosted/index.d.ts +16 -0
  30. package/dist/hosted/index.js +13 -0
  31. package/dist/hosted/index.js.map +1 -0
  32. package/dist/hosted/node-status.d.ts +51 -0
  33. package/dist/hosted/node-status.js +77 -0
  34. package/dist/hosted/node-status.js.map +1 -0
  35. package/dist/hosted/types.d.ts +141 -0
  36. package/dist/hosted/types.js +22 -0
  37. package/dist/hosted/types.js.map +1 -0
  38. package/dist/index.d.ts +12 -24
  39. package/dist/index.js +9 -19
  40. package/dist/index.js.map +1 -1
  41. package/dist/installer/config.d.ts +27 -34
  42. package/dist/installer/config.js +46 -15
  43. package/dist/installer/config.js.map +1 -1
  44. package/dist/installer/index.d.ts +9 -19
  45. package/dist/installer/index.js +21 -85
  46. package/dist/installer/index.js.map +1 -1
  47. package/dist/installer/paths.d.ts +0 -2
  48. package/dist/installer/paths.js +0 -4
  49. package/dist/installer/paths.js.map +1 -1
  50. package/dist/installer/wizard.d.ts +12 -9
  51. package/dist/installer/wizard.js +15 -14
  52. package/dist/installer/wizard.js.map +1 -1
  53. package/dist/nat/address-resolver.d.ts +18 -17
  54. package/dist/nat/address-resolver.js +79 -25
  55. package/dist/nat/address-resolver.js.map +1 -1
  56. package/dist/nat/address-watch.d.ts +37 -0
  57. package/dist/nat/address-watch.js +84 -0
  58. package/dist/nat/address-watch.js.map +1 -0
  59. package/dist/nat/ddns/duckdns.d.ts +2 -1
  60. package/dist/nat/ddns/duckdns.js +5 -1
  61. package/dist/nat/ddns/duckdns.js.map +1 -1
  62. package/dist/nat/ddns/index.d.ts +6 -0
  63. package/dist/nat/ddns/index.js.map +1 -1
  64. package/dist/nat/index.d.ts +13 -9
  65. package/dist/nat/index.js +7 -5
  66. package/dist/nat/index.js.map +1 -1
  67. package/dist/nat/nat-service.d.ts +220 -77
  68. package/dist/nat/nat-service.js +653 -239
  69. package/dist/nat/nat-service.js.map +1 -1
  70. package/dist/nat/nat-store.d.ts +12 -5
  71. package/dist/nat/nat-store.js +124 -29
  72. package/dist/nat/nat-store.js.map +1 -1
  73. package/dist/nat/network-mode.d.ts +19 -0
  74. package/dist/nat/network-mode.js +32 -0
  75. package/dist/nat/network-mode.js.map +1 -0
  76. package/dist/nat/port-mapper.d.ts +83 -79
  77. package/dist/nat/port-mapper.js +148 -189
  78. package/dist/nat/port-mapper.js.map +1 -1
  79. package/dist/nat/reachability.d.ts +32 -15
  80. package/dist/nat/reachability.js +110 -22
  81. package/dist/nat/reachability.js.map +1 -1
  82. package/dist/nat/types.d.ts +98 -27
  83. package/dist/nat/types.js +8 -5
  84. package/dist/nat/types.js.map +1 -1
  85. package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
  86. package/dist/orchestrator/host-process-orchestrator.js +84 -233
  87. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  88. package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
  89. package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
  90. package/dist/orchestrator/identity-file.js.map +1 -0
  91. package/dist/orchestrator/index.d.ts +2 -3
  92. package/dist/orchestrator/index.js +1 -1
  93. package/dist/orchestrator/index.js.map +1 -1
  94. package/dist/orchestrator/node-identity.d.ts +6 -6
  95. package/dist/orchestrator/node-identity.js +7 -7
  96. package/dist/orchestrator/node-identity.js.map +1 -1
  97. package/dist/orchestrator/port-allocator.d.ts +5 -6
  98. package/dist/orchestrator/port-allocator.js +9 -11
  99. package/dist/orchestrator/port-allocator.js.map +1 -1
  100. package/dist/orchestrator/state-store.d.ts +3 -5
  101. package/dist/orchestrator/state-store.js +2 -10
  102. package/dist/orchestrator/state-store.js.map +1 -1
  103. package/dist/orchestrator/types.d.ts +33 -22
  104. package/dist/orchestrator/types.js.map +1 -1
  105. package/dist/package-version.d.ts +1 -0
  106. package/dist/package-version.js +4 -0
  107. package/dist/package-version.js.map +1 -0
  108. package/dist/server/error-handler.d.ts +5 -25
  109. package/dist/server/error-handler.js +11 -74
  110. package/dist/server/error-handler.js.map +1 -1
  111. package/dist/server/events/types.d.ts +7 -11
  112. package/dist/server/events/types.js +2 -1
  113. package/dist/server/events/types.js.map +1 -1
  114. package/dist/server/index.d.ts +11 -37
  115. package/dist/server/index.js +30 -48
  116. package/dist/server/index.js.map +1 -1
  117. package/dist/server/routes/hosted-nodes.d.ts +28 -0
  118. package/dist/server/routes/hosted-nodes.js +68 -0
  119. package/dist/server/routes/hosted-nodes.js.map +1 -0
  120. package/dist/server/routes/nat.d.ts +4 -1
  121. package/dist/server/routes/nat.js +13 -2
  122. package/dist/server/routes/nat.js.map +1 -1
  123. package/dist/server/routes/nodes.d.ts +6 -20
  124. package/dist/server/routes/nodes.js +7 -86
  125. package/dist/server/routes/nodes.js.map +1 -1
  126. package/dist/server/routes/settings.d.ts +3 -7
  127. package/dist/server/routes/settings.js +18 -8
  128. package/dist/server/routes/settings.js.map +1 -1
  129. package/dist/server/routes/status.d.ts +2 -25
  130. package/dist/server/routes/status.js +3 -43
  131. package/dist/server/routes/status.js.map +1 -1
  132. package/dist/server/server.js +8 -8
  133. package/dist/server/settings-store.d.ts +2 -2
  134. package/dist/server/settings-store.js +2 -2
  135. package/dist/server/static.js +1 -2
  136. package/dist/server/static.js.map +1 -1
  137. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  138. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  139. package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
  140. package/dist/ui/index.html +2 -2
  141. package/dist/update/index.d.ts +2 -2
  142. package/dist/update/index.js +2 -2
  143. package/dist/update/store.d.ts +1 -1
  144. package/dist/update/store.js +1 -1
  145. package/dist/update/types.d.ts +1 -1
  146. package/dist/update/types.js +1 -1
  147. package/package.json +5 -7
  148. package/src/__tests__/cli-nat.smoke.test.ts +219 -212
  149. package/src/__tests__/cli.smoke.test.ts +2 -2
  150. package/src/__tests__/global-setup.ts +2 -3
  151. package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
  152. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  153. package/src/__tests__/orchestrator-handle-contract.ts +134 -134
  154. package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
  155. package/src/__tests__/orchestrator-ports.test.ts +124 -143
  156. package/src/__tests__/orchestrator-push.test.ts +138 -146
  157. package/src/__tests__/orchestrator.test.ts +1017 -1063
  158. package/src/__tests__/ui-access.test.ts +30 -0
  159. package/src/bin/host.ts +397 -572
  160. package/src/bin/join-output.ts +120 -0
  161. package/src/bin/nat-output.ts +188 -0
  162. package/src/bin/ui-access.ts +50 -0
  163. package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
  164. package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
  165. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
  166. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
  167. package/src/hosted/__tests__/test-invitation.ts +33 -0
  168. package/src/hosted/claim-details.ts +103 -0
  169. package/src/hosted/hosted-node-service.ts +814 -0
  170. package/src/hosted/hosted-node-store.ts +144 -0
  171. package/src/hosted/hosted-node-supervisor.ts +439 -0
  172. package/src/hosted/hosted-node-watcher.ts +288 -0
  173. package/src/hosted/index.ts +53 -0
  174. package/src/hosted/node-status.ts +97 -0
  175. package/src/hosted/types.ts +160 -0
  176. package/src/index.ts +72 -91
  177. package/src/installer/__tests__/config.test.ts +15 -40
  178. package/src/installer/__tests__/installer.smoke.test.ts +184 -263
  179. package/src/installer/__tests__/wizard.test.ts +25 -13
  180. package/src/installer/config.ts +201 -175
  181. package/src/installer/index.ts +252 -327
  182. package/src/installer/paths.ts +104 -109
  183. package/src/installer/wizard.ts +24 -26
  184. package/src/nat/__tests__/address-resolver.test.ts +107 -82
  185. package/src/nat/__tests__/nat-service.test.ts +608 -515
  186. package/src/nat/__tests__/nat-store.test.ts +49 -12
  187. package/src/nat/__tests__/network-mode.test.ts +38 -0
  188. package/src/nat/__tests__/port-mapper.test.ts +30 -191
  189. package/src/nat/__tests__/reachability.test.ts +96 -13
  190. package/src/nat/address-resolver.ts +94 -52
  191. package/src/nat/address-watch.ts +92 -0
  192. package/src/nat/ddns/duckdns.ts +5 -1
  193. package/src/nat/ddns/index.ts +6 -0
  194. package/src/nat/index.ts +33 -13
  195. package/src/nat/nat-service.ts +942 -501
  196. package/src/nat/nat-store.ts +126 -31
  197. package/src/nat/network-mode.ts +36 -0
  198. package/src/nat/port-mapper.ts +241 -329
  199. package/src/nat/reachability.ts +155 -34
  200. package/src/nat/types.ts +110 -37
  201. package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
  202. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  203. package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
  204. package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
  205. package/src/orchestrator/index.ts +9 -10
  206. package/src/orchestrator/node-identity.ts +41 -41
  207. package/src/orchestrator/port-allocator.ts +71 -73
  208. package/src/orchestrator/state-store.ts +76 -90
  209. package/src/orchestrator/types.ts +34 -23
  210. package/src/package-version.ts +4 -0
  211. package/src/server/__tests__/bus.test.ts +38 -38
  212. package/src/server/__tests__/error-handler.test.ts +81 -118
  213. package/src/server/__tests__/fakes.ts +15 -18
  214. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
  215. package/src/server/__tests__/nodes-route.test.ts +114 -283
  216. package/src/server/__tests__/publishers.test.ts +193 -180
  217. package/src/server/__tests__/server.smoke.test.ts +134 -223
  218. package/src/server/__tests__/settings-route.test.ts +210 -195
  219. package/src/server/__tests__/sse-route.test.ts +119 -119
  220. package/src/server/__tests__/status-route.test.ts +82 -121
  221. package/src/server/error-handler.ts +112 -178
  222. package/src/server/events/types.ts +20 -24
  223. package/src/server/index.ts +223 -267
  224. package/src/server/routes/hosted-nodes.ts +83 -0
  225. package/src/server/routes/nat.ts +75 -63
  226. package/src/server/routes/nodes.ts +96 -188
  227. package/src/server/routes/settings.ts +20 -15
  228. package/src/server/routes/status.ts +5 -67
  229. package/src/server/server.ts +8 -8
  230. package/src/server/settings-store.ts +2 -2
  231. package/src/server/static.ts +1 -2
  232. package/src/update/index.ts +2 -2
  233. package/src/update/store.ts +1 -1
  234. package/src/update/types.ts +1 -1
  235. package/dist/auth/duration.d.ts +0 -1
  236. package/dist/auth/duration.js +0 -32
  237. package/dist/auth/duration.js.map +0 -1
  238. package/dist/auth/index.d.ts +0 -11
  239. package/dist/auth/index.js +0 -9
  240. package/dist/auth/index.js.map +0 -1
  241. package/dist/auth/self-label.d.ts +0 -36
  242. package/dist/auth/self-label.js +0 -57
  243. package/dist/auth/self-label.js.map +0 -1
  244. package/dist/auth/trust-circle-store.d.ts +0 -26
  245. package/dist/auth/trust-circle-store.js +0 -129
  246. package/dist/auth/trust-circle-store.js.map +0 -1
  247. package/dist/auth/trust-circle.d.ts +0 -156
  248. package/dist/auth/trust-circle.js +0 -307
  249. package/dist/auth/trust-circle.js.map +0 -1
  250. package/dist/auth/types.d.ts +0 -84
  251. package/dist/auth/types.js +0 -24
  252. package/dist/auth/types.js.map +0 -1
  253. package/dist/donation/donation-service.d.ts +0 -389
  254. package/dist/donation/donation-service.js +0 -828
  255. package/dist/donation/donation-service.js.map +0 -1
  256. package/dist/donation/donation-store.d.ts +0 -51
  257. package/dist/donation/donation-store.js +0 -152
  258. package/dist/donation/donation-store.js.map +0 -1
  259. package/dist/donation/donation-supervisor.js +0 -364
  260. package/dist/donation/donation-supervisor.js.map +0 -1
  261. package/dist/donation/grant-service.d.ts +0 -80
  262. package/dist/donation/grant-service.js +0 -210
  263. package/dist/donation/grant-service.js.map +0 -1
  264. package/dist/donation/grant-store.d.ts +0 -38
  265. package/dist/donation/grant-store.js +0 -135
  266. package/dist/donation/grant-store.js.map +0 -1
  267. package/dist/donation/index.d.ts +0 -20
  268. package/dist/donation/index.js +0 -17
  269. package/dist/donation/index.js.map +0 -1
  270. package/dist/donation/types.d.ts +0 -224
  271. package/dist/donation/types.js +0 -40
  272. package/dist/donation/types.js.map +0 -1
  273. package/dist/installer/identity.js.map +0 -1
  274. package/dist/owner/index.d.ts +0 -11
  275. package/dist/owner/index.js +0 -11
  276. package/dist/owner/index.js.map +0 -1
  277. package/dist/owner/owner-node-client.d.ts +0 -110
  278. package/dist/owner/owner-node-client.js +0 -184
  279. package/dist/owner/owner-node-client.js.map +0 -1
  280. package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
  281. package/dist/server/routes/bootstrap-node-validation.js +0 -197
  282. package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
  283. package/dist/server/routes/grants-admin.d.ts +0 -29
  284. package/dist/server/routes/grants-admin.js +0 -58
  285. package/dist/server/routes/grants-admin.js.map +0 -1
  286. package/dist/server/routes/grants.d.ts +0 -28
  287. package/dist/server/routes/grants.js +0 -165
  288. package/dist/server/routes/grants.js.map +0 -1
  289. package/dist/server/routes/provision-request-validation.d.ts +0 -45
  290. package/dist/server/routes/provision-request-validation.js +0 -97
  291. package/dist/server/routes/provision-request-validation.js.map +0 -1
  292. package/dist/server/routes/strands.d.ts +0 -20
  293. package/dist/server/routes/strands.js +0 -38
  294. package/dist/server/routes/strands.js.map +0 -1
  295. package/dist/server/routes/trust-circle.d.ts +0 -17
  296. package/dist/server/routes/trust-circle.js +0 -35
  297. package/dist/server/routes/trust-circle.js.map +0 -1
  298. package/dist/strands/index.d.ts +0 -7
  299. package/dist/strands/index.js +0 -6
  300. package/dist/strands/index.js.map +0 -1
  301. package/dist/strands/strand-service.d.ts +0 -50
  302. package/dist/strands/strand-service.js +0 -103
  303. package/dist/strands/strand-service.js.map +0 -1
  304. package/dist/strands/types.d.ts +0 -93
  305. package/dist/strands/types.js +0 -23
  306. package/dist/strands/types.js.map +0 -1
  307. package/dist/ui/assets/index-C6nELNfC.js +0 -23
  308. package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
  309. package/dist/ui/assets/index-TtIYSeZO.css +0 -1
  310. package/src/__tests__/cli-invite.smoke.test.ts +0 -192
  311. package/src/__tests__/host-owner.smoke.test.ts +0 -123
  312. package/src/__tests__/orchestrator-owner.test.ts +0 -234
  313. package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
  314. package/src/auth/__tests__/duration.test.ts +0 -28
  315. package/src/auth/__tests__/self-label.test.ts +0 -85
  316. package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
  317. package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
  318. package/src/auth/__tests__/trust-circle.test.ts +0 -445
  319. package/src/auth/duration.ts +0 -33
  320. package/src/auth/index.ts +0 -21
  321. package/src/auth/self-label.ts +0 -74
  322. package/src/auth/trust-circle-store.ts +0 -155
  323. package/src/auth/trust-circle.ts +0 -395
  324. package/src/auth/types.ts +0 -99
  325. package/src/donation/__tests__/donation-service.test.ts +0 -1193
  326. package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
  327. package/src/donation/__tests__/grant-service.test.ts +0 -213
  328. package/src/donation/__tests__/grant-store.test.ts +0 -113
  329. package/src/donation/donation-service.ts +0 -1033
  330. package/src/donation/donation-store.ts +0 -176
  331. package/src/donation/donation-supervisor.ts +0 -411
  332. package/src/donation/grant-service.ts +0 -252
  333. package/src/donation/grant-store.ts +0 -152
  334. package/src/donation/index.ts +0 -65
  335. package/src/donation/types.ts +0 -260
  336. package/src/owner/__tests__/owner-node-client.test.ts +0 -194
  337. package/src/owner/index.ts +0 -12
  338. package/src/owner/owner-node-client.ts +0 -259
  339. package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
  340. package/src/server/__tests__/grants-admin-route.test.ts +0 -171
  341. package/src/server/__tests__/grants-route.test.ts +0 -394
  342. package/src/server/__tests__/strands-route.test.ts +0 -234
  343. package/src/server/routes/bootstrap-node-validation.ts +0 -206
  344. package/src/server/routes/grants-admin.ts +0 -74
  345. package/src/server/routes/grants.ts +0 -193
  346. package/src/server/routes/provision-request-validation.ts +0 -105
  347. package/src/server/routes/strands.ts +0 -54
  348. package/src/server/routes/trust-circle.ts +0 -49
  349. package/src/strands/__tests__/strand-service.test.ts +0 -165
  350. package/src/strands/index.ts +0 -14
  351. package/src/strands/strand-service.ts +0 -135
  352. package/src/strands/types.ts +0 -102
package/src/bin/host.ts CHANGED
@@ -3,51 +3,48 @@
3
3
  /**
4
4
  * CLI entrypoint for cadre-host — the self-hosted cadre node manager.
5
5
  *
6
- * Most subcommands (`status`, `invite`, `trust`, `grant`, `nat`) are thin HTTP
7
- * clients against the running cadre-host management API on loopback — they
8
- * don't spin up an inline service, so cadre-host must be running.
6
+ * Most subcommands (`join`, `node`, `nat`) are thin HTTP clients against the
7
+ * running cadre-host management API on loopback — they don't spin up an inline
8
+ * service, so cadre-host must be running.
9
9
  *
10
10
  * The exceptions operate on disk directly and need no running service:
11
11
  * `install`, `uninstall`, `start`, `ui`, and the `push` group.
12
12
  *
13
13
  * Every command here needs a matching `### ` heading in the package README's
14
14
  * `## CLI reference`; `__tests__/cli-reference.test.ts` enforces that.
15
+ *
16
+ * NOTE: this file is ~1200 lines; `nat-output.ts` shows the split to make —
17
+ * when a command group's code grows, move it to its own module under `bin/`.
15
18
  */
16
19
 
17
20
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
18
- import { createRequire } from 'node:module';
19
- import { fileURLToPath, pathToFileURL } from 'node:url';
20
- import { dirname, join, resolve } from 'node:path';
21
+ import { pathToFileURL } from 'node:url';
22
+ import { join } from 'node:path';
21
23
 
22
- import { Command } from 'commander';
24
+ import { Command, InvalidArgumentError } from 'commander';
23
25
 
24
- import { parseDuration } from '../auth/duration.js';
25
26
  import { Installer } from '../installer/index.js';
26
- import { readHostConfig, updateHostConfig, hostOwnsCadre } from '../installer/config.js';
27
+ import { packageVersion } from '../package-version.js';
28
+ import { readHostConfig, updateHostConfig } from '../installer/config.js';
27
29
  import {
28
30
  configPath as resolveConfigPath,
29
31
  defaultDataDir,
30
32
  defaultHostJs,
31
33
  defaultServiceDir,
32
- identityPath as resolveIdentityPath,
33
34
  } from '../installer/paths.js';
34
35
  import { detectPlatform } from '../installer/platform.js';
35
36
  import { createServiceHost } from '../installer/service-host/index.js';
36
37
  import { UpdateService } from '../update/index.js';
37
38
  import { HostProcessOrchestrator } from '../orchestrator/index.js';
38
- import { TrustCircleService, TrustCircleStore, ensureSelfLabel } from '../auth/index.js';
39
39
  import {
40
- GrantService,
41
- GrantStore,
42
- DonationService,
43
- DonationStore,
44
- DonationSupervisor,
45
- DONATION_AWAITING_SEED_TTL_MS,
46
- DONATION_PROVISIONING_TTL_MS,
47
- DONATION_REAP_SWEEP_MS,
48
- type GrantListing,
49
- } from '../donation/index.js';
50
- import { NatService } from '../nat/index.js';
40
+ HostedNodeService,
41
+ HostedNodeStore,
42
+ HostedNodeSupervisor,
43
+ HOSTED_NODE_REAP_SWEEP_MS,
44
+ HOSTED_NODE_SPAWNING_TTL_MS,
45
+ } from '../hosted/index.js';
46
+ import { effectiveNetworkMode, NatService, NatStore, publicInterfaceAddress } from '../nat/index.js';
47
+ import type { ManualForwardPatch } from '../nat/types.js';
51
48
  import { createSecretsStore } from '../nat/secrets/index.js';
52
49
  import {
53
50
  resolvePushCredentials,
@@ -56,20 +53,21 @@ import {
56
53
  clearPushSecret,
57
54
  pushStatus,
58
55
  } from '../push/index.js';
59
- import { StrandService } from '../strands/index.js';
60
- import { OwnerNodeClient } from '../owner/index.js';
61
- import { createLocalUiServer, HostSettingsStore, type FounderServices } from '../server/index.js';
56
+ import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
62
57
  import { openBrowser } from '../installer/browser.js';
58
+ import { printForwardResult, printNatStatus, type NatStatusLike } from './nat-output.js';
59
+ import { isHeadless, uiAccessLines } from './ui-access.js';
60
+ import {
61
+ printClaimPayload,
62
+ printClaimed,
63
+ printInvitationOutcome,
64
+ printNodeList,
65
+ type ClaimDetailsLike,
66
+ type HostedNodeLike,
67
+ } from './join-output.js';
63
68
 
64
69
  const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
65
70
 
66
- /** libp2p peer-ID prefixes by key type: Ed25519, secp256k1, legacy RSA. */
67
- const PEER_ID_PREFIXES = ['12D3Koo', '16Uiu2HAm', 'Qm'] as const;
68
-
69
- function looksLikePeerId(id: string): boolean {
70
- return PEER_ID_PREFIXES.some((p) => id.startsWith(p));
71
- }
72
-
73
71
  function resolvePort(raw: string): number {
74
72
  const n = Number(raw);
75
73
  if (!Number.isFinite(n) || n <= 0 || n > 65535) {
@@ -84,7 +82,7 @@ const program = new Command();
84
82
  program
85
83
  .name('cadre-host')
86
84
  .description('Sereus cadre node manager for self-hosted basement-PC deployments')
87
- .version(readPackageVersion());
85
+ .version(packageVersion);
88
86
 
89
87
  // ============================================================================
90
88
  // install — run the first-run wizard + register the system service
@@ -96,11 +94,8 @@ program
96
94
  .option('--non-interactive', 'Use defaults / CLI flags only; never prompt')
97
95
  .option('--data-dir <path>', 'Override the data directory')
98
96
  .option('--ui-port <port>', 'Override the management UI port', parseIntArg)
99
- .option('--libp2p-port <port>', 'Override the cadre libp2p port', parseIntArg)
100
97
  .option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
101
98
  .option('--no-browser', 'Do not open a browser after install')
102
- .option('--no-invite', 'Skip generating the first enrollment invite')
103
- .option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
104
99
  .option('--system', 'System-wide install (not yet supported in v1)')
105
100
  .option('--node-path <path>', 'Override the node binary embedded in the service unit')
106
101
  .option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
@@ -108,11 +103,8 @@ program
108
103
  nonInteractive?: boolean;
109
104
  dataDir?: string;
110
105
  uiPort?: number;
111
- libp2pPort?: number;
112
106
  upnp?: boolean;
113
107
  browser?: boolean;
114
- invite?: boolean;
115
- ownCadre?: boolean;
116
108
  system?: boolean;
117
109
  nodePath?: string;
118
110
  service?: boolean;
@@ -123,11 +115,9 @@ program
123
115
  nonInteractive: Boolean(opts.nonInteractive),
124
116
  ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
125
117
  ...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
126
- ...(typeof opts.libp2pPort === 'number' ? { libp2pPort: opts.libp2pPort } : {}),
127
118
  noUpnp: opts.upnp === false,
128
- openBrowser: opts.browser !== false,
129
- noInvite: opts.invite === false,
130
- ownCadre: opts.ownCadre === true,
119
+ // Headless, a browser would open nowhere the admin can see; the `ssh -L` lines below say how instead.
120
+ openBrowser: opts.browser !== false && !isHeadless(),
131
121
  system: Boolean(opts.system),
132
122
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
133
123
  noService: opts.service === false,
@@ -141,9 +131,15 @@ program
141
131
  console.log(' Service: not registered (--no-service)');
142
132
  console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
143
133
  }
144
- if (result.enrollmentInvite) {
145
- printEnrollmentInvite(result.enrollmentInvite);
146
- }
134
+ // A re-install keeps the `network` setting an earlier one recorded in nat.json.
135
+ const publicIp = publicInterfaceAddress();
136
+ const mode = effectiveNetworkMode(new NatStore(result.dataDir).load().network, publicIp);
137
+ console.log(mode === 'public'
138
+ ? ` Network: public IP${publicIp ? ` ${publicIp}` : ''}: nodes are reached directly. Allow their ports through your firewall (the UI lists them).`
139
+ : ' Network: behind a router: nodes need UPnP or a port forward (the UI shows which).');
140
+ console.log('');
141
+ for (const line of uiAccessLines(Number(new URL(result.uiUrl).port), isHeadless())) console.log(line);
142
+ console.log('Everything else (a public name, joining a cadre, ports) is in the UI.');
147
143
  process.exit(0);
148
144
  } catch (err) {
149
145
  console.error(`install failed: ${(err as Error).message}`);
@@ -164,8 +160,8 @@ program
164
160
  .action(async (opts: { yes?: boolean; removeData?: boolean; dataDir?: string }) => {
165
161
  const installer = new Installer();
166
162
  try {
167
- // --remove-data is destructive and irreversible (identity + trust circle
168
- // are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
163
+ // --remove-data is destructive and irreversible (node identities, hosted-node
164
+ // records and NAT state are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
169
165
  // confirmation when it is.
170
166
  if (opts.removeData && !opts.yes) {
171
167
  if (!process.stdin.isTTY) {
@@ -175,7 +171,7 @@ program
175
171
  }
176
172
  const dataDir = opts.dataDir ?? '(default data directory)';
177
173
  const confirmed = await confirmDestructive(
178
- `This will permanently delete ${dataDir} (identity, trust circle, NAT state). Continue? [y/N] `,
174
+ `This will permanently delete ${dataDir} (node identities, hosted-node records, NAT state). Continue? [y/N] `,
179
175
  );
180
176
  if (!confirmed) {
181
177
  console.error('uninstall aborted.');
@@ -217,7 +213,7 @@ program
217
213
  });
218
214
 
219
215
  // ============================================================================
220
- // start — load config + identity, bind management API listener, wait
216
+ // start — load config, bind management API listener, wait
221
217
  // ============================================================================
222
218
 
223
219
  program
@@ -240,12 +236,6 @@ program
240
236
  return;
241
237
  }
242
238
  const cfg = readHostConfig(cfgPath);
243
- const idPath = resolveIdentityPath(cfg.dataDir);
244
- if (!existsSync(idPath)) {
245
- console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
246
- process.exit(1);
247
- return;
248
- }
249
239
  console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
250
240
 
251
241
  // Update flow: notify-by-default; auto-apply opt-in via host.config.json.
@@ -255,7 +245,7 @@ program
255
245
  const serviceHost = createServiceHost(platform);
256
246
  const updateService = new UpdateService({
257
247
  dataDir: cfg.dataDir,
258
- currentVersion: readPackageVersion(),
248
+ currentVersion: packageVersion,
259
249
  settings: cfg.updates,
260
250
  ...(cfg.updates.manifestUrl ? { manifestUrl: cfg.updates.manifestUrl } : {}),
261
251
  restart: async () => {
@@ -284,169 +274,91 @@ program
284
274
  void updateService.check();
285
275
  updateService.start();
286
276
 
287
- // Wire the long-lived HTTP management server. cadre-host's primary role
288
- // is **node donor**: it spawns generic cadre nodes for *other people's*
289
- // cadres on request (the donation grant layer below) and never needs an
290
- // owner node of its own for that. Running the host's **own** personal
291
- // cadre here — the "founder" persona — is opt-in via `ownCadre.enabled`
292
- // (see docs/cadre-host.md § Two roles: donor and founder). Only when it
293
- // is enabled do we spawn the owner node and bring up trust-circle + NAT.
294
- // The manager never joins the control network (docs/cadre-host.md
295
- // § Control-plane separation).
277
+ // Wire the long-lived HTTP management server. cadre-host spawns cadre
278
+ // nodes for cadres that live on people's phones (the hosted-node service
279
+ // below); it holds no owner key and never founds a cadre. The manager
280
+ // never joins the control network (docs/cadre-host.md § Control-plane
281
+ // separation).
296
282
  // Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
297
283
  // restart re-reads the secret store (rotated keys) and nothing raw is
298
284
  // persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
299
285
  // cooldown/debounce) are re-read from host.config.json each call too.
300
286
  const pushSecrets = await createSecretsStore(cfg.dataDir);
287
+ // Each node is spawned announcing its public addresses. Read through a late-bound
288
+ // reference: NatService takes the orchestrator as its node source, so it is built after.
289
+ const addresses: { source?: NatService } = {};
301
290
  const orchestrator = new HostProcessOrchestrator({
302
291
  rootDir: join(cfg.dataDir, 'orchestrator'),
303
292
  pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
293
+ announceAddrs: (id, ports) => addresses.source?.publicAddressesFor(id, ports) ?? [],
304
294
  });
305
295
  await orchestrator.init();
306
296
 
307
- // Donation grant layer — the always-on donor surface. Local-only:
308
- // issue/validate/revoke are pure store ops (no node round-trip), so it
309
- // needs no owner-node handle. The loopback `/grants-admin` surface is
310
- // mounted by createLocalUiServer regardless of the founder persona.
311
- const grantService = new GrantService({ store: new GrantStore(cfg.dataDir) });
312
-
313
- // Donation lifecycle service — consumes a validated grant to actually
314
- // spawn a donated node into the requester's cadre. Drives the
315
- // grantee-facing `/grants` surface (mounted by createLocalUiServer below).
316
- const donationStore = new DonationStore(cfg.dataDir);
317
- const donationService = new DonationService({
297
+ // NAT layer: every node the host runs gets its two libp2p ports mapped
298
+ // on the router (or the user's manual forward recorded for them). Started
299
+ // before anything is spawned so the first spawns see a discovered gateway. `start()` awaits
300
+ // only gateway discovery and IP detection (both bounded); the mappings
301
+ // for re-attached nodes land in the background. Best-effort: a failure
302
+ // here leaves the management API up.
303
+ const natService = new NatService({ rootDir: cfg.dataDir, nodeSource: orchestrator });
304
+ addresses.source = natService;
305
+ try {
306
+ await natService.start();
307
+ } catch (err) {
308
+ console.error(`NAT start failed: ${(err as Error).message}`);
309
+ }
310
+
311
+ // Hosted nodes — "Join a cadre": a child started waiting to be claimed, the
312
+ // QR code the owner's phone scans, and the record of whose cadre it joined.
313
+ // Drives `/api/hosted-nodes` (mounted by createLocalUiServer below).
314
+ const hostedNodeStore = new HostedNodeStore(cfg.dataDir);
315
+ const hostedNodes = new HostedNodeService({
318
316
  orchestrator,
319
- grants: grantService,
320
- store: donationStore,
317
+ store: hostedNodeStore,
318
+ addresses: natService,
319
+ // Re-read per claim code so a Settings edit (or a hand edit) applies to the next code shown.
320
+ claimAddressSettings: () => readHostConfig(cfgPath).claimAddresses,
321
321
  });
322
322
 
323
- // Respawn supervision — nothing else brings a donated node back. A crash,
324
- // an OOM kill, or a reboot otherwise leaves the record reading `seeded`
325
- // with no process behind it, costing the borrower a node and their grant a
326
- // quota slot. Sweeps at startup, on every child exit, and on its own timer.
327
- const donationSupervisor = new DonationSupervisor({
328
- service: donationService,
329
- store: donationStore,
323
+ // Respawn supervision — nothing else brings a hosted node back. A crash,
324
+ // an OOM kill, or a reboot otherwise leaves the record reading `joined`
325
+ // with no process behind it, costing its cadre a node. Sweeps at startup,
326
+ // on every child exit, and on its own timer.
327
+ const hostedNodeSupervisor = new HostedNodeSupervisor({
328
+ service: hostedNodes,
329
+ store: hostedNodeStore,
330
330
  orchestrator,
331
331
  });
332
- donationSupervisor.start();
333
-
334
- // Reap orphaned donations: a requester that provisioned a node but never
335
- // presented a seed leaves an `awaiting_seed` child holding host ports,
336
- // and a host that died between writing a `provisioning` row and finishing
337
- // the spawn leaves that row stuck forever (nothing else ever revisits it).
338
- // Sweep once at startup (for records recovered from disk by
332
+ hostedNodeSupervisor.start();
333
+ // Follows each node's /status: the claim, and whether it holds a connection.
334
+ hostedNodes.startWatching();
335
+
336
+ // A node learns its public addresses only at start, so one whose addresses
337
+ // changed (a mapping on another port, a forward, the DDNS hostname, the external
338
+ // IP) is restarted, at most once per node per 10 minutes.
339
+ natService.onNodeAddressesStale((id) => hostedNodeSupervisor.restart(id));
340
+
341
+ // Reap records stuck in `spawning`: a host that died between writing the
342
+ // row and finishing the spawn leaves it stuck forever (nothing else ever
343
+ // revisits it). Sweep once at startup (for records recovered from disk by
339
344
  // orchestrator.init()), then periodically.
340
345
  const reapStale = (): void => {
341
- void donationService
342
- .reapStaleAwaitingSeed(DONATION_AWAITING_SEED_TTL_MS)
343
- .catch((err) => console.error(`donation reap failed: ${(err as Error).message}`));
344
- void donationService
345
- .reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)
346
- .catch((err) => console.error(`donation provisioning reap failed: ${(err as Error).message}`));
346
+ void hostedNodes
347
+ .reapStuckSpawning(HOSTED_NODE_SPAWNING_TTL_MS)
348
+ .catch((err) => console.error(`hosted node reap failed: ${(err as Error).message}`));
347
349
  };
348
350
  reapStale();
349
- const reapTimer = setInterval(reapStale, DONATION_REAP_SWEEP_MS);
351
+ const reapTimer = setInterval(reapStale, HOSTED_NODE_REAP_SWEEP_MS);
350
352
  reapTimer.unref();
351
353
 
352
- // Founder stack (opt-in). Absent `ownCadre.enabled`, cadre-host is a pure
353
- // donor: no owner node, and the /auth + /nat surfaces stay unmounted
354
- // (they 404). Per-donated-node WAN reachability is deferred to
355
- // backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
356
- // loopback-only — nothing for NatService to map without an owner node.
357
- let founder: FounderServices | undefined;
358
- if (hostOwnsCadre(cfg)) {
359
- // Spawn the owner node. Best-effort: a spawn failure leaves the
360
- // management API up (trust-circle listing degrades to local labels,
361
- // owner ops return 503) rather than taking down the whole process.
362
- try {
363
- await orchestrator.ensureOwnerNode({
364
- identityPath: idPath,
365
- partyId: cfg.installId,
366
- libp2pPort: cfg.libp2pPort,
367
- });
368
- console.log('cadre-host: owner node spawned (host-own-cadre enabled)');
369
- } catch (err) {
370
- console.error(`owner node spawn failed: ${(err as Error).message}`);
371
- }
372
-
373
- // The client reads the admin endpoint lazily so a node restart's fresh
374
- // bearer token is picked up automatically.
375
- const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
376
-
377
- const trustCircleStore = new TrustCircleStore(cfg.dataDir);
378
- const trustCircle = new TrustCircleService({
379
- cadreNode: owner,
380
- store: trustCircleStore,
381
- });
382
-
383
- const natService = new NatService({
384
- rootDir: cfg.dataDir,
385
- cadreNode: owner,
386
- });
387
-
388
- // Strand management is founder-only for the same reason as the trust
389
- // circle: it asks the owner node, and donor-only mode has none.
390
- const strandService = new StrandService({ cadreNode: owner });
391
-
392
- // Push NAT-resolved invite addresses to the node on every NAT change.
393
- // NatService.start() also fires this once as an initial push, retried
394
- // until the freshly spawned owner node accepts it (bounded; the
395
- // management API that mints invites comes up only after start() resolves).
396
- natService.onAddressesChanged(async (addresses) => {
397
- if (addresses.length === 0) return;
398
- try {
399
- await owner.pushInviteAddresses(addresses);
400
- } catch (err) {
401
- console.error(`invite-address push failed: ${(err as Error).message}`);
402
- }
403
- });
404
-
405
- // Best-effort NAT start. Failures here aren't fatal — the local UI
406
- // can still serve the trust circle, settings, etc.
407
- try {
408
- await natService.start();
409
- } catch (err) {
410
- console.error(`NAT start failed: ${(err as Error).message}`);
411
- }
412
-
413
- // Label the owner's own device so it shows up in the trust-circle
414
- // listing (see auth/self-label.ts for why the listing can't get it for
415
- // free). Runs after natService.start(), which already waits out a
416
- // freshly spawned node's warm-up, so the peer ID is normally there on
417
- // the first try. Best-effort: a failure logs and the next start heals
418
- // it — the listing gap is cosmetic, not a security boundary.
419
- try {
420
- await ensureSelfLabel({ store: trustCircleStore, getPeerId: () => owner.getPeerId() });
421
- } catch (err) {
422
- console.error(`self trust-circle label failed: ${(err as Error).message}`);
423
- }
424
-
425
- founder = { trustCircle, nat: natService, strands: strandService };
426
- } else {
427
- // Donor-only. If ownCadre was toggled off after a prior founder run,
428
- // orchestrator.init() re-attaches the still-running owner child (it would
429
- // otherwise linger in listNodes with no trustCircle/nat wired, serving
430
- // the host's own cadre despite being disabled). Reap it now so a disabled
431
- // own-cadre is actually stopped — its workdir + control-DB persist on
432
- // disk, so toggling ownCadre back on re-spawns it from saved config.
433
- try {
434
- await orchestrator.stopOwnerNode();
435
- } catch (err) {
436
- console.error(`owner node reap failed: ${(err as Error).message}`);
437
- }
438
- console.log('cadre-host: node-donor mode (host-own-cadre disabled — no owner node; /auth + /nat inactive)');
439
- }
440
-
441
354
  const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
442
355
  const server = createLocalUiServer({
443
356
  uiPort: cfg.uiPort,
444
357
  dataDir: cfg.dataDir,
445
358
  orchestrator,
446
- ...(founder ? { founder } : {}),
359
+ nat: natService,
447
360
  update: updateService,
448
- grants: grantService,
449
- donations: donationService,
361
+ hostedNodes,
450
362
  settingsStore,
451
363
  });
452
364
  const { url, port } = await server.start();
@@ -457,10 +369,10 @@ program
457
369
 
458
370
  await waitForTermination();
459
371
  clearInterval(reapTimer);
460
- donationSupervisor.stop();
372
+ hostedNodes.stopWatching();
373
+ hostedNodeSupervisor.stop();
461
374
  try { await server.stop(); } catch { /* ignore */ }
462
- try { await founder?.nat.stop(); } catch { /* ignore */ }
463
- try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
375
+ try { await natService.stop(); } catch { /* ignore */ }
464
376
  updateService.stop();
465
377
  console.log('cadre-host stopped.');
466
378
  process.exit(0);
@@ -491,6 +403,12 @@ program
491
403
  }
492
404
  const cfg = readHostConfig(cfgPath);
493
405
  const url = `http://127.0.0.1:${cfg.uiPort}`;
406
+ if (isHeadless()) {
407
+ // A browser here would open nowhere the admin can see: say how to reach it instead.
408
+ for (const line of uiAccessLines(cfg.uiPort, true)) console.log(line);
409
+ process.exit(0);
410
+ return;
411
+ }
494
412
  console.log(url);
495
413
  if (opts.browser !== false) {
496
414
  const res = openBrowser(url);
@@ -515,19 +433,6 @@ function parseIntArg(raw: string): number {
515
433
  return n;
516
434
  }
517
435
 
518
- function readPackageVersion(): string {
519
- try {
520
- // dist/bin/host.js -> ../../package.json
521
- const here = dirname(fileURLToPath(import.meta.url));
522
- const pkgPath = resolve(here, '..', '..', 'package.json');
523
- const raw = readFileSync(pkgPath, 'utf8');
524
- const parsed = JSON.parse(raw) as { version?: string };
525
- return parsed.version ?? '0.0.0-unknown';
526
- } catch {
527
- return '0.0.0-unknown';
528
- }
529
- }
530
-
531
436
  function waitForTermination(): Promise<void> {
532
437
  return new Promise<void>((resolveTerm) => {
533
438
  const done = () => resolveTerm();
@@ -547,340 +452,188 @@ async function confirmDestructive(message: string): Promise<boolean> {
547
452
  }
548
453
  }
549
454
 
550
- const requireForQr = createRequire(import.meta.url);
551
-
552
- function printEnrollmentInvite(invite: { encodedInvite: string; expiresAt?: string }): void {
553
- // Best-effort QR render — fall back to the bare token if the lib chokes.
554
- console.log('\nEnroll your first device with this invite:');
555
- try {
556
- const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
557
- qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
558
- console.log(rendered);
559
- });
560
- } catch (err) {
561
- console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
562
- }
563
- console.log(` ${invite.encodedInvite}`);
564
- if (invite.expiresAt) {
565
- console.log(` (expires ${invite.expiresAt})`);
566
- }
567
- }
568
-
569
- function printGrantToken(token: string, withQr: boolean): void {
570
- if (withQr) {
571
- // Best-effort QR render — fall back to the bare token if the lib chokes.
572
- try {
573
- const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
574
- qr.generate(token, { small: true }, (rendered) => {
575
- console.error(rendered);
576
- });
577
- } catch (err) {
578
- console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
579
- }
580
- }
581
- // The token goes to stdout alone so it can be piped/copied; metadata to stderr.
582
- console.log(token);
455
+ // ============================================================================
456
+ // join — start a node waiting to be claimed and show the QR code, or one that
457
+ // redeems a cadre invitation
458
+ // ============================================================================
459
+ //
460
+ // The host's one action. The owner's phone scans the code (or takes the pasted
461
+ // text), claims the node, and the node joins that phone's cadre. To put up a
462
+ // node for a friend, run it again and let the friend scan. With `--invitation`
463
+ // the node instead redeems an invitation the owner's app copied, at whichever
464
+ // member of the cadre it can reach. These commands are thin HTTP clients of the
465
+ // loopback `/api/hosted-nodes` surface — no bearer (same-machine admin), same
466
+ // posture as `nat`.
467
+
468
+ /** How long `join` waits for the new child's `/status` before giving up on the claim details. */
469
+ const CLAIM_DETAILS_WAIT_MS = 60_000;
470
+ const CLAIM_DETAILS_POLL_MS = 500;
471
+ /** How often `join` re-reads the node while waiting for the claim or the redemption. */
472
+ const CLAIM_WAIT_POLL_MS = 2_000;
473
+
474
+ interface JoinOptions {
475
+ qr?: boolean;
476
+ wait?: boolean;
477
+ invitation?: string;
478
+ port: string;
479
+ host: string;
583
480
  }
584
481
 
585
482
  program
586
- .command('invite')
587
- .description('Generate an invite to add a member to the trust circle')
588
- .argument('<label>', 'Display label for the new member (e.g. "Mom\'s phone")')
589
- .option('--ttl <duration>', 'Invite lifetime (e.g. 24h, 7d, 30m)', '24h')
483
+ .command('join')
484
+ .description('Start a node waiting to be claimed and show the code the owner\'s phone scans to add it to their cadre, or with --invitation one that redeems a cadre invitation')
485
+ .option('--invitation <encoded>', 'Join with a cadre invitation the owner\'s app copied instead of showing a code; a member of the cadre must be reachable from this machine')
486
+ .option('--no-qr', 'Print only the join text, no QR code')
487
+ .option('--no-wait', 'Exit once the code is shown (or the node started) instead of waiting for it to join')
590
488
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
591
489
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
592
- .action(async (label: string, opts: { ttl: string; port: string; host: string }) => {
593
- let ttlMs: number;
594
- try {
595
- ttlMs = parseDuration(opts.ttl);
596
- } catch (err) {
597
- console.error(`Invalid --ttl: ${(err as Error).message}`);
598
- process.exit(1);
599
- return;
490
+ .action(async (opts: JoinOptions) => {
491
+ const base = `http://${opts.host}:${resolvePort(opts.port)}`;
492
+ if (opts.invitation !== undefined) {
493
+ const { node } = await callApi<{ node: HostedNodeLike }>(
494
+ `${base}/api/hosted-nodes`, 'POST', { invitation: opts.invitation },
495
+ (error) => `cadre-host refused the invitation: ${error.message} (${error.code})`,
496
+ );
497
+ process.exit(await followInvitationJoin(base, node, opts));
600
498
  }
499
+ const { node } = await callApi<{ node: HostedNodeLike }>(`${base}/api/hosted-nodes`, 'POST', {});
500
+ await showClaimAndWait(base, node, opts);
501
+ process.exit(0);
502
+ });
601
503
 
602
- const port = resolvePort(opts.port);
504
+ /**
505
+ * Print the node's claim code once its child answers, then — unless `--no-wait`
506
+ * — follow the node until a phone claims it. Ctrl-C leaves the node waiting.
507
+ */
508
+ async function showClaimAndWait(base: string, node: HostedNodeLike, opts: JoinOptions): Promise<void> {
509
+ const id = node.id ?? '';
510
+ console.error(`Started hosted node ${id}; waiting for it to report its addresses…`);
511
+ const details = await waitForClaimDetails(base, id);
512
+ printClaimPayload(details, { qr: opts.qr !== false });
513
+ if (opts.wait === false) return;
514
+ console.error('Waiting for a phone to claim this node (Ctrl-C leaves it waiting)…');
515
+ const settled = await waitUntilSettled(base, id);
516
+ if (settled.status === 'error') {
517
+ console.error(`✗ Hosted node ${id} failed: ${settled.error ?? 'unknown error'}`);
518
+ process.exit(1);
519
+ }
520
+ printClaimed(settled);
521
+ }
603
522
 
604
- const url = `http://${opts.host}:${port}/auth/invites`;
605
- let response: Response;
606
- try {
607
- response = await fetch(url, {
608
- method: 'POST',
609
- headers: { 'content-type': 'application/json' },
610
- body: JSON.stringify({ label, ttlMs }),
611
- });
612
- } catch (err) {
613
- console.error(
614
- `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
615
- `Hint: is cadre-host running? Try \`cadre-host start\`.`,
616
- );
617
- process.exit(2);
618
- return;
619
- }
523
+ /**
524
+ * Follow a node redeeming its invitation until a member admits it or the redemption fails,
525
+ * unless `--no-wait`. Returns the exit code: 1 when it failed. Ctrl-C leaves the node joining.
526
+ */
527
+ async function followInvitationJoin(base: string, node: HostedNodeLike, opts: { wait?: boolean }): Promise<number> {
528
+ console.error(`Joining cadre ${node.partyId ?? '?'} through the invitation…`);
529
+ if (opts.wait === false) return 0;
530
+ const settled = await waitUntilSettled(base, node.id ?? '');
531
+ printInvitationOutcome(settled);
532
+ return settled.status === 'joined' ? 0 : 1;
533
+ }
620
534
 
621
- if (!response.ok) {
535
+ /** Poll `GET …/:id/claim` until the child answers (503 meanwhile), up to `CLAIM_DETAILS_WAIT_MS`. */
536
+ async function waitForClaimDetails(base: string, id: string): Promise<ClaimDetailsLike> {
537
+ const url = `${base}/api/hosted-nodes/${encodeURIComponent(id)}/claim`;
538
+ const deadline = Date.now() + CLAIM_DETAILS_WAIT_MS;
539
+ while (true) {
540
+ const response = await fetchOrExit(url, { method: 'GET' });
541
+ if (response.ok) return (await response.json() as ApiEnvelope<ClaimDetailsLike>).data;
542
+ if (response.status !== 503 || Date.now() >= deadline) {
622
543
  const text = await response.text().catch(() => '');
623
544
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
624
545
  process.exit(1);
625
- return;
626
546
  }
547
+ await sleep(CLAIM_DETAILS_POLL_MS);
548
+ }
549
+ }
627
550
 
628
- const body = await response.json() as { encodedInvite?: string; expiresAt?: string; token?: string };
629
- if (!body.encodedInvite) {
630
- console.error('cadre-host returned malformed response (missing encodedInvite)');
631
- process.exit(1);
632
- return;
633
- }
551
+ /** Poll `GET …/:id` until the node is `joined` or `error`. */
552
+ async function waitUntilSettled(base: string, id: string): Promise<HostedNodeLike> {
553
+ const url = `${base}/api/hosted-nodes/${encodeURIComponent(id)}`;
554
+ while (true) {
555
+ const { node } = await callApi<{ node: HostedNodeLike }>(url, 'GET');
556
+ if (node.status === 'joined' || node.status === 'error') return node;
557
+ await sleep(CLAIM_WAIT_POLL_MS);
558
+ }
559
+ }
634
560
 
635
- console.log(body.encodedInvite);
636
- if (body.expiresAt) {
637
- console.error(`(expires at ${body.expiresAt})`);
638
- }
639
- process.exit(0);
640
- });
561
+ // ============================================================================
562
+ // node subcommands — the nodes this host runs
563
+ // ============================================================================
641
564
 
642
- const trust = program
643
- .command('trust')
644
- .description('Manage the trust circle (list members, revoke invites/members)');
565
+ const node = program
566
+ .command('node')
567
+ .description('List, remove, reset or retry the nodes this host runs');
645
568
 
646
- trust
569
+ node
647
570
  .command('list')
648
- .description('List trust-circle members and pending invites')
571
+ .description('List hosted nodes: id, status, cadre, owner fingerprint, connected')
649
572
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
650
573
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
651
574
  .action(async (opts: { port: string; host: string }) => {
652
- const url = `http://${opts.host}:${resolvePort(opts.port)}/auth/trust-circle`;
653
- let response: Response;
654
- try {
655
- response = await fetch(url);
656
- } catch (err) {
657
- console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
658
- process.exit(2);
659
- return;
660
- }
661
- if (!response.ok) {
662
- console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
663
- process.exit(1);
664
- return;
665
- }
666
- const body = await response.json() as {
667
- members: Array<{ peerId: string; label: string; addedAt: string; self?: boolean }>;
668
- pending: Array<{ token: string; label: string; createdAt: string; expiresAt?: string }>;
669
- };
670
- console.log('Members:');
671
- if (body.members.length === 0) {
672
- console.log(' (none)');
673
- } else {
674
- for (const m of body.members) {
675
- const self = m.self ? ' [self]' : '';
676
- console.log(` ${m.peerId} ${m.label}${self}`);
677
- }
678
- }
679
- console.log('\nPending invites:');
680
- if (body.pending.length === 0) {
681
- console.log(' (none)');
682
- } else {
683
- for (const p of body.pending) {
684
- const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
685
- console.log(` ${p.token} ${p.label}${expires}`);
686
- }
687
- }
688
- process.exit(0);
689
- });
690
-
691
- trust
692
- .command('revoke')
693
- .description('Revoke a pending invite (by token) or remove a member (by peerId)')
694
- .argument('<id>', 'Token (pending invite) or peerId (existing member) to remove')
695
- .option('--kind <kind>', 'Force interpretation: "invite" | "member" (default: auto)', 'auto')
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 (id: string, opts: { kind: string; port: string; host: string }) => {
699
575
  const base = `http://${opts.host}:${resolvePort(opts.port)}`;
700
- const kind = opts.kind === 'auto'
701
- ? (looksLikePeerId(id) ? 'member' : 'invite')
702
- : opts.kind;
703
- const path = kind === 'member'
704
- ? `/auth/members/${encodeURIComponent(id)}`
705
- : `/auth/invites/${encodeURIComponent(id)}`;
706
-
707
- let response: Response;
708
- try {
709
- response = await fetch(`${base}${path}`, { method: 'DELETE' });
710
- } catch (err) {
711
- console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
712
- process.exit(2);
713
- return;
714
- }
715
- if (!response.ok) {
716
- const text = await response.text().catch(() => '');
717
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
718
- process.exit(1);
719
- return;
720
- }
721
- console.log(`revoked ${kind}: ${id}`);
576
+ const { nodes } = await callApi<{ nodes: HostedNodeLike[] }>(`${base}/api/hosted-nodes`, 'GET');
577
+ printNodeList(nodes);
722
578
  process.exit(0);
723
579
  });
724
580
 
725
- // ============================================================================
726
- // grant subcommands — donation grant tokens (who may ask this host for a node)
727
- // ============================================================================
728
- //
729
- // A grant token lets one grantee (friend/family) present a Bearer credential to
730
- // ask this host to donate cadre nodes, up to a per-grantee cap. These commands
731
- // are thin HTTP clients of the loopback `/grants-admin` admin surface — no
732
- // bearer (same-machine admin), same posture as `invite` / `trust`.
733
-
734
- const grant = program
735
- .command('grant')
736
- .description('Manage donation grant tokens (who may ask this host to donate a node)');
737
-
738
- grant
739
- .command('issue')
740
- .description('Issue a grant token for one grantee (prints the token + QR)')
741
- .argument('<label>', 'Display label for the grantee (e.g. "Alice\'s cadre")')
742
- .option('--max-nodes <n>', 'Max concurrently-live donated nodes this grant may hold', parseIntArg)
743
- .option('--ttl <duration>', 'Grant lifetime (e.g. 30d, 12h); omit for no expiry')
744
- .option('--no-qr', 'Print only the token, no QR code')
581
+ node
582
+ .command('remove')
583
+ .description('Stop a hosted node and delete its data on this machine (its cadre keeps its row until the owner removes it there)')
584
+ .argument('<id>', 'Hosted node id (hn_…), as `cadre-host node list` shows it')
745
585
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
746
586
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
747
- .action(async (label: string, opts: {
748
- maxNodes?: number;
749
- ttl?: string;
750
- qr?: boolean;
751
- port: string;
752
- host: string;
753
- }) => {
754
- const payload: { label: string; maxNodes?: number; ttlMs?: number } = { label };
755
- if (typeof opts.maxNodes === 'number') payload.maxNodes = opts.maxNodes;
756
- if (opts.ttl) {
757
- try {
758
- payload.ttlMs = parseDuration(opts.ttl);
759
- } catch (err) {
760
- console.error(`Invalid --ttl: ${(err as Error).message}`);
761
- process.exit(1);
762
- return;
763
- }
764
- }
765
-
766
- const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
767
- let response: Response;
768
- try {
769
- response = await fetch(url, {
770
- method: 'POST',
771
- headers: { 'content-type': 'application/json' },
772
- body: JSON.stringify(payload),
773
- });
774
- } catch (err) {
775
- console.error(
776
- `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
777
- `Hint: is cadre-host running? Try \`cadre-host start\`.`,
778
- );
779
- process.exit(2);
780
- return;
781
- }
782
- if (!response.ok) {
783
- const text = await response.text().catch(() => '');
784
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
785
- process.exit(1);
786
- return;
787
- }
788
- const body = await response.json() as { grant?: { token?: string; maxNodes?: number; expiresAt?: string } };
789
- const issued = body.grant;
790
- if (!issued?.token) {
791
- console.error('cadre-host returned malformed response (missing grant token)');
792
- process.exit(1);
793
- return;
794
- }
795
- printGrantToken(issued.token, opts.qr !== false);
796
- console.error(`(maxNodes ${issued.maxNodes ?? '?'}${issued.expiresAt ? `, expires ${issued.expiresAt}` : ', no expiry'})`);
797
- process.exit(0);
798
- });
799
-
800
- grant
801
- .command('list')
802
- .description('List issued grant tokens')
803
- .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
804
- .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
805
- .action(async (opts: { port: string; host: string }) => {
806
- const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
807
- let response: Response;
808
- try {
809
- response = await fetch(url);
810
- } catch (err) {
811
- console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
812
- process.exit(2);
813
- return;
814
- }
815
- if (!response.ok) {
816
- console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
817
- process.exit(1);
818
- return;
819
- }
820
- const body = await response.json() as { grants: GrantListing[] };
821
- console.log('Grants:');
822
- if (body.grants.length === 0) {
823
- console.log(' (none)');
824
- } else {
825
- for (const g of body.grants) {
826
- const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
827
- console.log(` ${g.token} ${g.label} live=${g.liveNodes} max=${g.maxNodes}${state}`);
828
- }
829
- }
587
+ .action(async (id: string, opts: { port: string; host: string }) => {
588
+ const base = `http://${opts.host}:${resolvePort(opts.port)}`;
589
+ await callApi(`${base}/api/hosted-nodes/${encodeURIComponent(id)}`, 'DELETE', undefined, (error) => (error.code === 'not_found'
590
+ ? `No hosted node with id "${id}". Run \`cadre-host node list\` to list them.`
591
+ : `cadre-host refused the removal: ${error.message} (${error.code})`));
592
+ console.log(`removed hosted node ${id}`);
830
593
  process.exit(0);
831
594
  });
832
595
 
833
- grant
834
- .command('revoke')
835
- .description('Revoke a grant token (blocks future requests) and shut down the nodes donated under it')
836
- .argument('<token>', 'Grant token to revoke')
837
- .option('--keep-nodes', 'Leave the nodes already donated under this grant running')
596
+ node
597
+ .command('reset')
598
+ .description('Remove a hosted node and start a fresh one with a new code (for a node someone else claimed, or one that failed)')
599
+ .argument('<id>', 'Hosted node id (hn_…) to replace')
600
+ .option('--no-qr', 'Print only the join text, no QR code')
601
+ .option('--no-wait', 'Exit once the code is shown instead of waiting for the claim')
838
602
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
839
603
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
840
- .action(async (token: string, opts: { keepNodes?: boolean; port: string; host: string }) => {
604
+ .action(async (id: string, opts: JoinOptions) => {
841
605
  const base = `http://${opts.host}:${resolvePort(opts.port)}`;
842
- const query = opts.keepNodes ? '?keepNodes=true' : '';
843
- const response = await adminDelete(base, `/grants-admin/${encodeURIComponent(token)}${query}`);
844
- const body = await response.json() as { terminated?: string[] };
845
- console.log(`revoked grant: ${token}`);
846
- console.log(opts.keepNodes
847
- ? 'existing donated nodes left running'
848
- : `terminated ${body.terminated?.length ?? 0} donated node(s)`);
606
+ const { node: fresh } = await callApi<{ node: HostedNodeLike }>(
607
+ `${base}/api/hosted-nodes/${encodeURIComponent(id)}/reset`, 'POST', {},
608
+ (error) => (error.code === 'not_found'
609
+ ? `No hosted node with id "${id}". Run \`cadre-host node list\` to list them.`
610
+ : `cadre-host refused the reset: ${error.message} (${error.code})`),
611
+ );
612
+ console.error(`Removed hosted node ${id}.`);
613
+ await showClaimAndWait(base, fresh, opts);
849
614
  process.exit(0);
850
615
  });
851
616
 
852
- grant
853
- .command('terminate')
854
- .description('Shut down one donated node (the id shown on the Nodes page)')
855
- .argument('<donation-id>', 'Donation id (grn_…) of the node to shut down')
617
+ node
618
+ .command('retry')
619
+ .description('Start an invitation node again after no member of its cadre could be reached, and wait until it joins or fails')
620
+ .argument('<id>', 'Hosted node id (hn_…) whose invitation failed')
621
+ .option('--no-wait', 'Exit once the node is started again instead of waiting for it to join')
856
622
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
857
623
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
858
- .action(async (id: string, opts: { port: string; host: string }) => {
624
+ .action(async (id: string, opts: { wait?: boolean; port: string; host: string }) => {
859
625
  const base = `http://${opts.host}:${resolvePort(opts.port)}`;
860
- await adminDelete(base, `/grants-admin/donations/${encodeURIComponent(id)}`);
861
- console.log(`terminated donated node: ${id}`);
862
- process.exit(0);
626
+ const { node: retried } = await callApi<{ node: HostedNodeLike }>(
627
+ `${base}/api/hosted-nodes/${encodeURIComponent(id)}/retry`, 'POST', {},
628
+ (error) => (error.code === 'not_found'
629
+ ? `No hosted node with id "${id}". Run \`cadre-host node list\` to list them.`
630
+ : `cadre-host refused the retry: ${error.message} (${error.code})`),
631
+ );
632
+ process.exit(await followInvitationJoin(base, retried, opts));
863
633
  });
864
634
 
865
- /**
866
- * DELETE against the loopback admin surface, exiting the process on failure —
867
- * 2 when cadre-host is unreachable, 1 on a non-OK response — so callers only
868
- * ever see an OK response.
869
- */
870
- async function adminDelete(base: string, path: string): Promise<Response> {
871
- let response: Response;
872
- try {
873
- response = await fetch(`${base}${path}`, { method: 'DELETE' });
874
- } catch (err) {
875
- console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
876
- process.exit(2);
877
- }
878
- if (!response.ok) {
879
- const text = await response.text().catch(() => '');
880
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
881
- process.exit(1);
882
- }
883
- return response;
635
+ function sleep(ms: number): Promise<void> {
636
+ return new Promise((res) => setTimeout(res, ms));
884
637
  }
885
638
 
886
639
  // ============================================================================
@@ -927,23 +680,21 @@ nat
927
680
 
928
681
  nat
929
682
  .command('settings')
930
- .description('Update NAT settings (external port, UPnP toggle)')
931
- .option('--external-port <port>', 'External port to map')
932
- .option('--internal-port <port>', 'Internal libp2p port (defaults to external)')
933
- .option('--no-upnp', 'Disable UPnP/NAT-PMP port mapping')
683
+ .description('Update NAT settings (network mode, UPnP toggle)')
684
+ .option('--network <mode>', 'auto (default: public when this machine holds a public IPv4), lan (behind a router) or public (reached directly, e.g. a VPS)')
685
+ .option('--upnp', 'Ask the router for port mappings over UPnP')
686
+ .option('--no-upnp', 'Stop asking the router for port mappings (manual forwards stay)')
934
687
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
935
688
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
936
689
  .action(async (opts: {
937
- externalPort?: string;
938
- internalPort?: string;
690
+ network?: string;
939
691
  upnp?: boolean;
940
692
  port: string;
941
693
  host: string;
942
694
  }) => {
943
695
  const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
944
696
  const patch: Record<string, unknown> = {};
945
- if (opts.externalPort) patch.externalPort = resolvePort(opts.externalPort);
946
- if (opts.internalPort) patch.internalPort = resolvePort(opts.internalPort);
697
+ if (opts.network !== undefined) patch.network = opts.network;
947
698
  // Commander assigns `upnp: false` when `--no-upnp` is passed.
948
699
  if (opts.upnp === false) patch.upnpEnabled = false;
949
700
  if (opts.upnp === true) patch.upnpEnabled = true;
@@ -953,6 +704,67 @@ nat
953
704
  process.exit(0);
954
705
  });
955
706
 
707
+ interface ForwardOptions {
708
+ tcp?: number;
709
+ ws?: number;
710
+ clearTcp?: boolean;
711
+ clearWs?: boolean;
712
+ clear?: boolean;
713
+ port: string;
714
+ host: string;
715
+ }
716
+
717
+ nat
718
+ .command('forward')
719
+ .description('Tell cadre-host the external ports you forwarded on your router for one node')
720
+ .argument('<nodeId>', 'Node id as `cadre-host nat status` lists it')
721
+ .option('--tcp <port>', "External port your router forwards to the node's TCP port", parsePortArg)
722
+ .option('--ws <port>', "External port your router forwards to the node's WebSocket port", parsePortArg)
723
+ .option('--clear-tcp', 'Forget the TCP forward')
724
+ .option('--clear-ws', 'Forget the WebSocket forward')
725
+ .option('--clear', 'Forget both forwards')
726
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
727
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
728
+ .action(async (nodeId: string, opts: ForwardOptions) => {
729
+ const patch = forwardPatchFrom(opts);
730
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/nodes/${encodeURIComponent(nodeId)}/forward`;
731
+ const body = await callJson<NatStatusLike>(url, 'PUT', patch, (error) => (error.code === 'unknown_node'
732
+ ? `No node with id "${nodeId}" runs on this host. Run \`cadre-host nat status\` to list node ids.`
733
+ : `cadre-host refused the forward: ${error.message} (${error.code})`));
734
+ printForwardResult(nodeId, body);
735
+ process.exit(0);
736
+ });
737
+
738
+ /** The route's patch: a port sets that forward, a clear flag sends `null`. Exits on conflicting or missing flags. */
739
+ function forwardPatchFrom(opts: ForwardOptions): ManualForwardPatch {
740
+ const patch: ManualForwardPatch = {};
741
+ const clears = { tcp: opts.clearTcp === true, ws: opts.clearWs === true };
742
+ for (const kind of ['tcp', 'ws'] as const) {
743
+ const value = opts[kind];
744
+ const clear = opts.clear === true || clears[kind];
745
+ if (value !== undefined && clear) {
746
+ console.error(`--${kind} conflicts with ${opts.clear ? '--clear' : `--clear-${kind}`}`);
747
+ process.exit(1);
748
+ }
749
+ if (value !== undefined) patch[kind] = value;
750
+ else if (clear) patch[kind] = null;
751
+ }
752
+ if (Object.keys(patch).length === 0) {
753
+ console.error('Nothing to change: pass --tcp, --ws, --clear-tcp, --clear-ws or --clear.');
754
+ process.exit(1);
755
+ }
756
+ return patch;
757
+ }
758
+
759
+ /** Commander parser for an external port. */
760
+ function parsePortArg(raw: string): number {
761
+ const n = Number(raw);
762
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
763
+ throw new InvalidArgumentError('expected a whole number from 1 to 65535.');
764
+ }
765
+ return n;
766
+ }
767
+
956
768
  const ddns = nat
957
769
  .command('ddns')
958
770
  .description('Configure dynamic DNS');
@@ -1035,8 +847,7 @@ ddns
1035
847
  // do NOT go through the running management API). Private keys land in the OS
1036
848
  // keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
1037
849
  // bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
1038
- // credentials take effect on the next owner-node (re)spawn — run
1039
- // `cadre-host` restart (or restart the service) to apply them immediately.
850
+ // credentials reach a node the next time it is spawned.
1040
851
 
1041
852
  const push = program
1042
853
  .command('push')
@@ -1065,7 +876,7 @@ push
1065
876
  clientEmail: opts.clientEmail,
1066
877
  privateKey,
1067
878
  });
1068
- console.log('✓ FCM credentials stored. Restart cadre-host to apply on the owner node.');
879
+ console.log('✓ FCM credentials stored. A node picks them up the next time it is spawned.');
1069
880
  process.exit(0);
1070
881
  });
1071
882
 
@@ -1102,7 +913,7 @@ push
1102
913
  });
1103
914
  console.log(
1104
915
  `✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
1105
- `Restart cadre-host to apply on the owner node.`,
916
+ `A node picks them up the next time it is spawned.`,
1106
917
  );
1107
918
  process.exit(0);
1108
919
  });
@@ -1146,7 +957,7 @@ push
1146
957
  updateHostConfig(cfgPath, { push: rest });
1147
958
  }
1148
959
  }
1149
- console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
960
+ console.log(`✓ Cleared push credentials: ${target}. A node stops carrying them the next time it is spawned.`);
1150
961
  process.exit(0);
1151
962
  });
1152
963
 
@@ -1200,46 +1011,77 @@ function readPrivateKeyArg(file: string | undefined, inline: string | undefined,
1200
1011
  }
1201
1012
 
1202
1013
  async function getJson(url: string): Promise<NatStatusLike> {
1203
- return await callJson(url, 'GET');
1014
+ return await callJson<NatStatusLike>(url, 'GET');
1204
1015
  }
1205
1016
 
1206
1017
  async function postJson(url: string, body: unknown): Promise<NatStatusLike> {
1207
- return await callJson(url, 'POST', body);
1018
+ return await callJson<NatStatusLike>(url, 'POST', body);
1208
1019
  }
1209
1020
 
1210
1021
  async function putJson(url: string, body: unknown): Promise<NatStatusLike> {
1211
- return await callJson(url, 'PUT', body);
1022
+ return await callJson<NatStatusLike>(url, 'PUT', body);
1023
+ }
1024
+
1025
+ /** The `{ code, message }` of a management-API error response. */
1026
+ interface ApiErrorBody {
1027
+ code: string;
1028
+ message: string;
1029
+ }
1030
+
1031
+ /** The `/api/*` routes' success envelope. */
1032
+ interface ApiEnvelope<T> {
1033
+ ok: true;
1034
+ data: T;
1035
+ }
1036
+
1037
+ /**
1038
+ * One request to an `/api/*` route, unwrapping its `{ ok, data }` envelope.
1039
+ * Exits on failure as `callJson` does; a 204 answers `undefined`.
1040
+ */
1041
+ async function callApi<T = undefined>(
1042
+ url: string,
1043
+ method: string,
1044
+ body?: unknown,
1045
+ describeError?: (error: ApiErrorBody) => string,
1046
+ ): Promise<T> {
1047
+ const envelope = await callJson<ApiEnvelope<T> | undefined>(url, method, body, describeError);
1048
+ return envelope?.data as T;
1212
1049
  }
1213
1050
 
1214
- interface NatStatusLike {
1215
- portMode?: string;
1216
- externalPort?: number;
1217
- internalPort?: number;
1218
- externalIp?: string | null;
1219
- routerExternalIp?: string | null;
1220
- cgnatDetected?: boolean;
1221
- directReachability?: string;
1222
- lastTestedAt?: string | null;
1223
- mappingLeaseExpiresAt?: string | null;
1224
- ddns?: {
1225
- providerId?: string | null;
1226
- hostname?: string | null;
1227
- externallyManaged?: boolean;
1228
- lastUpdateAt?: string | null;
1229
- lastUpdateOk?: boolean | null;
1230
- lastError?: string | null;
1231
- };
1051
+ /**
1052
+ * One request to the management API, exiting on failure: 2 when cadre-host is
1053
+ * unreachable, 1 on a non-OK response, printed by `describeError` when the
1054
+ * response carries a typed error and the caller has words for it.
1055
+ */
1056
+ async function callJson<T>(
1057
+ url: string,
1058
+ method: string,
1059
+ body?: unknown,
1060
+ describeError?: (error: ApiErrorBody) => string,
1061
+ ): Promise<T> {
1062
+ const init: RequestInit = { method };
1063
+ if (body !== undefined) {
1064
+ init.headers = { 'content-type': 'application/json' };
1065
+ init.body = JSON.stringify(body);
1066
+ }
1067
+ const response = await fetchOrExit(url, init);
1068
+ if (!response.ok) {
1069
+ const text = await response.text().catch(() => '');
1070
+ const error = describeError ? apiErrorOf(response, text) : null;
1071
+ console.error(error && describeError
1072
+ ? describeError(error)
1073
+ : `cadre-host returned ${response.status}: ${text || response.statusText}`);
1074
+ process.exit(1);
1075
+ throw new Error('non-ok');
1076
+ }
1077
+ if (response.status === 204) return undefined as T;
1078
+ return await response.json() as T;
1232
1079
  }
1233
1080
 
1234
- async function callJson(url: string, method: string, body?: unknown): Promise<NatStatusLike> {
1235
- let response: Response;
1081
+ /** `fetch`, exiting 2 with a hint when cadre-host cannot be reached at all. */
1082
+ async function fetchOrExit(url: string, init: RequestInit): Promise<Response> {
1236
1083
  try {
1237
- const init: RequestInit = { method };
1238
- if (body !== undefined) {
1239
- init.headers = { 'content-type': 'application/json' };
1240
- init.body = JSON.stringify(body);
1241
- }
1242
- response = await fetch(url, init);
1084
+ return await fetch(url, init);
1243
1085
  } catch (err) {
1244
1086
  console.error(
1245
1087
  `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
@@ -1248,38 +1090,21 @@ async function callJson(url: string, method: string, body?: unknown): Promise<Na
1248
1090
  process.exit(2);
1249
1091
  throw err;
1250
1092
  }
1251
- if (!response.ok) {
1252
- const text = await response.text().catch(() => '');
1253
- console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
1254
- process.exit(1);
1255
- throw new Error('non-ok');
1256
- }
1257
- if (response.status === 204) return {} as NatStatusLike;
1258
- return await response.json() as NatStatusLike;
1259
1093
  }
1260
1094
 
1261
- function printNatStatus(s: NatStatusLike): void {
1262
- console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
1263
- if (s.routerExternalIp) {
1264
- console.log(`Router IP: ${s.routerExternalIp}`);
1265
- }
1266
- console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
1267
- console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
1268
- if (s.mappingLeaseExpiresAt) {
1269
- console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
1270
- }
1271
- const ddns = s.ddns ?? {};
1272
- if (ddns.providerId || ddns.hostname) {
1273
- console.log(`\nDDNS:`);
1274
- console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
1275
- console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
1276
- if (ddns.lastUpdateAt) {
1277
- const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
1278
- console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
1279
- }
1280
- } else {
1281
- console.log('\nDDNS: not configured');
1095
+ /** The typed error in a `{ ok: false, error: { code, message } }` body (`server/error-handler.ts`), or null. */
1096
+ function apiErrorOf(response: Response, text: string): ApiErrorBody | null {
1097
+ if (!response.headers.get('content-type')?.includes('application/json')) return null;
1098
+ let parsed: { error?: { code?: unknown; message?: unknown } };
1099
+ try {
1100
+ parsed = JSON.parse(text) as typeof parsed;
1101
+ } catch {
1102
+ // Malformed JSON: the caller prints the raw text instead.
1103
+ return null;
1282
1104
  }
1105
+ const { code, message } = parsed.error ?? {};
1106
+ if (typeof code !== 'string') return null;
1107
+ return { code, message: typeof message === 'string' ? message : '' };
1283
1108
  }
1284
1109
 
1285
1110
  /** Read a single line from stdin with echo suppressed (TTY only). */