@serfab/cadre-host 0.9.0 → 0.11.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 (213) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/server/static.js +38 -38
  57. package/dist/strands/index.d.ts +7 -0
  58. package/dist/strands/index.js +6 -0
  59. package/dist/strands/index.js.map +1 -0
  60. package/dist/strands/strand-service.d.ts +50 -0
  61. package/dist/strands/strand-service.js +103 -0
  62. package/dist/strands/strand-service.js.map +1 -0
  63. package/dist/strands/types.d.ts +93 -0
  64. package/dist/strands/types.js +23 -0
  65. package/dist/strands/types.js.map +1 -0
  66. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  67. package/dist/ui/assets/index-C-iks41M.js +17 -0
  68. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  69. package/dist/ui/index.html +14 -14
  70. package/dist/update/release-key.d.ts +1 -1
  71. package/dist/update/release-key.js +1 -1
  72. package/package.json +5 -5
  73. package/service/cadre-host.service.tmpl +16 -16
  74. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  75. package/service/install-service.ps1 +98 -98
  76. package/service/uninstall-service.ps1 +19 -19
  77. package/src/__tests__/build-targets.test.ts +26 -0
  78. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  79. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  80. package/src/__tests__/cli.smoke.test.ts +46 -46
  81. package/src/__tests__/global-setup.ts +51 -0
  82. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  83. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  84. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  85. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  86. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  87. package/src/__tests__/orchestrator-push.test.ts +145 -145
  88. package/src/__tests__/orchestrator.test.ts +951 -570
  89. package/src/auth/__tests__/duration.test.ts +28 -28
  90. package/src/auth/__tests__/self-label.test.ts +85 -0
  91. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  92. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  93. package/src/auth/__tests__/trust-circle.test.ts +445 -401
  94. package/src/auth/duration.ts +33 -33
  95. package/src/auth/index.ts +21 -19
  96. package/src/auth/self-label.ts +74 -0
  97. package/src/auth/trust-circle-store.ts +155 -155
  98. package/src/auth/trust-circle.ts +45 -13
  99. package/src/auth/types.ts +99 -99
  100. package/src/bin/host.ts +1299 -1258
  101. package/src/donation/__tests__/donation-service.test.ts +1068 -290
  102. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  103. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  104. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  105. package/src/donation/__tests__/grant-service.test.ts +213 -213
  106. package/src/donation/__tests__/grant-store.test.ts +113 -113
  107. package/src/donation/donation-service.ts +962 -392
  108. package/src/donation/donation-store.ts +171 -164
  109. package/src/donation/donation-supervisor.ts +388 -0
  110. package/src/donation/grant-service.ts +200 -200
  111. package/src/donation/grant-store.ts +152 -152
  112. package/src/donation/index.ts +64 -49
  113. package/src/donation/types.ts +243 -220
  114. package/src/index.ts +14 -0
  115. package/src/installer/__tests__/identity.test.ts +34 -34
  116. package/src/installer/__tests__/paths.test.ts +47 -47
  117. package/src/installer/__tests__/service-host.test.ts +62 -62
  118. package/src/installer/__tests__/wizard.test.ts +64 -64
  119. package/src/installer/identity.ts +49 -49
  120. package/src/installer/paths.ts +109 -109
  121. package/src/installer/platform.ts +27 -27
  122. package/src/installer/service-host/index.ts +21 -21
  123. package/src/installer/service-host/template.ts +21 -21
  124. package/src/installer/wizard.ts +103 -103
  125. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  126. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  127. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  128. package/src/nat/__tests__/external-ip.test.ts +124 -124
  129. package/src/nat/__tests__/nat-service.test.ts +497 -497
  130. package/src/nat/__tests__/nat-store.test.ts +93 -93
  131. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  132. package/src/nat/__tests__/reachability.test.ts +24 -24
  133. package/src/nat/__tests__/secrets.test.ts +178 -178
  134. package/src/nat/address-resolver.ts +51 -51
  135. package/src/nat/ddns/duckdns.ts +76 -76
  136. package/src/nat/ddns/index.ts +55 -55
  137. package/src/nat/ddns/updater.ts +243 -243
  138. package/src/nat/external-ip.ts +154 -154
  139. package/src/nat/index.ts +71 -71
  140. package/src/nat/nat-service.ts +501 -501
  141. package/src/nat/nat-store.ts +154 -154
  142. package/src/nat/port-mapper.ts +329 -329
  143. package/src/nat/reachability.ts +34 -34
  144. package/src/nat/secrets/file-store.ts +130 -130
  145. package/src/nat/secrets/index.ts +112 -112
  146. package/src/nat/secrets/keytar-store.ts +67 -67
  147. package/src/nat/types.ts +159 -159
  148. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  149. package/src/orchestrator/host-process-orchestrator.ts +1133 -894
  150. package/src/orchestrator/index.ts +10 -10
  151. package/src/orchestrator/log-rotator.ts +53 -53
  152. package/src/orchestrator/node-identity.ts +40 -0
  153. package/src/orchestrator/pid-liveness.ts +24 -24
  154. package/src/orchestrator/port-allocator.ts +91 -43
  155. package/src/orchestrator/state-store.ts +90 -90
  156. package/src/orchestrator/types.ts +141 -141
  157. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  158. package/src/owner/index.ts +12 -12
  159. package/src/owner/owner-node-client.ts +259 -233
  160. package/src/push/__tests__/push.test.ts +90 -90
  161. package/src/push/index.ts +146 -146
  162. package/src/server/__tests__/bus.test.ts +38 -38
  163. package/src/server/__tests__/error-handler.test.ts +118 -118
  164. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  165. package/src/server/__tests__/grants-route.test.ts +263 -189
  166. package/src/server/__tests__/nodes-route.test.ts +259 -259
  167. package/src/server/__tests__/origin-guard.test.ts +72 -72
  168. package/src/server/__tests__/publishers.test.ts +216 -216
  169. package/src/server/__tests__/server.smoke.test.ts +253 -253
  170. package/src/server/__tests__/settings-route.test.ts +194 -194
  171. package/src/server/__tests__/sse-route.test.ts +119 -119
  172. package/src/server/__tests__/static.test.ts +145 -145
  173. package/src/server/__tests__/status-route.test.ts +134 -134
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +178 -155
  176. package/src/server/events/bus.ts +42 -42
  177. package/src/server/events/sse-route.ts +81 -81
  178. package/src/server/events/types.ts +23 -22
  179. package/src/server/index.ts +267 -254
  180. package/src/server/origin-guard.ts +84 -84
  181. package/src/server/routes/grants-admin.ts +41 -41
  182. package/src/server/routes/grants.ts +210 -186
  183. package/src/server/routes/nat.ts +63 -63
  184. package/src/server/routes/nodes.ts +170 -170
  185. package/src/server/routes/settings.ts +123 -123
  186. package/src/server/routes/status.ts +118 -118
  187. package/src/server/routes/strands.ts +54 -0
  188. package/src/server/routes/trust-circle.ts +49 -49
  189. package/src/server/routes/update.ts +43 -43
  190. package/src/server/server.ts +122 -122
  191. package/src/server/settings-store.ts +37 -37
  192. package/src/server/static.ts +207 -207
  193. package/src/strands/__tests__/strand-service.test.ts +165 -0
  194. package/src/strands/index.ts +14 -0
  195. package/src/strands/strand-service.ts +135 -0
  196. package/src/strands/types.ts +102 -0
  197. package/src/update/__tests__/apply.test.ts +158 -158
  198. package/src/update/__tests__/manifest.test.ts +128 -128
  199. package/src/update/__tests__/release-key.test.ts +42 -42
  200. package/src/update/__tests__/sign.test.ts +78 -78
  201. package/src/update/__tests__/store.test.ts +81 -81
  202. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  203. package/src/update/__tests__/version.test.ts +31 -31
  204. package/src/update/apply.ts +190 -190
  205. package/src/update/index.ts +341 -341
  206. package/src/update/release-key.ts +71 -71
  207. package/src/update/sign.ts +74 -74
  208. package/src/update/store.ts +101 -101
  209. package/src/update/types.ts +109 -109
  210. package/src/update/version.ts +80 -80
  211. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  212. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  213. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -1,190 +1,190 @@
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
- /**
120
- * Default executor: spawn `npm` with the given args. Adds a hard timeout.
121
- *
122
- * On Windows, npm ships as `npm.cmd` (a batch file). CreateProcess can't run
123
- * batch files directly, so a shell is required — but Node's DEP0190 warns
124
- * against `spawn(cmd, args, { shell: true })` because args are concatenated
125
- * (not escaped) into the resulting command line. To dodge the deprecation we
126
- * pre-quote each arg ourselves and pass the result as a single command string
127
- * with an empty args array. Inputs come from a signed manifest after
128
- * regex-validated package + semver checks, so injection surface is minimal;
129
- * quoting is defense in depth.
130
- */
131
- export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
132
- return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
133
- const spawnSpec = buildNpmSpawnSpec(args);
134
- const child = spawn(spawnSpec.command, spawnSpec.args, {
135
- shell: spawnSpec.shell,
136
- windowsHide: true,
137
- });
138
- let stdout = '';
139
- let stderr = '';
140
- child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
141
- child.stderr?.on('data', (chunk: Buffer | string) => { stderr += String(chunk); });
142
-
143
- const timer = setTimeout(() => {
144
- stderr += `\n[cadre-host] npm install timed out after ${timeoutMs}ms, killing`;
145
- child.kill('SIGTERM');
146
- }, timeoutMs);
147
-
148
- child.on('error', (err) => {
149
- clearTimeout(timer);
150
- resolveExec({ exitCode: 127, stdout, stderr: stderr + `\nspawn failed: ${err.message}` });
151
- });
152
- child.on('close', (code) => {
153
- clearTimeout(timer);
154
- resolveExec({ exitCode: code ?? -1, stdout, stderr });
155
- });
156
- });
157
- }
158
-
159
- /**
160
- * Build the `(command, args, shell)` triple passed to `spawn`. Exported for
161
- * tests — the spawn shape differs per platform and the quoting matters.
162
- */
163
- export function buildNpmSpawnSpec(args: string[]): { command: string; args: string[]; shell: boolean } {
164
- if (process.platform === 'win32') {
165
- const commandLine = ['npm', ...args.map(quoteWindowsArg)].join(' ');
166
- return { command: commandLine, args: [], shell: true };
167
- }
168
- return { command: 'npm', args, shell: false };
169
- }
170
-
171
- /** cmd.exe quoting — wrap args containing whitespace or shell metachars. */
172
- export function quoteWindowsArg(arg: string): string {
173
- // \x00 is an intentional security guard: reject NUL/CR/LF in args before they
174
- // reach the cmd.exe command line. The control char is the point of the check.
175
- // eslint-disable-next-line no-control-regex
176
- if (/[\x00\r\n]/.test(arg)) {
177
- throw new Error('refusing to spawn npm with control character in argument');
178
- }
179
- if (arg.length === 0) return '""';
180
- if (!/[\s"&|<>^()%!]/.test(arg)) return arg;
181
- // Escape backslashes preceding a quote per MSVCRT/cmd rules, then double the
182
- // quote itself (the cmd-friendly form rather than backslash-quote).
183
- const escaped = arg.replace(/(\\*)"/g, (_m, slashes: string) => slashes + slashes + '\\"');
184
- const trailing = escaped.replace(/(\\*)$/, (_m, slashes: string) => slashes + slashes);
185
- return `"${trailing}"`;
186
- }
187
-
188
- function tail(s: string, n: number): string {
189
- return s.length <= n ? s : '…' + s.slice(-n);
190
- }
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
+ /**
120
+ * Default executor: spawn `npm` with the given args. Adds a hard timeout.
121
+ *
122
+ * On Windows, npm ships as `npm.cmd` (a batch file). CreateProcess can't run
123
+ * batch files directly, so a shell is required — but Node's DEP0190 warns
124
+ * against `spawn(cmd, args, { shell: true })` because args are concatenated
125
+ * (not escaped) into the resulting command line. To dodge the deprecation we
126
+ * pre-quote each arg ourselves and pass the result as a single command string
127
+ * with an empty args array. Inputs come from a signed manifest after
128
+ * regex-validated package + semver checks, so injection surface is minimal;
129
+ * quoting is defense in depth.
130
+ */
131
+ export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
132
+ return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
133
+ const spawnSpec = buildNpmSpawnSpec(args);
134
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
135
+ shell: spawnSpec.shell,
136
+ windowsHide: true,
137
+ });
138
+ let stdout = '';
139
+ let stderr = '';
140
+ child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
141
+ child.stderr?.on('data', (chunk: Buffer | string) => { stderr += String(chunk); });
142
+
143
+ const timer = setTimeout(() => {
144
+ stderr += `\n[cadre-host] npm install timed out after ${timeoutMs}ms, killing`;
145
+ child.kill('SIGTERM');
146
+ }, timeoutMs);
147
+
148
+ child.on('error', (err) => {
149
+ clearTimeout(timer);
150
+ resolveExec({ exitCode: 127, stdout, stderr: stderr + `\nspawn failed: ${err.message}` });
151
+ });
152
+ child.on('close', (code) => {
153
+ clearTimeout(timer);
154
+ resolveExec({ exitCode: code ?? -1, stdout, stderr });
155
+ });
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Build the `(command, args, shell)` triple passed to `spawn`. Exported for
161
+ * tests — the spawn shape differs per platform and the quoting matters.
162
+ */
163
+ export function buildNpmSpawnSpec(args: string[]): { command: string; args: string[]; shell: boolean } {
164
+ if (process.platform === 'win32') {
165
+ const commandLine = ['npm', ...args.map(quoteWindowsArg)].join(' ');
166
+ return { command: commandLine, args: [], shell: true };
167
+ }
168
+ return { command: 'npm', args, shell: false };
169
+ }
170
+
171
+ /** cmd.exe quoting — wrap args containing whitespace or shell metachars. */
172
+ export function quoteWindowsArg(arg: string): string {
173
+ // \x00 is an intentional security guard: reject NUL/CR/LF in args before they
174
+ // reach the cmd.exe command line. The control char is the point of the check.
175
+ // eslint-disable-next-line no-control-regex
176
+ if (/[\x00\r\n]/.test(arg)) {
177
+ throw new Error('refusing to spawn npm with control character in argument');
178
+ }
179
+ if (arg.length === 0) return '""';
180
+ if (!/[\s"&|<>^()%!]/.test(arg)) return arg;
181
+ // Escape backslashes preceding a quote per MSVCRT/cmd rules, then double the
182
+ // quote itself (the cmd-friendly form rather than backslash-quote).
183
+ const escaped = arg.replace(/(\\*)"/g, (_m, slashes: string) => slashes + slashes + '\\"');
184
+ const trailing = escaped.replace(/(\\*)$/, (_m, slashes: string) => slashes + slashes);
185
+ return `"${trailing}"`;
186
+ }
187
+
188
+ function tail(s: string, n: number): string {
189
+ return s.length <= n ? s : '…' + s.slice(-n);
190
+ }