@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,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { compareVersions, parseVersion } from '../version.js';
4
+
5
+ describe('compareVersions', () => {
6
+ it('orders releases by major/minor/patch', () => {
7
+ expect(compareVersions('0.6.0', '0.7.0')).toBeLessThan(0);
8
+ expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0);
9
+ expect(compareVersions('1.2.3', '1.2.3')).toBe(0);
10
+ expect(compareVersions('1.2.4', '1.2.3')).toBeGreaterThan(0);
11
+ });
12
+
13
+ it('puts prereleases below their associated release (semver §11)', () => {
14
+ expect(compareVersions('0.7.0-rc1', '0.7.0')).toBeLessThan(0);
15
+ expect(compareVersions('0.7.0', '0.7.0-rc1')).toBeGreaterThan(0);
16
+ });
17
+
18
+ it('compares prereleases by identifier with numeric < alphanumeric', () => {
19
+ expect(compareVersions('0.7.0-alpha', '0.7.0-beta')).toBeLessThan(0);
20
+ expect(compareVersions('0.7.0-rc.1', '0.7.0-rc.2')).toBeLessThan(0);
21
+ expect(compareVersions('0.7.0-rc.2', '0.7.0-rc.10')).toBeLessThan(0);
22
+ expect(compareVersions('0.7.0-1', '0.7.0-rc')).toBeLessThan(0);
23
+ });
24
+
25
+ it('rejects ill-formed input', () => {
26
+ expect(() => parseVersion('not a version')).toThrow(/Invalid version/);
27
+ expect(() => parseVersion('1.2')).toThrow(/Invalid version/);
28
+ expect(() => parseVersion('1.2.3+build')).toThrow(/Invalid version/);
29
+ expect(() => compareVersions('1.2.3', '1.x.0')).toThrow(/Invalid version/);
30
+ });
31
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Apply an update.
3
+ *
4
+ * Apply == reinstall the package globally via `npm install -g <pkg>@<ver>`.
5
+ * Rollback == reinstall the previously-recorded version. Both are dispatched
6
+ * through an injectable `NpmExecutor` so tests don't shell out.
7
+ *
8
+ * Service-host restart is best-effort: the binary swap succeeded even if
9
+ * `systemctl/launchctl/nssm restart` returned non-zero, so the state stays
10
+ * successful and the caller surfaces a warning. (User can restart by hand.)
11
+ */
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import debug from 'debug';
15
+
16
+ import {
17
+ UpdateErrorException,
18
+ type UpdateApplyResult,
19
+ } from './types.js';
20
+
21
+ const log = debug('cadre:host:update-apply');
22
+
23
+ const DEFAULT_NPM_TIMEOUT_MS = 5 * 60 * 1000;
24
+
25
+ export interface NpmResult {
26
+ exitCode: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ }
30
+
31
+ /** Pluggable npm runner — replaced in tests. */
32
+ export type NpmExecutor = (args: string[]) => Promise<NpmResult>;
33
+
34
+ /** Optional service-host restart hook. Returns undefined on success, an error message on failure. */
35
+ export type ServiceRestarter = () => Promise<string | undefined>;
36
+
37
+ export interface ApplyOptions {
38
+ npmPackage: string;
39
+ fromVersion: string;
40
+ toVersion: string;
41
+ npmExecutor?: NpmExecutor;
42
+ restart?: ServiceRestarter;
43
+ timeoutMs?: number;
44
+ }
45
+
46
+ /**
47
+ * Drive the install + restart. Throws `UpdateErrorException('apply_failed')`
48
+ * if the install itself fails (after attempting rollback). On install
49
+ * success but restart failure, returns a result with the restart error set.
50
+ */
51
+ export async function applyUpdate(opts: ApplyOptions): Promise<UpdateApplyResult> {
52
+ const executor = opts.npmExecutor ?? defaultNpmExecutor(opts.timeoutMs ?? DEFAULT_NPM_TIMEOUT_MS);
53
+
54
+ const installArgs = ['install', '-g', `${opts.npmPackage}@${opts.toVersion}`];
55
+ let installResult: NpmResult;
56
+ try {
57
+ installResult = await executor(installArgs);
58
+ } catch (err) {
59
+ await tryRollback(opts.npmPackage, opts.fromVersion, executor);
60
+ throw new UpdateErrorException(
61
+ 'apply_failed',
62
+ `npm install threw: ${(err as Error).message}`,
63
+ );
64
+ }
65
+ if (installResult.exitCode !== 0) {
66
+ const stderrTail = tail(installResult.stderr || installResult.stdout, 400);
67
+ const rollbackErr = await tryRollback(opts.npmPackage, opts.fromVersion, executor);
68
+ if (rollbackErr) {
69
+ throw new UpdateErrorException(
70
+ 'rollback_failed',
71
+ `npm install failed (exit ${installResult.exitCode}); rollback also failed: ${rollbackErr}. Original error: ${stderrTail}`,
72
+ );
73
+ }
74
+ throw new UpdateErrorException(
75
+ 'apply_failed',
76
+ `npm install failed (exit ${installResult.exitCode}): ${stderrTail}`,
77
+ );
78
+ }
79
+ log('npm install -g %s@%s succeeded', opts.npmPackage, opts.toVersion);
80
+
81
+ let restartError: string | undefined;
82
+ if (opts.restart) {
83
+ try {
84
+ restartError = await opts.restart();
85
+ } catch (err) {
86
+ restartError = (err as Error).message;
87
+ }
88
+ if (restartError) {
89
+ log('service restart failed: %s', restartError);
90
+ }
91
+ }
92
+
93
+ return {
94
+ fromVersion: opts.fromVersion,
95
+ toVersion: opts.toVersion,
96
+ restarted: opts.restart !== undefined && restartError === undefined,
97
+ ...(restartError ? { restartError } : {}),
98
+ };
99
+ }
100
+
101
+ /** Best-effort rollback. Returns an error string if the rollback itself failed. */
102
+ async function tryRollback(
103
+ pkg: string,
104
+ fromVersion: string,
105
+ executor: NpmExecutor,
106
+ ): Promise<string | undefined> {
107
+ log('rolling back to %s@%s', pkg, fromVersion);
108
+ try {
109
+ const rollback = await executor(['install', '-g', `${pkg}@${fromVersion}`]);
110
+ if (rollback.exitCode !== 0) {
111
+ return `npm install exit ${rollback.exitCode}: ${tail(rollback.stderr || rollback.stdout, 400)}`;
112
+ }
113
+ return undefined;
114
+ } catch (err) {
115
+ return `npm install threw: ${(err as Error).message}`;
116
+ }
117
+ }
118
+
119
+ /** Default executor: spawn `npm` with the given args. Adds a hard timeout. */
120
+ export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
121
+ return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
122
+ // Windows resolves `npm` via `npm.cmd`; using shell:true is the
123
+ // simplest cross-platform answer that still works with PATH lookup.
124
+ const child = spawn('npm', args, { shell: process.platform === 'win32', windowsHide: true });
125
+ let stdout = '';
126
+ let stderr = '';
127
+ child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
128
+ child.stderr?.on('data', (chunk: Buffer | string) => { stderr += String(chunk); });
129
+
130
+ const timer = setTimeout(() => {
131
+ stderr += `\n[cadre-host] npm install timed out after ${timeoutMs}ms, killing`;
132
+ child.kill('SIGTERM');
133
+ }, timeoutMs);
134
+
135
+ child.on('error', (err) => {
136
+ clearTimeout(timer);
137
+ resolveExec({ exitCode: 127, stdout, stderr: stderr + `\nspawn failed: ${err.message}` });
138
+ });
139
+ child.on('close', (code) => {
140
+ clearTimeout(timer);
141
+ resolveExec({ exitCode: code ?? -1, stdout, stderr });
142
+ });
143
+ });
144
+ }
145
+
146
+ function tail(s: string, n: number): string {
147
+ return s.length <= n ? s : '…' + s.slice(-n);
148
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * UpdateService — the composition root for the cadre-host update flow.
3
+ *
4
+ * Background check cadence: once at construction time (`check()` called
5
+ * explicitly by `cadre-host start` after the management API binds) and a
6
+ * 24-hour interval timer thereafter. Failures are silent — UI consumers see
7
+ * `lastChecked` only when a fetch succeeds.
8
+ *
9
+ * Apply flow: re-fetch + re-verify the manifest (never trust the cached
10
+ * `available`), record `applyInProgress`, run `npm install -g`, restart the
11
+ * service via the optional `ServiceRestarter`, and roll back on failure.
12
+ */
13
+
14
+ import debug from 'debug';
15
+
16
+ import {
17
+ applyUpdate,
18
+ defaultNpmExecutor,
19
+ type NpmExecutor,
20
+ type ServiceRestarter,
21
+ } from './apply.js';
22
+ import {
23
+ DEFAULT_MANIFEST_URL,
24
+ fetchManifest,
25
+ } from './manifest.js';
26
+ import { UpdateStateStore } from './store.js';
27
+ import {
28
+ UpdateErrorException,
29
+ type UpdateApplyResult,
30
+ type UpdateManifest,
31
+ type UpdateSettings,
32
+ type UpdateState,
33
+ } from './types.js';
34
+ import { compareVersions } from './version.js';
35
+
36
+ const log = debug('cadre:host:update-service');
37
+
38
+ const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
39
+ const DEFAULT_NPM_TIMEOUT_MS = 5 * 60 * 1000;
40
+
41
+ export interface UpdateServiceOptions {
42
+ /** cadre-host data directory; UpdateState file lives here. */
43
+ dataDir: string;
44
+ /** Currently-running package version (from package.json). */
45
+ currentVersion: string;
46
+ /** Manifest URL. Defaults: env CADRE_HOST_UPDATE_MANIFEST_URL → DEFAULT_MANIFEST_URL. */
47
+ manifestUrl?: string;
48
+ /** Fetcher (test injection). */
49
+ fetcher?: typeof fetch;
50
+ /** npm runner (test injection). */
51
+ npmExecutor?: NpmExecutor;
52
+ /** Service-host restart hook (test injection / wiring from cadre-host start). */
53
+ restart?: ServiceRestarter;
54
+ /** Initial settings (autoApply etc) — typically sourced from host.config.json. */
55
+ settings?: UpdateSettings;
56
+ /** Periodic-check interval; defaults to 24h. */
57
+ checkIntervalMs?: number;
58
+ /** npm install timeout; defaults to 5m. */
59
+ npmTimeoutMs?: number;
60
+ }
61
+
62
+ /** Handlers for the management-API listener. */
63
+ export interface UpdateHandlers {
64
+ getState(): Promise<UpdateState>;
65
+ postApply(): Promise<UpdateApplyResult>;
66
+ getSettings(): Promise<UpdateSettings>;
67
+ putSettings(body: { autoApply?: boolean; manifestUrl?: string }): Promise<UpdateSettings>;
68
+ }
69
+
70
+ export class UpdateService {
71
+ private readonly store: UpdateStateStore;
72
+ private readonly currentVersion: string;
73
+ private readonly fetcher: typeof fetch;
74
+ private readonly npmExecutor: NpmExecutor;
75
+ private readonly restart: ServiceRestarter | undefined;
76
+ private readonly checkIntervalMs: number;
77
+ private readonly npmTimeoutMs: number;
78
+
79
+ private manifestUrl: string;
80
+ private settings: UpdateSettings;
81
+ private timer: ReturnType<typeof setInterval> | null = null;
82
+ private applying = false;
83
+
84
+ /** Persisted settings changes are bubbled out via this callback (wired in cadre-host start). */
85
+ private onSettingsChange?: (next: UpdateSettings) => void | Promise<void>;
86
+
87
+ constructor(opts: UpdateServiceOptions) {
88
+ this.store = new UpdateStateStore(opts.dataDir);
89
+ this.currentVersion = opts.currentVersion;
90
+ this.fetcher = opts.fetcher ?? fetch;
91
+ this.npmTimeoutMs = opts.npmTimeoutMs ?? DEFAULT_NPM_TIMEOUT_MS;
92
+ this.npmExecutor = opts.npmExecutor ?? defaultNpmExecutor(this.npmTimeoutMs);
93
+ this.restart = opts.restart;
94
+ this.checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
95
+ this.settings = opts.settings ?? { autoApply: false };
96
+ this.manifestUrl = resolveManifestUrl(opts.manifestUrl, this.settings.manifestUrl);
97
+ }
98
+
99
+ /** Read the persisted state for UI consumption. */
100
+ async getState(): Promise<UpdateState> {
101
+ return this.store.load();
102
+ }
103
+
104
+ /** Current settings (in-memory). */
105
+ getSettings(): UpdateSettings {
106
+ return { ...this.settings };
107
+ }
108
+
109
+ /**
110
+ * Update settings (autoApply / manifestUrl). The optional `onSettingsChange`
111
+ * hook is invoked so the caller can persist to host.config.json.
112
+ */
113
+ async putSettings(patch: { autoApply?: boolean; manifestUrl?: string }): Promise<UpdateSettings> {
114
+ if (patch.autoApply !== undefined) this.settings.autoApply = patch.autoApply;
115
+ if (patch.manifestUrl !== undefined) this.settings.manifestUrl = patch.manifestUrl;
116
+ this.manifestUrl = resolveManifestUrl(undefined, this.settings.manifestUrl);
117
+ if (this.onSettingsChange) await this.onSettingsChange({ ...this.settings });
118
+ return { ...this.settings };
119
+ }
120
+
121
+ /** Register the persistence hook called after every settings mutation. */
122
+ onSettingsChanged(handler: (next: UpdateSettings) => void | Promise<void>): void {
123
+ this.onSettingsChange = handler;
124
+ }
125
+
126
+ /**
127
+ * Background manifest check. Never throws — fetch/parse failures log at
128
+ * debug; signature failures are recorded as `lastError` so the UI can warn.
129
+ */
130
+ async check(): Promise<void> {
131
+ let manifest: UpdateManifest;
132
+ try {
133
+ manifest = await fetchManifest({
134
+ url: this.manifestUrl,
135
+ fetcher: this.fetcher,
136
+ });
137
+ } catch (err) {
138
+ if (err instanceof UpdateErrorException && err.code === 'signature_invalid') {
139
+ log('manifest signature invalid: %s', err.message);
140
+ this.store.update({
141
+ lastError: { code: err.code, message: err.message, at: new Date().toISOString() },
142
+ });
143
+ return;
144
+ }
145
+ // Network / parse / endpoint-down: silent.
146
+ log('manifest fetch failed: %s', (err as Error).message);
147
+ return;
148
+ }
149
+
150
+ const now = new Date().toISOString();
151
+ const compared = compareVersions(this.currentVersion, manifest.version);
152
+ if (compared >= 0) {
153
+ // Already up-to-date — clear any stale `available` record.
154
+ this.store.update({
155
+ lastChecked: now,
156
+ available: undefined,
157
+ lastError: undefined,
158
+ });
159
+ log('check ok: current %s ≥ %s', this.currentVersion, manifest.version);
160
+ return;
161
+ }
162
+
163
+ this.store.update({
164
+ lastChecked: now,
165
+ available: {
166
+ version: manifest.version,
167
+ publishedAt: manifest.publishedAt,
168
+ ...(manifest.releaseNotesUrl ? { releaseNotesUrl: manifest.releaseNotesUrl } : {}),
169
+ },
170
+ lastError: undefined,
171
+ });
172
+ log('update available: %s → %s', this.currentVersion, manifest.version);
173
+
174
+ if (this.settings.autoApply) {
175
+ try {
176
+ await this.apply();
177
+ } catch (err) {
178
+ // apply() already recorded the error; we just don't want to crash the timer.
179
+ log('auto-apply failed: %s', (err as Error).message);
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Trigger an apply. Re-verifies the manifest, runs npm install -g, attempts
186
+ * a service restart, and rolls back on failure. Throws UpdateErrorException
187
+ * on any hard failure; success returns the new state.
188
+ */
189
+ async apply(): Promise<UpdateApplyResult> {
190
+ if (this.applying) {
191
+ throw new UpdateErrorException('apply_in_progress', 'another apply is already running');
192
+ }
193
+ const state = this.store.load();
194
+ if (state.applyInProgress) {
195
+ throw new UpdateErrorException('apply_in_progress', 'state already records an in-flight apply');
196
+ }
197
+ this.applying = true;
198
+ try {
199
+ const manifest = await fetchManifest({
200
+ url: this.manifestUrl,
201
+ fetcher: this.fetcher,
202
+ });
203
+ if (compareVersions(this.currentVersion, manifest.version) >= 0) {
204
+ throw new UpdateErrorException(
205
+ 'no_update_available',
206
+ `current ${this.currentVersion} is already ≥ latest ${manifest.version}`,
207
+ );
208
+ }
209
+ if (manifest.minPreviousVersion && compareVersions(this.currentVersion, manifest.minPreviousVersion) < 0) {
210
+ throw new UpdateErrorException(
211
+ 'min_previous_version',
212
+ `current ${this.currentVersion} is below minPreviousVersion ${manifest.minPreviousVersion}; install an intermediate release first`,
213
+ );
214
+ }
215
+
216
+ const startedAt = new Date().toISOString();
217
+ this.store.update({
218
+ applyInProgress: {
219
+ fromVersion: this.currentVersion,
220
+ toVersion: manifest.version,
221
+ startedAt,
222
+ },
223
+ });
224
+
225
+ try {
226
+ const result = await applyUpdate({
227
+ npmPackage: manifest.channels.npm.package,
228
+ fromVersion: this.currentVersion,
229
+ toVersion: manifest.version,
230
+ npmExecutor: this.npmExecutor,
231
+ ...(this.restart ? { restart: this.restart } : {}),
232
+ timeoutMs: this.npmTimeoutMs,
233
+ });
234
+ // Success: clear progress + any prior error.
235
+ this.store.update({
236
+ applyInProgress: undefined,
237
+ lastError: undefined,
238
+ });
239
+ return result;
240
+ } catch (err) {
241
+ const code = err instanceof UpdateErrorException ? err.code : 'apply_failed';
242
+ const message = (err as Error).message;
243
+ this.store.update({
244
+ applyInProgress: undefined,
245
+ lastError: { code, message, at: new Date().toISOString() },
246
+ });
247
+ throw err instanceof UpdateErrorException ? err : new UpdateErrorException('apply_failed', message);
248
+ }
249
+ } finally {
250
+ this.applying = false;
251
+ }
252
+ }
253
+
254
+ /** Start the daily-check timer. `start()` ensures we don't double-arm. */
255
+ start(): void {
256
+ if (this.timer) return;
257
+ this.timer = setInterval(() => {
258
+ void this.check().catch((err) => log('scheduled check threw (should not happen): %s', (err as Error).message));
259
+ }, this.checkIntervalMs);
260
+ // Don't keep the event loop alive just for this timer.
261
+ if (typeof this.timer === 'object' && this.timer && 'unref' in this.timer) {
262
+ (this.timer as { unref?: () => void }).unref?.();
263
+ }
264
+ }
265
+
266
+ /** Stop the daily-check timer. */
267
+ stop(): void {
268
+ if (this.timer) {
269
+ clearInterval(this.timer);
270
+ this.timer = null;
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Typed handler facade — mirrors `createTrustCircleHandlers` /
277
+ * `createNatHandlers`. The local-UI ticket consumes these.
278
+ */
279
+ export function createUpdateHandlers(service: UpdateService): UpdateHandlers {
280
+ return {
281
+ async getState() { return service.getState(); },
282
+ async postApply() { return await service.apply(); },
283
+ async getSettings() { return service.getSettings(); },
284
+ async putSettings(body) { return await service.putSettings(body); },
285
+ };
286
+ }
287
+
288
+ function resolveManifestUrl(explicit: string | undefined, fromSettings: string | undefined): string {
289
+ // Env var wins — operators routinely point this at staging endpoints, and
290
+ // both the README and host.ts documentation promise that behavior.
291
+ // Then explicit constructor arg → persisted settings → compiled default.
292
+ const envUrl = process.env.CADRE_HOST_UPDATE_MANIFEST_URL?.trim();
293
+ if (envUrl) return envUrl;
294
+ return explicit ?? fromSettings ?? DEFAULT_MANIFEST_URL;
295
+ }
296
+
297
+ export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest } from './types.js';
298
+ export { UpdateErrorException, type UpdateErrorCode } from './types.js';
299
+ export { compareVersions, parseVersion } from './version.js';
300
+ export { fetchManifest, verifyManifest, canonicalJson, signManifestForTesting } from './manifest.js';
301
+ export { UpdateStateStore } from './store.js';
302
+ export { applyUpdate, defaultNpmExecutor, type NpmExecutor, type ServiceRestarter } from './apply.js';
303
+ export { getReleasePublicKey, getReleasePublicKeyBase64, ed25519FromRaw } from './release-key.js';
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Fetch + verify the signed update manifest.
3
+ *
4
+ * The manifest is a JSON envelope `{ manifest, sig }` where `sig` is a
5
+ * detached Ed25519 signature of `canonicalJson(manifest)`. cadre-host only
6
+ * trusts manifests whose signature matches the embedded release public key.
7
+ *
8
+ * Canonical serialization is a tiny local impl — depending on
9
+ * `json-canonicalize` (or similar) for a single call site is overkill, and
10
+ * the shape is fixed (`UpdateManifest`).
11
+ */
12
+
13
+ import { sign as cryptoSign, verify as cryptoVerify, type KeyObject } from 'node:crypto';
14
+
15
+ import { ed25519FromRaw, getReleasePublicKey, getReleasePublicKeyBase64 } from './release-key.js';
16
+ import {
17
+ UpdateErrorException,
18
+ type SignedManifest,
19
+ type UpdateManifest,
20
+ } from './types.js';
21
+
22
+ /** Default release-manifest URL. Overridable via env var on the caller. */
23
+ export const DEFAULT_MANIFEST_URL = 'https://releases.serfab.io/cadre-host/latest.json';
24
+
25
+ export interface FetchManifestOptions {
26
+ url: string;
27
+ fetcher?: typeof fetch;
28
+ /** Total fetch timeout (connect + read) in ms; default 10000. */
29
+ readTimeoutMs?: number;
30
+ }
31
+
32
+ /**
33
+ * Fetch the manifest endpoint and validate the signature against the embedded
34
+ * release public key (or `CADRE_HOST_UPDATE_DEV_KEY` if set).
35
+ *
36
+ * Throws `UpdateErrorException` on any failure (bad JSON, bad signature,
37
+ * etc.). Network errors are *thrown* — the caller decides whether to log
38
+ * them at debug (the cadence path) or surface them (the apply path).
39
+ */
40
+ export async function fetchManifest(opts: FetchManifestOptions): Promise<UpdateManifest> {
41
+ const fetcher = opts.fetcher ?? fetch;
42
+ const readTimeout = opts.readTimeoutMs ?? 10_000;
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), readTimeout);
45
+ let response: Response;
46
+ try {
47
+ response = await fetcher(opts.url, { signal: controller.signal });
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ if (!response.ok) {
52
+ throw new Error(`manifest endpoint returned ${response.status}`);
53
+ }
54
+ let body: unknown;
55
+ try {
56
+ body = await response.json();
57
+ } catch (err) {
58
+ throw new UpdateErrorException(
59
+ 'manifest_invalid',
60
+ `manifest is not valid JSON: ${(err as Error).message}`,
61
+ );
62
+ }
63
+ return verifyManifest(body);
64
+ }
65
+
66
+ /**
67
+ * Verify a parsed signed-manifest body. Pure (no network), so unit tests
68
+ * exercise this directly.
69
+ */
70
+ export function verifyManifest(
71
+ body: unknown,
72
+ env: NodeJS.ProcessEnv = process.env,
73
+ ): UpdateManifest {
74
+ if (!isSignedManifest(body)) {
75
+ throw new UpdateErrorException('manifest_invalid', 'manifest envelope is malformed');
76
+ }
77
+ if (!isUpdateManifest(body.manifest)) {
78
+ throw new UpdateErrorException('manifest_invalid', 'manifest payload is missing required fields');
79
+ }
80
+
81
+ const sig = parseSignature(body.sig);
82
+ const canonical = Buffer.from(canonicalJson(body.manifest), 'utf8');
83
+ const key = getReleasePublicKey(env);
84
+
85
+ let ok: boolean;
86
+ try {
87
+ ok = cryptoVerify(null, canonical, key, sig);
88
+ } catch (err) {
89
+ throw new UpdateErrorException(
90
+ 'signature_invalid',
91
+ `signature verification threw: ${(err as Error).message}`,
92
+ );
93
+ }
94
+ if (!ok) {
95
+ throw new UpdateErrorException('signature_invalid', 'manifest signature did not verify');
96
+ }
97
+ return body.manifest;
98
+ }
99
+
100
+ /** Exposed for test signers. */
101
+ export function signManifestForTesting(manifest: UpdateManifest, privateKey: KeyObject): SignedManifest {
102
+ const canonical = Buffer.from(canonicalJson(manifest), 'utf8');
103
+ const sig = cryptoSign(null, canonical, privateKey).toString('base64');
104
+ return { manifest, sig: `ed25519:${sig}` };
105
+ }
106
+
107
+ /** Used by `apply.ts` to detect which key the signer matches. */
108
+ export function publicKeyMatchesEmbedded(rawBase64: string, env: NodeJS.ProcessEnv = process.env): boolean {
109
+ return rawBase64 === getReleasePublicKeyBase64(env);
110
+ }
111
+
112
+ /** Wrapper for `getReleasePublicKey`, re-exported for parity with `ed25519FromRaw`. */
113
+ export { ed25519FromRaw };
114
+
115
+ /** ---- internals ---- */
116
+
117
+ function parseSignature(sig: string): Buffer {
118
+ const match = /^ed25519:(.+)$/.exec(sig);
119
+ if (!match) {
120
+ throw new UpdateErrorException('signature_invalid', `unsupported signature scheme: ${sig.slice(0, 16)}…`);
121
+ }
122
+ const decoded = Buffer.from(match[1]!, 'base64');
123
+ if (decoded.length !== 64) {
124
+ throw new UpdateErrorException('signature_invalid', `Ed25519 signature must be 64 bytes, got ${decoded.length}`);
125
+ }
126
+ return decoded;
127
+ }
128
+
129
+ function isSignedManifest(v: unknown): v is SignedManifest {
130
+ if (!v || typeof v !== 'object') return false;
131
+ const obj = v as Record<string, unknown>;
132
+ return typeof obj.sig === 'string' && obj.manifest !== undefined && typeof obj.manifest === 'object';
133
+ }
134
+
135
+ function isUpdateManifest(v: unknown): v is UpdateManifest {
136
+ if (!v || typeof v !== 'object') return false;
137
+ const o = v as Record<string, unknown>;
138
+ if (o.v !== 1) return false;
139
+ if (typeof o.version !== 'string' || o.version.length === 0) return false;
140
+ if (typeof o.publishedAt !== 'string') return false;
141
+ if (!o.channels || typeof o.channels !== 'object') return false;
142
+ const npm = (o.channels as Record<string, unknown>).npm as Record<string, unknown> | undefined;
143
+ if (!npm || typeof npm.package !== 'string' || typeof npm.tag !== 'string') return false;
144
+ if (o.releaseNotesUrl !== undefined && typeof o.releaseNotesUrl !== 'string') return false;
145
+ if (o.minPreviousVersion !== undefined && typeof o.minPreviousVersion !== 'string') return false;
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Canonical JSON: recursively sort object keys, no whitespace, no trailing
151
+ * newline. Matches a typical RFC 8785 subset for the fixed shape we sign.
152
+ * Arrays preserve order.
153
+ */
154
+ export function canonicalJson(value: unknown): string {
155
+ if (value === null) return 'null';
156
+ if (typeof value === 'number') {
157
+ if (!Number.isFinite(value)) throw new Error('cannot serialize non-finite number');
158
+ return JSON.stringify(value);
159
+ }
160
+ if (typeof value === 'string' || typeof value === 'boolean') {
161
+ return JSON.stringify(value);
162
+ }
163
+ if (Array.isArray(value)) {
164
+ return '[' + value.map((v) => canonicalJson(v)).join(',') + ']';
165
+ }
166
+ if (typeof value === 'object') {
167
+ const obj = value as Record<string, unknown>;
168
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
169
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
170
+ }
171
+ throw new Error(`cannot serialize value of type ${typeof value}`);
172
+ }