@serfab/cadre-host 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/README.md +101 -0
  2. package/dist/auth/duration.d.ts +1 -0
  3. package/dist/auth/duration.js +32 -0
  4. package/dist/auth/duration.js.map +1 -0
  5. package/dist/auth/index.d.ts +9 -0
  6. package/dist/auth/index.js +8 -0
  7. package/dist/auth/index.js.map +1 -0
  8. package/dist/auth/trust-circle-store.d.ts +26 -0
  9. package/dist/auth/trust-circle-store.js +129 -0
  10. package/dist/auth/trust-circle-store.js.map +1 -0
  11. package/dist/auth/trust-circle.d.ts +91 -0
  12. package/dist/auth/trust-circle.js +230 -0
  13. package/dist/auth/trust-circle.js.map +1 -0
  14. package/dist/auth/types.d.ts +81 -0
  15. package/dist/auth/types.js +24 -0
  16. package/dist/auth/types.js.map +1 -0
  17. package/dist/bin/host.d.ts +13 -0
  18. package/dist/bin/host.js +826 -0
  19. package/dist/bin/host.js.map +1 -0
  20. package/dist/index.d.ts +32 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/installer/browser.d.ts +14 -0
  24. package/dist/installer/browser.js +35 -0
  25. package/dist/installer/browser.js.map +1 -0
  26. package/dist/installer/config.d.ts +43 -0
  27. package/dist/installer/config.js +113 -0
  28. package/dist/installer/config.js.map +1 -0
  29. package/dist/installer/identity.d.ts +23 -0
  30. package/dist/installer/identity.js +36 -0
  31. package/dist/installer/identity.js.map +1 -0
  32. package/dist/installer/index.d.ts +83 -0
  33. package/dist/installer/index.js +204 -0
  34. package/dist/installer/index.js.map +1 -0
  35. package/dist/installer/paths.d.ts +33 -0
  36. package/dist/installer/paths.js +84 -0
  37. package/dist/installer/paths.js.map +1 -0
  38. package/dist/installer/platform.d.ts +9 -0
  39. package/dist/installer/platform.js +21 -0
  40. package/dist/installer/platform.js.map +1 -0
  41. package/dist/installer/service-host/index.d.ts +11 -0
  42. package/dist/installer/service-host/index.js +16 -0
  43. package/dist/installer/service-host/index.js.map +1 -0
  44. package/dist/installer/service-host/launchd.d.ts +16 -0
  45. package/dist/installer/service-host/launchd.js +79 -0
  46. package/dist/installer/service-host/launchd.js.map +1 -0
  47. package/dist/installer/service-host/nssm.d.ts +20 -0
  48. package/dist/installer/service-host/nssm.js +88 -0
  49. package/dist/installer/service-host/nssm.js.map +1 -0
  50. package/dist/installer/service-host/systemd.d.ts +16 -0
  51. package/dist/installer/service-host/systemd.js +78 -0
  52. package/dist/installer/service-host/systemd.js.map +1 -0
  53. package/dist/installer/service-host/template.d.ts +6 -0
  54. package/dist/installer/service-host/template.js +20 -0
  55. package/dist/installer/service-host/template.js.map +1 -0
  56. package/dist/installer/service-host/types.d.ts +40 -0
  57. package/dist/installer/service-host/types.js +6 -0
  58. package/dist/installer/service-host/types.js.map +1 -0
  59. package/dist/installer/wizard.d.ts +31 -0
  60. package/dist/installer/wizard.js +67 -0
  61. package/dist/installer/wizard.js.map +1 -0
  62. package/dist/nat/address-resolver.d.ts +22 -0
  63. package/dist/nat/address-resolver.js +31 -0
  64. package/dist/nat/address-resolver.js.map +1 -0
  65. package/dist/nat/ddns/duckdns.d.ts +12 -0
  66. package/dist/nat/ddns/duckdns.js +57 -0
  67. package/dist/nat/ddns/duckdns.js.map +1 -0
  68. package/dist/nat/ddns/index.d.ts +30 -0
  69. package/dist/nat/ddns/index.js +21 -0
  70. package/dist/nat/ddns/index.js.map +1 -0
  71. package/dist/nat/ddns/updater.d.ts +68 -0
  72. package/dist/nat/ddns/updater.js +194 -0
  73. package/dist/nat/ddns/updater.js.map +1 -0
  74. package/dist/nat/external-ip.d.ts +62 -0
  75. package/dist/nat/external-ip.js +114 -0
  76. package/dist/nat/external-ip.js.map +1 -0
  77. package/dist/nat/index.d.ts +22 -0
  78. package/dist/nat/index.js +16 -0
  79. package/dist/nat/index.js.map +1 -0
  80. package/dist/nat/nat-service.d.ts +101 -0
  81. package/dist/nat/nat-service.js +284 -0
  82. package/dist/nat/nat-service.js.map +1 -0
  83. package/dist/nat/nat-store.d.ts +19 -0
  84. package/dist/nat/nat-store.js +136 -0
  85. package/dist/nat/nat-store.js.map +1 -0
  86. package/dist/nat/port-mapper.d.ts +98 -0
  87. package/dist/nat/port-mapper.js +226 -0
  88. package/dist/nat/port-mapper.js.map +1 -0
  89. package/dist/nat/reachability.d.ts +19 -0
  90. package/dist/nat/reachability.js +29 -0
  91. package/dist/nat/reachability.js.map +1 -0
  92. package/dist/nat/secrets/file-store.d.ts +23 -0
  93. package/dist/nat/secrets/file-store.js +102 -0
  94. package/dist/nat/secrets/file-store.js.map +1 -0
  95. package/dist/nat/secrets/index.d.ts +36 -0
  96. package/dist/nat/secrets/index.js +56 -0
  97. package/dist/nat/secrets/index.js.map +1 -0
  98. package/dist/nat/secrets/keytar-store.d.ts +18 -0
  99. package/dist/nat/secrets/keytar-store.js +52 -0
  100. package/dist/nat/secrets/keytar-store.js.map +1 -0
  101. package/dist/nat/types.d.ts +120 -0
  102. package/dist/nat/types.js +24 -0
  103. package/dist/nat/types.js.map +1 -0
  104. package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
  105. package/dist/orchestrator/host-process-orchestrator.js +557 -0
  106. package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
  107. package/dist/orchestrator/index.d.ts +3 -0
  108. package/dist/orchestrator/index.js +2 -0
  109. package/dist/orchestrator/index.js.map +1 -0
  110. package/dist/orchestrator/log-rotator.d.ts +25 -0
  111. package/dist/orchestrator/log-rotator.js +56 -0
  112. package/dist/orchestrator/log-rotator.js.map +1 -0
  113. package/dist/orchestrator/pid-liveness.d.ts +11 -0
  114. package/dist/orchestrator/pid-liveness.js +26 -0
  115. package/dist/orchestrator/pid-liveness.js.map +1 -0
  116. package/dist/orchestrator/port-allocator.d.ts +16 -0
  117. package/dist/orchestrator/port-allocator.js +38 -0
  118. package/dist/orchestrator/port-allocator.js.map +1 -0
  119. package/dist/orchestrator/state-store.d.ts +32 -0
  120. package/dist/orchestrator/state-store.js +44 -0
  121. package/dist/orchestrator/state-store.js.map +1 -0
  122. package/dist/orchestrator/types.d.ts +85 -0
  123. package/dist/orchestrator/types.js +31 -0
  124. package/dist/orchestrator/types.js.map +1 -0
  125. package/dist/server/error-handler.d.ts +26 -0
  126. package/dist/server/error-handler.js +95 -0
  127. package/dist/server/error-handler.js.map +1 -0
  128. package/dist/server/events/bus.d.ts +17 -0
  129. package/dist/server/events/bus.js +36 -0
  130. package/dist/server/events/bus.js.map +1 -0
  131. package/dist/server/events/sse-route.d.ts +22 -0
  132. package/dist/server/events/sse-route.js +65 -0
  133. package/dist/server/events/sse-route.js.map +1 -0
  134. package/dist/server/events/types.d.ts +25 -0
  135. package/dist/server/events/types.js +8 -0
  136. package/dist/server/events/types.js.map +1 -0
  137. package/dist/server/index.d.ts +67 -0
  138. package/dist/server/index.js +149 -0
  139. package/dist/server/index.js.map +1 -0
  140. package/dist/server/origin-guard.d.ts +24 -0
  141. package/dist/server/origin-guard.js +72 -0
  142. package/dist/server/origin-guard.js.map +1 -0
  143. package/dist/server/routes/nat.d.ts +16 -0
  144. package/dist/server/routes/nat.js +42 -0
  145. package/dist/server/routes/nat.js.map +1 -0
  146. package/dist/server/routes/nodes.d.ts +23 -0
  147. package/dist/server/routes/nodes.js +128 -0
  148. package/dist/server/routes/nodes.js.map +1 -0
  149. package/dist/server/routes/settings.d.ts +26 -0
  150. package/dist/server/routes/settings.js +97 -0
  151. package/dist/server/routes/settings.js.map +1 -0
  152. package/dist/server/routes/status.d.ts +42 -0
  153. package/dist/server/routes/status.js +76 -0
  154. package/dist/server/routes/status.js.map +1 -0
  155. package/dist/server/routes/trust-circle.d.ts +17 -0
  156. package/dist/server/routes/trust-circle.js +35 -0
  157. package/dist/server/routes/trust-circle.js.map +1 -0
  158. package/dist/server/routes/update.d.ts +16 -0
  159. package/dist/server/routes/update.js +29 -0
  160. package/dist/server/routes/update.js.map +1 -0
  161. package/dist/server/server.d.ts +40 -0
  162. package/dist/server/server.js +90 -0
  163. package/dist/server/server.js.map +1 -0
  164. package/dist/server/settings-store.d.ts +24 -0
  165. package/dist/server/settings-store.js +27 -0
  166. package/dist/server/settings-store.js.map +1 -0
  167. package/dist/server/static.d.ts +25 -0
  168. package/dist/server/static.js +177 -0
  169. package/dist/server/static.js.map +1 -0
  170. package/dist/ui/assets/index-BwU5mJGF.css +1 -0
  171. package/dist/ui/assets/index-C9uChk2P.js +14 -0
  172. package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
  173. package/dist/ui/index.html +14 -0
  174. package/dist/update/apply.d.ts +37 -0
  175. package/dist/update/apply.js +102 -0
  176. package/dist/update/apply.js.map +1 -0
  177. package/dist/update/index.d.ts +101 -0
  178. package/dist/update/index.js +228 -0
  179. package/dist/update/index.js.map +1 -0
  180. package/dist/update/manifest.d.ts +48 -0
  181. package/dist/update/manifest.js +152 -0
  182. package/dist/update/manifest.js.map +1 -0
  183. package/dist/update/release-key.d.ts +24 -0
  184. package/dist/update/release-key.js +52 -0
  185. package/dist/update/release-key.js.map +1 -0
  186. package/dist/update/store.d.ts +22 -0
  187. package/dist/update/store.js +81 -0
  188. package/dist/update/store.js.map +1 -0
  189. package/dist/update/types.d.ts +90 -0
  190. package/dist/update/types.js +22 -0
  191. package/dist/update/types.js.map +1 -0
  192. package/dist/update/version.d.ts +18 -0
  193. package/dist/update/version.js +76 -0
  194. package/dist/update/version.js.map +1 -0
  195. package/package.json +80 -0
  196. package/service/README.md +86 -0
  197. package/service/cadre-host.service.tmpl +16 -0
  198. package/service/com.serfab.cadre-host.plist.tmpl +35 -0
  199. package/service/install-service.ps1 +98 -0
  200. package/service/uninstall-service.ps1 +19 -0
  201. package/src/__tests__/cli-invite.smoke.test.ts +187 -0
  202. package/src/__tests__/cli-nat.smoke.test.ts +207 -0
  203. package/src/__tests__/cli.smoke.test.ts +41 -0
  204. package/src/__tests__/orchestrator.test.ts +570 -0
  205. package/src/auth/__tests__/duration.test.ts +28 -0
  206. package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
  207. package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
  208. package/src/auth/__tests__/trust-circle.test.ts +334 -0
  209. package/src/auth/duration.ts +33 -0
  210. package/src/auth/index.ts +19 -0
  211. package/src/auth/trust-circle-store.ts +155 -0
  212. package/src/auth/trust-circle.ts +304 -0
  213. package/src/auth/types.ts +96 -0
  214. package/src/bin/host.ts +905 -0
  215. package/src/index.ts +167 -0
  216. package/src/installer/__tests__/config.test.ts +86 -0
  217. package/src/installer/__tests__/identity.test.ts +34 -0
  218. package/src/installer/__tests__/installer.smoke.test.ts +124 -0
  219. package/src/installer/__tests__/paths.test.ts +47 -0
  220. package/src/installer/__tests__/service-host.test.ts +62 -0
  221. package/src/installer/__tests__/wizard.test.ts +60 -0
  222. package/src/installer/browser.ts +42 -0
  223. package/src/installer/config.ts +153 -0
  224. package/src/installer/identity.ts +49 -0
  225. package/src/installer/index.ts +303 -0
  226. package/src/installer/paths.ts +109 -0
  227. package/src/installer/platform.ts +27 -0
  228. package/src/installer/service-host/index.ts +21 -0
  229. package/src/installer/service-host/launchd.ts +90 -0
  230. package/src/installer/service-host/nssm.ts +100 -0
  231. package/src/installer/service-host/systemd.ts +88 -0
  232. package/src/installer/service-host/template.ts +21 -0
  233. package/src/installer/service-host/types.ts +48 -0
  234. package/src/installer/wizard.ts +92 -0
  235. package/src/nat/__tests__/address-resolver.test.ts +82 -0
  236. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
  237. package/src/nat/__tests__/ddns-updater.test.ts +253 -0
  238. package/src/nat/__tests__/external-ip.test.ts +124 -0
  239. package/src/nat/__tests__/nat-service.test.ts +305 -0
  240. package/src/nat/__tests__/nat-store.test.ts +93 -0
  241. package/src/nat/__tests__/port-mapper.test.ts +191 -0
  242. package/src/nat/__tests__/reachability.test.ts +24 -0
  243. package/src/nat/__tests__/secrets.test.ts +156 -0
  244. package/src/nat/address-resolver.ts +51 -0
  245. package/src/nat/ddns/duckdns.ts +76 -0
  246. package/src/nat/ddns/index.ts +55 -0
  247. package/src/nat/ddns/updater.ts +243 -0
  248. package/src/nat/external-ip.ts +154 -0
  249. package/src/nat/index.ts +71 -0
  250. package/src/nat/nat-service.ts +375 -0
  251. package/src/nat/nat-store.ts +154 -0
  252. package/src/nat/port-mapper.ts +329 -0
  253. package/src/nat/reachability.ts +34 -0
  254. package/src/nat/secrets/file-store.ts +130 -0
  255. package/src/nat/secrets/index.ts +82 -0
  256. package/src/nat/secrets/keytar-store.ts +67 -0
  257. package/src/nat/types.ts +157 -0
  258. package/src/orchestrator/host-process-orchestrator.ts +626 -0
  259. package/src/orchestrator/index.ts +3 -0
  260. package/src/orchestrator/log-rotator.ts +53 -0
  261. package/src/orchestrator/pid-liveness.ts +24 -0
  262. package/src/orchestrator/port-allocator.ts +43 -0
  263. package/src/orchestrator/state-store.ts +72 -0
  264. package/src/orchestrator/types.ts +95 -0
  265. package/src/server/__tests__/bus.test.ts +38 -0
  266. package/src/server/__tests__/error-handler.test.ts +96 -0
  267. package/src/server/__tests__/nodes-route.test.ts +179 -0
  268. package/src/server/__tests__/origin-guard.test.ts +72 -0
  269. package/src/server/__tests__/publishers.test.ts +216 -0
  270. package/src/server/__tests__/server.smoke.test.ts +178 -0
  271. package/src/server/__tests__/settings-route.test.ts +177 -0
  272. package/src/server/__tests__/sse-route.test.ts +119 -0
  273. package/src/server/__tests__/status-route.test.ts +113 -0
  274. package/src/server/error-handler.ts +108 -0
  275. package/src/server/events/bus.ts +42 -0
  276. package/src/server/events/sse-route.ts +81 -0
  277. package/src/server/events/types.ts +22 -0
  278. package/src/server/index.ts +209 -0
  279. package/src/server/origin-guard.ts +84 -0
  280. package/src/server/routes/nat.ts +63 -0
  281. package/src/server/routes/nodes.ts +147 -0
  282. package/src/server/routes/settings.ts +115 -0
  283. package/src/server/routes/status.ts +108 -0
  284. package/src/server/routes/trust-circle.ts +49 -0
  285. package/src/server/routes/update.ts +43 -0
  286. package/src/server/server.ts +122 -0
  287. package/src/server/settings-store.ts +37 -0
  288. package/src/server/static.ts +191 -0
  289. package/src/update/__tests__/apply.test.ts +101 -0
  290. package/src/update/__tests__/manifest.test.ts +93 -0
  291. package/src/update/__tests__/store.test.ts +81 -0
  292. package/src/update/__tests__/update-service.integration.test.ts +221 -0
  293. package/src/update/__tests__/version.test.ts +31 -0
  294. package/src/update/apply.ts +148 -0
  295. package/src/update/index.ts +303 -0
  296. package/src/update/manifest.ts +172 -0
  297. package/src/update/release-key.ts +55 -0
  298. package/src/update/store.ts +101 -0
  299. package/src/update/types.ts +109 -0
  300. package/src/update/version.ts +80 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `host.config.json` schema + I/O.
3
+ *
4
+ * This file captures the wizard's output. Subsystems (trust-circle, NAT,
5
+ * orchestrator) each own their own files under `<dataDir>/`; this is NOT
6
+ * an umbrella config.
7
+ *
8
+ * Schema versions:
9
+ * v1 — initial layout from 6.4.1.
10
+ * v2 — adds `updates: { autoApply, manifestUrl? }` for the update-flow
11
+ * (6.4.2). v1 files are silently upgraded on read with autoApply=false.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+
17
+ export interface UpdatesConfig {
18
+ /** When true, the daily-check timer auto-applies updates. Default false. */
19
+ autoApply: boolean;
20
+ /** Optional override for the manifest URL (env var still wins). */
21
+ manifestUrl?: string;
22
+ }
23
+
24
+ export interface HostConfigFile {
25
+ version: 2;
26
+ /** A stable per-install id; useful for log correlation and the control-network partyId. */
27
+ installId: string;
28
+ /** Localhost-only management UI + API listener port. */
29
+ uiPort: number;
30
+ /** External cadre libp2p port (NAT will map to this). */
31
+ libp2pPort: number;
32
+ /** Self-reference (convenient for service-host ExecStart). */
33
+ dataDir: string;
34
+ /** Absolute path to the Ed25519 protobuf identity. */
35
+ identityPath: string;
36
+ /** Whether UPnP/NAT-PMP should be attempted at start time. */
37
+ upnpEnabled: boolean;
38
+ /** ISO timestamp the wizard wrote this file. */
39
+ installedAt: string;
40
+ /** Installer (= package) version that wrote this file. */
41
+ installerVersion: string;
42
+ /** Update-flow settings; defaults: { autoApply: false }. */
43
+ updates: UpdatesConfig;
44
+ }
45
+
46
+ const CURRENT_VERSION = 2;
47
+
48
+ export function writeHostConfig(filePath: string, cfg: HostConfigFile): void {
49
+ if (cfg.version !== CURRENT_VERSION) {
50
+ throw new Error(`Refusing to write host.config.json with version=${cfg.version} (current: ${CURRENT_VERSION})`);
51
+ }
52
+ mkdirSync(dirname(filePath), { recursive: true });
53
+ writeFileSync(filePath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
54
+ }
55
+
56
+ /** Apply a partial patch to the persisted config (read-modify-write). */
57
+ export function updateHostConfig(
58
+ filePath: string,
59
+ patch: Partial<Omit<HostConfigFile, 'version'>>,
60
+ ): HostConfigFile {
61
+ const current = readHostConfig(filePath);
62
+ const next: HostConfigFile = {
63
+ ...current,
64
+ ...patch,
65
+ updates: { ...current.updates, ...(patch.updates ?? {}) },
66
+ version: 2,
67
+ };
68
+ writeHostConfig(filePath, next);
69
+ return next;
70
+ }
71
+
72
+ export function readHostConfig(filePath: string): HostConfigFile {
73
+ const raw = readFileSync(filePath, 'utf8');
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch (err) {
78
+ throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}`);
79
+ }
80
+ if (!parsed || typeof parsed !== 'object') {
81
+ throw new Error(`host.config.json at ${filePath} is not a JSON object`);
82
+ }
83
+ const obj = parsed as Record<string, unknown>;
84
+ if (obj.version === 1) {
85
+ // Silent in-place upgrade. v1 lacks `updates`; default to notify-only.
86
+ const upgraded = upgradeV1(obj, filePath);
87
+ writeHostConfig(filePath, upgraded);
88
+ return upgraded;
89
+ }
90
+ if (obj.version !== CURRENT_VERSION) {
91
+ throw new Error(
92
+ `host.config.json at ${filePath} has unsupported version=${obj.version} (this build expects ${CURRENT_VERSION})`,
93
+ );
94
+ }
95
+ if (!isHostConfigShape(obj)) {
96
+ throw new Error(`host.config.json at ${filePath} is missing required fields`);
97
+ }
98
+ return obj;
99
+ }
100
+
101
+ /** v1 → v2 migration: stamp `updates: { autoApply: false }` and bump version. */
102
+ function upgradeV1(obj: Record<string, unknown>, filePath: string): HostConfigFile {
103
+ if (!isV1Shape(obj)) {
104
+ throw new Error(`host.config.json at ${filePath} is missing required v1 fields`);
105
+ }
106
+ return {
107
+ version: 2,
108
+ installId: obj.installId as string,
109
+ uiPort: obj.uiPort as number,
110
+ libp2pPort: obj.libp2pPort as number,
111
+ dataDir: obj.dataDir as string,
112
+ identityPath: obj.identityPath as string,
113
+ upnpEnabled: obj.upnpEnabled as boolean,
114
+ installedAt: obj.installedAt as string,
115
+ installerVersion: obj.installerVersion as string,
116
+ updates: { autoApply: false },
117
+ };
118
+ }
119
+
120
+ function isV1Shape(v: Record<string, unknown>): boolean {
121
+ return (
122
+ typeof v.installId === 'string' &&
123
+ typeof v.uiPort === 'number' &&
124
+ typeof v.libp2pPort === 'number' &&
125
+ typeof v.dataDir === 'string' &&
126
+ typeof v.identityPath === 'string' &&
127
+ typeof v.upnpEnabled === 'boolean' &&
128
+ typeof v.installedAt === 'string' &&
129
+ typeof v.installerVersion === 'string'
130
+ );
131
+ }
132
+
133
+ function isHostConfigShape(v: unknown): v is HostConfigFile {
134
+ if (!v || typeof v !== 'object') return false;
135
+ const o = v as Record<string, unknown>;
136
+ if (
137
+ typeof o.installId !== 'string' ||
138
+ typeof o.uiPort !== 'number' ||
139
+ typeof o.libp2pPort !== 'number' ||
140
+ typeof o.dataDir !== 'string' ||
141
+ typeof o.identityPath !== 'string' ||
142
+ typeof o.upnpEnabled !== 'boolean' ||
143
+ typeof o.installedAt !== 'string' ||
144
+ typeof o.installerVersion !== 'string'
145
+ ) {
146
+ return false;
147
+ }
148
+ const updates = o.updates as Record<string, unknown> | undefined;
149
+ if (!updates || typeof updates !== 'object') return false;
150
+ if (typeof updates.autoApply !== 'boolean') return false;
151
+ if (updates.manifestUrl !== undefined && typeof updates.manifestUrl !== 'string') return false;
152
+ return true;
153
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Identity persistence for cadre-host.
3
+ *
4
+ * The host's libp2p peer identity is an Ed25519 keypair generated at
5
+ * install time. The protobuf-encoded private key is written to
6
+ * `<dataDir>/identity.key` with mode 0600 on POSIX. `cadre-host start`
7
+ * later loads it with `privateKeyFromProtobuf`.
8
+ *
9
+ * We persist the protobuf form (not raw bytes) because that's the format
10
+ * libp2p's `privateKeyFromProtobuf` consumes directly and it preserves the
11
+ * key type tag — future migrations to a different key algorithm don't
12
+ * require a schema change here.
13
+ */
14
+
15
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import {
18
+ generateKeyPair,
19
+ privateKeyFromProtobuf,
20
+ privateKeyToProtobuf,
21
+ } from '@libp2p/crypto/keys';
22
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
23
+ import type { PrivateKey } from '@libp2p/interface';
24
+
25
+ export interface IdentityRecord {
26
+ privateKey: PrivateKey;
27
+ /** Stringified libp2p peer ID (`12D3Koo…`). */
28
+ peerId: string;
29
+ }
30
+
31
+ /** Generate a fresh Ed25519 identity and persist it. Returns the in-memory record. */
32
+ export async function generateIdentity(filePath: string): Promise<IdentityRecord> {
33
+ const privateKey = await generateKeyPair('Ed25519');
34
+ const bytes = privateKeyToProtobuf(privateKey);
35
+ mkdirSync(dirname(filePath), { recursive: true });
36
+ writeFileSync(filePath, bytes);
37
+ // 0600 — owner read/write only. No-op on Windows.
38
+ if (process.platform !== 'win32') {
39
+ chmodSync(filePath, 0o600);
40
+ }
41
+ return { privateKey, peerId: peerIdFromPrivateKey(privateKey).toString() };
42
+ }
43
+
44
+ /** Load an existing identity file. Throws if it doesn't decode. */
45
+ export function loadIdentity(filePath: string): IdentityRecord {
46
+ const bytes = readFileSync(filePath);
47
+ const privateKey = privateKeyFromProtobuf(new Uint8Array(bytes));
48
+ return { privateKey, peerId: peerIdFromPrivateKey(privateKey).toString() };
49
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Public installer surface for cadre-host.
3
+ *
4
+ * Orchestrates the install / uninstall flow described in
5
+ * `tickets/implement/6.4.1-cadre-host-installer.md`.
6
+ *
7
+ * The class is purposefully thin — heavy lifting lives in the focused
8
+ * subsystem modules (wizard.ts, identity.ts, config.ts,
9
+ * service-host/{systemd,launchd,nssm}.ts, browser.ts).
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
13
+ import { randomBytes } from 'node:crypto';
14
+ import { join } from 'node:path';
15
+ import debug from 'debug';
16
+
17
+ import { NatStore } from '../nat/index.js';
18
+
19
+ import { openBrowser } from './browser.js';
20
+ import { writeHostConfig, type HostConfigFile } from './config.js';
21
+ import { generateIdentity } from './identity.js';
22
+ import {
23
+ configPath,
24
+ defaultHostJs,
25
+ defaultServiceDir,
26
+ identityPath,
27
+ logsDir,
28
+ } from './paths.js';
29
+ import {
30
+ detectPlatform,
31
+ type SupportedPlatform,
32
+ } from './platform.js';
33
+ import { createServiceHost, type ServiceHost, type ServiceHostContext } from './service-host/index.js';
34
+ import {
35
+ defaultsForPlatform,
36
+ runWizard,
37
+ runWizardWith,
38
+ DEFAULT_UI_PORT,
39
+ DEFAULT_LIBP2P_PORT,
40
+ type WizardAnswers,
41
+ } from './wizard.js';
42
+
43
+ export type { HostConfigFile } from './config.js';
44
+ export type { WizardAnswers, WizardDefaults } from './wizard.js';
45
+ export type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './service-host/index.js';
46
+
47
+ const log = debug('cadre:host:installer');
48
+
49
+ export interface InstallOptions {
50
+ /** If true, all prompts use defaults; CLI flags override individual values. */
51
+ nonInteractive: boolean;
52
+ /** Override the data dir. */
53
+ dataDir?: string;
54
+ /** Override the UI port. */
55
+ uiPort?: number;
56
+ /** Override the libp2p port. */
57
+ libp2pPort?: number;
58
+ /** Disable UPnP probing on first run. */
59
+ noUpnp?: boolean;
60
+ /** Open browser at end (default true; suppressed by --no-browser or non-TTY). */
61
+ openBrowser?: boolean;
62
+ /** Skip the enrollment-invite step (no QR printed). */
63
+ noInvite?: boolean;
64
+ /** System-wide install — v1 emits a not-yet-supported error. */
65
+ system?: boolean;
66
+ /** Path to node.exe / node binary used by the service-host unit. */
67
+ nodePath?: string;
68
+ /** Test-only: stub the service-host registration. */
69
+ serviceHost?: ServiceHost;
70
+ }
71
+
72
+ export interface InstallResult {
73
+ dataDir: string;
74
+ uiUrl: string;
75
+ serviceName: string;
76
+ /** Path to the rendered config file. */
77
+ configPath: string;
78
+ /** Generated enrollment invite, unless --no-invite. */
79
+ enrollmentInvite?: {
80
+ encodedInvite: string;
81
+ expiresAt?: string;
82
+ };
83
+ }
84
+
85
+ export interface UninstallOptions {
86
+ /** Also remove the data dir (default false — preserve trust circle + identity). */
87
+ removeData: boolean;
88
+ /** Skip the confirmation prompt (for scripts). */
89
+ yes: boolean;
90
+ /** Override the data dir to remove (defaults to platform default). */
91
+ dataDir?: string;
92
+ /** Test-only: stub the service-host registration. */
93
+ serviceHost?: ServiceHost;
94
+ }
95
+
96
+ export interface InstallerOptions {
97
+ platform?: SupportedPlatform;
98
+ /** Installer version string written into host.config.json. */
99
+ installerVersion?: string;
100
+ }
101
+
102
+ export class Installer {
103
+ private readonly platform: SupportedPlatform;
104
+ private readonly installerVersion: string;
105
+
106
+ constructor(opts: InstallerOptions = {}) {
107
+ this.platform = opts.platform ?? detectPlatform();
108
+ this.installerVersion = opts.installerVersion ?? readPackageVersion();
109
+ }
110
+
111
+ async install(opts: InstallOptions): Promise<InstallResult> {
112
+ if (opts.system) {
113
+ throw new Error(
114
+ 'cadre-host install --system is not yet supported. Run without --system ' +
115
+ 'for a per-user install; see packages/cadre-host/service/README.md.',
116
+ );
117
+ }
118
+
119
+ // 1. Resolve answers — wizard for interactive, defaults+overrides otherwise.
120
+ const answers = await this.resolveAnswers(opts);
121
+
122
+ // 2. Make sure the data dir exists.
123
+ mkdirSync(answers.dataDir, { recursive: true });
124
+ mkdirSync(logsDir(answers.dataDir), { recursive: true });
125
+
126
+ // 3. Identity + config. Don't overwrite an existing key — re-running
127
+ // install on a populated data dir must preserve the host's peer ID.
128
+ const idPath = identityPath(answers.dataDir);
129
+ if (existsSync(idPath)) {
130
+ log('identity already exists at %s — leaving it', idPath);
131
+ } else {
132
+ await generateIdentity(idPath);
133
+ }
134
+
135
+ const cfg: HostConfigFile = {
136
+ version: 2,
137
+ installId: randomInstallId(),
138
+ uiPort: answers.uiPort,
139
+ libp2pPort: answers.libp2pPort,
140
+ dataDir: answers.dataDir,
141
+ identityPath: idPath,
142
+ upnpEnabled: answers.upnpEnabled,
143
+ installedAt: new Date().toISOString(),
144
+ installerVersion: this.installerVersion,
145
+ updates: { autoApply: false },
146
+ };
147
+ const cfgPath = configPath(answers.dataDir);
148
+ writeHostConfig(cfgPath, cfg);
149
+
150
+ // 4. Seed nat.json with the chosen libp2p port + UPnP flag so the
151
+ // NatService picks them up on first start.
152
+ seedNatSettings(answers.dataDir, answers.libp2pPort, answers.upnpEnabled);
153
+
154
+ // 5. Service-host registration.
155
+ const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
156
+ const ctx: ServiceHostContext = {
157
+ nodePath: opts.nodePath ?? process.execPath,
158
+ hostJs: defaultHostJs(),
159
+ dataDir: answers.dataDir,
160
+ serviceDir: defaultServiceDir(),
161
+ };
162
+ await serviceHost.install(ctx);
163
+
164
+ // 6. Browser open (best-effort).
165
+ const uiUrl = `http://127.0.0.1:${answers.uiPort}/`;
166
+ const shouldOpenBrowser = opts.openBrowser !== false && !opts.nonInteractive && process.stdout.isTTY;
167
+ if (shouldOpenBrowser) {
168
+ openBrowser(uiUrl);
169
+ }
170
+
171
+ // 7. Enrollment invite (best-effort — the listener may not be up yet).
172
+ let invite: InstallResult['enrollmentInvite'];
173
+ if (!opts.noInvite && !opts.nonInteractive) {
174
+ invite = await fetchEnrollmentInvite(answers.uiPort).catch((err) => {
175
+ log('enrollment-invite fetch failed: %s', (err as Error).message);
176
+ return undefined;
177
+ });
178
+ }
179
+
180
+ return {
181
+ dataDir: answers.dataDir,
182
+ uiUrl,
183
+ serviceName: serviceHost.name,
184
+ configPath: cfgPath,
185
+ ...(invite ? { enrollmentInvite: invite } : {}),
186
+ };
187
+ }
188
+
189
+ async uninstall(opts: UninstallOptions): Promise<void> {
190
+ const dataDir = opts.dataDir ?? defaultsForPlatform(this.platform).dataDir;
191
+ const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
192
+ const ctx: ServiceHostContext = {
193
+ nodePath: process.execPath,
194
+ hostJs: defaultHostJs(),
195
+ dataDir,
196
+ serviceDir: defaultServiceDir(),
197
+ };
198
+ await serviceHost.uninstall(ctx);
199
+
200
+ if (opts.removeData) {
201
+ if (existsSync(dataDir)) {
202
+ rmSync(dataDir, { recursive: true, force: true });
203
+ log('removed data dir %s', dataDir);
204
+ }
205
+ }
206
+ }
207
+
208
+ async status(opts: { serviceHost?: ServiceHost } = {}): Promise<{ installed: boolean; running: boolean }> {
209
+ const serviceHost = opts.serviceHost ?? createServiceHost(this.platform);
210
+ const ctx: ServiceHostContext = {
211
+ nodePath: process.execPath,
212
+ hostJs: defaultHostJs(),
213
+ dataDir: defaultsForPlatform(this.platform).dataDir,
214
+ serviceDir: defaultServiceDir(),
215
+ };
216
+ return await serviceHost.status(ctx);
217
+ }
218
+
219
+ /** Exposed for tests / unattended CI. Resolves the wizard answers without running prompts. */
220
+ resolveAnswersFromOptions(opts: InstallOptions): WizardAnswers {
221
+ const defaults = defaultsForPlatform(this.platform);
222
+ return {
223
+ dataDir: opts.dataDir ?? defaults.dataDir,
224
+ uiPort: opts.uiPort ?? defaults.uiPort,
225
+ libp2pPort: opts.libp2pPort ?? defaults.libp2pPort,
226
+ upnpEnabled: opts.noUpnp ? false : defaults.upnpEnabled,
227
+ configureDdns: false,
228
+ };
229
+ }
230
+
231
+ private async resolveAnswers(opts: InstallOptions): Promise<WizardAnswers> {
232
+ if (opts.nonInteractive) {
233
+ return this.resolveAnswersFromOptions(opts);
234
+ }
235
+ const defaults = defaultsForPlatform(this.platform);
236
+ const answers = await runWizard(defaults);
237
+ // CLI flags still take precedence over wizard input — useful when the
238
+ // user passes `--ui-port 9000` but accepts defaults at the prompts.
239
+ return {
240
+ dataDir: opts.dataDir ?? answers.dataDir,
241
+ uiPort: opts.uiPort ?? answers.uiPort,
242
+ libp2pPort: opts.libp2pPort ?? answers.libp2pPort,
243
+ upnpEnabled: opts.noUpnp ? false : answers.upnpEnabled,
244
+ configureDdns: answers.configureDdns,
245
+ };
246
+ }
247
+ }
248
+
249
+ export { runWizardWith, defaultsForPlatform, DEFAULT_UI_PORT, DEFAULT_LIBP2P_PORT };
250
+
251
+ function seedNatSettings(dataDir: string, libp2pPort: number, upnpEnabled: boolean): void {
252
+ const store = new NatStore(dataDir);
253
+ // `update()` merges + persists.
254
+ store.update({
255
+ externalPort: libp2pPort,
256
+ internalPort: libp2pPort,
257
+ upnpEnabled,
258
+ });
259
+ }
260
+
261
+ function randomInstallId(): string {
262
+ return randomBytes(16).toString('hex');
263
+ }
264
+
265
+ function readPackageVersion(): string {
266
+ // Best-effort: read the package.json next to dist/ at runtime.
267
+ try {
268
+ const here = new URL('../../package.json', import.meta.url);
269
+ const raw = readFileSync(here, 'utf8');
270
+ const parsed = JSON.parse(raw) as { version?: string };
271
+ return parsed.version ?? '0.0.0-unknown';
272
+ } catch {
273
+ return '0.0.0-unknown';
274
+ }
275
+ }
276
+
277
+ async function fetchEnrollmentInvite(uiPort: number): Promise<InstallResult['enrollmentInvite']> {
278
+ const url = `http://127.0.0.1:${uiPort}/auth/invites`;
279
+ // Node 18+'s fetch has no default timeout. Bound it so a slow / hung
280
+ // local-UI listener can't wedge `cadre-host install` indefinitely.
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), 5000);
283
+ try {
284
+ const response = await fetch(url, {
285
+ method: 'POST',
286
+ headers: { 'content-type': 'application/json' },
287
+ body: JSON.stringify({ label: 'Install — first device', ttlMs: 24 * 60 * 60 * 1000 }),
288
+ signal: controller.signal,
289
+ });
290
+ if (!response.ok) return undefined;
291
+ const body = await response.json() as { encodedInvite?: string; expiresAt?: string };
292
+ if (!body.encodedInvite) return undefined;
293
+ return {
294
+ encodedInvite: body.encodedInvite,
295
+ ...(body.expiresAt ? { expiresAt: body.expiresAt } : {}),
296
+ };
297
+ } finally {
298
+ clearTimeout(timer);
299
+ }
300
+ }
301
+
302
+ // Exposed for tests that drive the wizard with a custom prompt fn.
303
+ export { runWizard };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Per-platform default paths for the installer.
3
+ *
4
+ * Linux: XDG_DATA_HOME / ~/.local/share/cadre-host
5
+ * macOS: ~/Library/Application Support/CadreHost
6
+ * Windows: %LocalAppData%\CadreHost
7
+ *
8
+ * The package root (where the installed `dist/bin/host.js` and `service/`
9
+ * directory live) is resolved at runtime relative to this module. The
10
+ * installer needs both: the data dir is per-user state, while the package
11
+ * root is what the service-host unit's `ExecStart` line points at.
12
+ */
13
+
14
+ import { homedir } from 'node:os';
15
+ import { posix, win32, join, resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import type { SupportedPlatform } from './platform.js';
19
+
20
+ /** Use the path joiner the *target* platform would use, not the runtime's. */
21
+ function joinFor(platform: SupportedPlatform, ...parts: string[]): string {
22
+ return platform === 'win32' ? win32.join(...parts) : posix.join(...parts);
23
+ }
24
+
25
+ export interface InstallerEnv {
26
+ HOME?: string;
27
+ XDG_DATA_HOME?: string;
28
+ XDG_CONFIG_HOME?: string;
29
+ LocalAppData?: string;
30
+ }
31
+
32
+ /** Default data dir for the given platform. Honors XDG_DATA_HOME / LocalAppData. */
33
+ export function defaultDataDir(
34
+ platform: SupportedPlatform,
35
+ env: InstallerEnv = process.env as InstallerEnv,
36
+ home: string = homedir(),
37
+ ): string {
38
+ switch (platform) {
39
+ case 'linux': {
40
+ const base = env.XDG_DATA_HOME?.trim() || joinFor(platform, env.HOME?.trim() || home, '.local', 'share');
41
+ return joinFor(platform, base, 'cadre-host');
42
+ }
43
+ case 'darwin': {
44
+ return joinFor(platform, env.HOME?.trim() || home, 'Library', 'Application Support', 'CadreHost');
45
+ }
46
+ case 'win32': {
47
+ // %LocalAppData% is the canonical per-user, non-roaming app data dir.
48
+ const base = env.LocalAppData?.trim();
49
+ if (!base) {
50
+ // Fallback: derive from HOME (works under cygwin/MSYS test envs too).
51
+ return joinFor(platform, home, 'AppData', 'Local', 'CadreHost');
52
+ }
53
+ return joinFor(platform, base, 'CadreHost');
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Path on disk for the installed cadre-host CLI entrypoint. */
59
+ export function defaultHostJs(): string {
60
+ // dist/installer/paths.js -> ../../dist/bin/host.js
61
+ // src/installer/paths.ts -> ../bin/host.ts (only used by tests; build emits dist)
62
+ const here = dirname(fileURLToPath(import.meta.url));
63
+ return resolve(here, '..', 'bin', 'host.js');
64
+ }
65
+
66
+ /** Directory containing service templates (cadre-host.service.tmpl etc.). */
67
+ export function defaultServiceDir(): string {
68
+ // dist/installer/paths.js -> ../../service/
69
+ const here = dirname(fileURLToPath(import.meta.url));
70
+ return resolve(here, '..', '..', 'service');
71
+ }
72
+
73
+ /** Where the wizard writes the JSON config. */
74
+ export function configPath(dataDir: string): string {
75
+ return join(dataDir, 'host.config.json');
76
+ }
77
+
78
+ /** Where the wizard writes the Ed25519 identity. */
79
+ export function identityPath(dataDir: string): string {
80
+ return join(dataDir, 'identity.key');
81
+ }
82
+
83
+ /** Logs directory (used by launchd / NSSM unit templates). */
84
+ export function logsDir(dataDir: string): string {
85
+ return join(dataDir, 'logs');
86
+ }
87
+
88
+ /** Per-platform install dir for the rendered service unit file. */
89
+ export function serviceInstallPath(
90
+ platform: SupportedPlatform,
91
+ env: InstallerEnv = process.env as InstallerEnv,
92
+ home: string = homedir(),
93
+ ): string {
94
+ const hd = env.HOME?.trim() || home;
95
+ switch (platform) {
96
+ case 'linux': {
97
+ const base = env.XDG_CONFIG_HOME?.trim() || joinFor(platform, hd, '.config');
98
+ return joinFor(platform, base, 'systemd', 'user', 'cadre-host.service');
99
+ }
100
+ case 'darwin': {
101
+ return joinFor(platform, hd, 'Library', 'LaunchAgents', 'com.serfab.cadre-host.plist');
102
+ }
103
+ case 'win32': {
104
+ // NSSM stores service config in the registry; this path is unused
105
+ // on Windows but returned for symmetry / logging.
106
+ return 'HKLM\\SYSTEM\\CurrentControlSet\\Services\\CadreHost';
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Platform detection helpers for the installer.
3
+ *
4
+ * Kept narrow on purpose — `process.platform` everywhere else in the
5
+ * installer is funneled through here so tests can stub a single point.
6
+ */
7
+
8
+ export type SupportedPlatform = 'linux' | 'darwin' | 'win32';
9
+
10
+ const SUPPORTED: ReadonlySet<NodeJS.Platform> = new Set<NodeJS.Platform>([
11
+ 'linux',
12
+ 'darwin',
13
+ 'win32',
14
+ ]);
15
+
16
+ export function detectPlatform(p: NodeJS.Platform = process.platform): SupportedPlatform {
17
+ if (!SUPPORTED.has(p)) {
18
+ throw new Error(
19
+ `Unsupported platform: ${p}. cadre-host install supports linux, darwin, win32.`,
20
+ );
21
+ }
22
+ return p as SupportedPlatform;
23
+ }
24
+
25
+ export function isPosix(p: SupportedPlatform): boolean {
26
+ return p === 'linux' || p === 'darwin';
27
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pick the right service-host impl for the current platform.
3
+ */
4
+
5
+ import { detectPlatform, type SupportedPlatform } from '../platform.js';
6
+ import { SystemdServiceHost } from './systemd.js';
7
+ import { LaunchdServiceHost } from './launchd.js';
8
+ import { NssmServiceHost } from './nssm.js';
9
+ import type { ServiceHost } from './types.js';
10
+
11
+ export type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './types.js';
12
+
13
+ export function createServiceHost(platform: SupportedPlatform = detectPlatform()): ServiceHost {
14
+ switch (platform) {
15
+ case 'linux': return new SystemdServiceHost();
16
+ case 'darwin': return new LaunchdServiceHost();
17
+ case 'win32': return new NssmServiceHost();
18
+ }
19
+ }
20
+
21
+ export { SystemdServiceHost, LaunchdServiceHost, NssmServiceHost };