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