@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.
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/self-label.d.ts +36 -0
- package/dist/auth/self-label.js +57 -0
- package/dist/auth/self-label.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +32 -5
- package/dist/auth/trust-circle.js +31 -11
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/bin/host.js +41 -5
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
- package/dist/donation/__tests__/fake-orchestrator.js +180 -0
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
- package/dist/donation/donation-service.d.ts +237 -12
- package/dist/donation/donation-service.js +491 -34
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +7 -0
- package/dist/donation/donation-store.js +7 -0
- package/dist/donation/donation-store.js.map +1 -1
- package/dist/donation/donation-supervisor.d.ts +167 -0
- package/dist/donation/donation-supervisor.js +341 -0
- package/dist/donation/donation-supervisor.js.map +1 -0
- package/dist/donation/index.d.ts +4 -2
- package/dist/donation/index.js +2 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +26 -0
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
- package/dist/orchestrator/host-process-orchestrator.js +341 -98
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +26 -0
- package/dist/orchestrator/node-identity.js +36 -0
- package/dist/orchestrator/node-identity.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +14 -0
- package/dist/orchestrator/port-allocator.js +45 -0
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/owner/owner-node-client.d.ts +20 -5
- package/dist/owner/owner-node-client.js +21 -4
- package/dist/owner/owner-node-client.js.map +1 -1
- package/dist/server/error-handler.d.ts +7 -1
- package/dist/server/error-handler.js +23 -1
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +9 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants.js +22 -3
- package/dist/server/routes/grants.js.map +1 -1
- package/dist/server/routes/strands.d.ts +20 -0
- package/dist/server/routes/strands.js +38 -0
- package/dist/server/routes/strands.js.map +1 -0
- package/dist/server/static.js +38 -38
- package/dist/strands/index.d.ts +7 -0
- package/dist/strands/index.js +6 -0
- package/dist/strands/index.js.map +1 -0
- package/dist/strands/strand-service.d.ts +50 -0
- package/dist/strands/strand-service.js +103 -0
- package/dist/strands/strand-service.js.map +1 -0
- package/dist/strands/types.d.ts +93 -0
- package/dist/strands/types.js +23 -0
- package/dist/strands/types.js.map +1 -0
- package/dist/ui/assets/index-BVoqLjyX.css +1 -0
- package/dist/ui/assets/index-C-iks41M.js +17 -0
- package/dist/ui/assets/index-C-iks41M.js.map +1 -0
- package/dist/ui/index.html +14 -14
- package/dist/update/release-key.d.ts +1 -1
- package/dist/update/release-key.js +1 -1
- package/package.json +5 -5
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -0
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +46 -46
- package/src/__tests__/global-setup.ts +51 -0
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -570
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -401
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -19
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/trust-circle.ts +45 -13
- package/src/auth/types.ts +99 -99
- package/src/bin/host.ts +1299 -1258
- package/src/donation/__tests__/donation-service.test.ts +1068 -290
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
- package/src/donation/__tests__/fake-orchestrator.ts +225 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -392
- package/src/donation/donation-store.ts +171 -164
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -49
- package/src/donation/types.ts +243 -220
- package/src/index.ts +14 -0
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/identity.ts +49 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +497 -497
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +1133 -894
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -43
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -233
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -189
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +194 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +178 -155
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -22
- package/src/server/index.ts +267 -254
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -186
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -0
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +37 -37
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -0
- package/src/strands/index.ts +14 -0
- package/src/strands/strand-service.ts +135 -0
- package/src/strands/types.ts +102 -0
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- package/src/update/version.ts +80 -80
- package/dist/ui/assets/index-BlMXQPSw.js +0 -14
- package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
- package/dist/ui/assets/index-BwU5mJGF.css +0 -1
package/src/update/apply.ts
CHANGED
|
@@ -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
|
+
}
|