@serfab/cadre-host 0.7.1 → 0.9.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/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/bin/host.js +426 -112
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/owner/owner-node-client.d.ts +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +17 -0
- package/dist/server/error-handler.js +45 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -3
- package/dist/server/routes/nodes.js +39 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +29 -14
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +322 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +42 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +31 -15
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { buildManifest, derivePublicKeyBase64, signAndSelfVerify, type ManifestFields } from '../sign.js';
|
|
5
|
+
import { verifyManifest } from '../manifest.js';
|
|
6
|
+
|
|
7
|
+
function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
|
|
8
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
9
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' });
|
|
10
|
+
return { privateKey, publicRawB64: Buffer.from(spki.subarray(12)).toString('base64') };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const validFields: ManifestFields = {
|
|
14
|
+
version: '0.7.0',
|
|
15
|
+
package: '@serfab/cadre-host',
|
|
16
|
+
tag: 'latest',
|
|
17
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
18
|
+
releaseNotesUrl: 'https://example.com/notes',
|
|
19
|
+
minPreviousVersion: '0.6.0',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('buildManifest', () => {
|
|
23
|
+
it('constructs a well-formed manifest from flat fields', () => {
|
|
24
|
+
expect(buildManifest(validFields)).toEqual({
|
|
25
|
+
v: 1,
|
|
26
|
+
version: '0.7.0',
|
|
27
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
28
|
+
channels: { npm: { package: '@serfab/cadre-host', tag: 'latest' } },
|
|
29
|
+
releaseNotesUrl: 'https://example.com/notes',
|
|
30
|
+
minPreviousVersion: '0.6.0',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('omits optional fields when not provided', () => {
|
|
35
|
+
const m = buildManifest({ version: '1.0.0', package: 'pkg', tag: 'latest', publishedAt: '2026-01-01T00:00:00.000Z' });
|
|
36
|
+
expect(m.releaseNotesUrl).toBeUndefined();
|
|
37
|
+
expect(m.minPreviousVersion).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects a bad semver version', () => {
|
|
41
|
+
expect(() => buildManifest({ ...validFields, version: 'not-semver' })).toThrow(/not a valid semver/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects a bad npm package name', () => {
|
|
45
|
+
expect(() => buildManifest({ ...validFields, package: 'BAD CHARS!' })).toThrow(/not a valid npm package name/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects a non-ISO publishedAt', () => {
|
|
49
|
+
expect(() => buildManifest({ ...validFields, publishedAt: 'last tuesday' })).toThrow(/ISO-8601/i);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects a bad minPreviousVersion', () => {
|
|
53
|
+
expect(() => buildManifest({ ...validFields, minPreviousVersion: '0.6' })).toThrow(/minPreviousVersion.*not a valid semver/i);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('signAndSelfVerify', () => {
|
|
58
|
+
afterEach(() => { delete process.env.CADRE_HOST_UPDATE_DEV_KEY; });
|
|
59
|
+
|
|
60
|
+
it('produces an envelope that verifies against the derived public key', () => {
|
|
61
|
+
const kp = freshKeypair();
|
|
62
|
+
const manifest = buildManifest(validFields);
|
|
63
|
+
const envelope = signAndSelfVerify(manifest, kp.privateKey);
|
|
64
|
+
// derivePublicKeyBase64 must agree with the independently-extracted raw key.
|
|
65
|
+
expect(derivePublicKeyBase64(kp.privateKey)).toBe(kp.publicRawB64);
|
|
66
|
+
// Independent verification against the derived key round-trips the manifest.
|
|
67
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = kp.publicRawB64;
|
|
68
|
+
expect(verifyManifest(envelope)).toEqual(manifest);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('the self-verify rejects verification against a different key', () => {
|
|
72
|
+
const kp = freshKeypair();
|
|
73
|
+
const other = freshKeypair();
|
|
74
|
+
const envelope = signAndSelfVerify(buildManifest(validFields), kp.privateKey);
|
|
75
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
76
|
+
expect(() => verifyManifest(envelope)).toThrow(/signature/i);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { signManifest } from '../manifest.js';
|
|
8
8
|
import { UpdateService } from '../index.js';
|
|
9
|
+
import { UpdateStateStore } from '../store.js';
|
|
9
10
|
import type { UpdateManifest } from '../types.js';
|
|
10
11
|
|
|
11
12
|
function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
|
|
@@ -50,7 +51,7 @@ describe('UpdateService', () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
it('check(): records an available update when manifest > current', async () => {
|
|
53
|
-
const envelope =
|
|
54
|
+
const envelope = signManifest(manifest('0.7.0', { releaseNotesUrl: 'https://x' }), kp.privateKey);
|
|
54
55
|
const svc = new UpdateService({
|
|
55
56
|
dataDir: tmp,
|
|
56
57
|
currentVersion: '0.6.0',
|
|
@@ -69,7 +70,7 @@ describe('UpdateService', () => {
|
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
it('check(): clears stale available when up-to-date', async () => {
|
|
72
|
-
const envelope =
|
|
73
|
+
const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
|
|
73
74
|
const svc = new UpdateService({
|
|
74
75
|
dataDir: tmp,
|
|
75
76
|
currentVersion: '0.7.0',
|
|
@@ -96,7 +97,7 @@ describe('UpdateService', () => {
|
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
it('check(): records signature_invalid as lastError', async () => {
|
|
99
|
-
const envelope =
|
|
100
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
100
101
|
// Swap the trusted key after signing.
|
|
101
102
|
const other = freshKeypair();
|
|
102
103
|
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
@@ -113,7 +114,7 @@ describe('UpdateService', () => {
|
|
|
113
114
|
});
|
|
114
115
|
|
|
115
116
|
it('apply(): records applyInProgress, calls npm + restart, clears on success', async () => {
|
|
116
|
-
const envelope =
|
|
117
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
117
118
|
const calls: string[][] = [];
|
|
118
119
|
const svc = new UpdateService({
|
|
119
120
|
dataDir: tmp,
|
|
@@ -132,7 +133,7 @@ describe('UpdateService', () => {
|
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
it('apply(): rejects when current is below minPreviousVersion', async () => {
|
|
135
|
-
const envelope =
|
|
136
|
+
const envelope = signManifest(manifest('0.8.0', { minPreviousVersion: '0.7.0' }), kp.privateKey);
|
|
136
137
|
const svc = new UpdateService({
|
|
137
138
|
dataDir: tmp,
|
|
138
139
|
currentVersion: '0.6.0',
|
|
@@ -143,7 +144,7 @@ describe('UpdateService', () => {
|
|
|
143
144
|
});
|
|
144
145
|
|
|
145
146
|
it('apply(): rejects when current >= latest', async () => {
|
|
146
|
-
const envelope =
|
|
147
|
+
const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
|
|
147
148
|
const svc = new UpdateService({
|
|
148
149
|
dataDir: tmp,
|
|
149
150
|
currentVersion: '0.7.0',
|
|
@@ -154,7 +155,7 @@ describe('UpdateService', () => {
|
|
|
154
155
|
});
|
|
155
156
|
|
|
156
157
|
it('apply(): records lastError + clears applyInProgress when npm install fails', async () => {
|
|
157
|
-
const envelope =
|
|
158
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
158
159
|
let installCount = 0;
|
|
159
160
|
const svc = new UpdateService({
|
|
160
161
|
dataDir: tmp,
|
|
@@ -188,7 +189,7 @@ describe('UpdateService', () => {
|
|
|
188
189
|
});
|
|
189
190
|
|
|
190
191
|
it('autoApply: check() triggers apply when settings.autoApply is true', async () => {
|
|
191
|
-
const envelope =
|
|
192
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
192
193
|
const calls: string[][] = [];
|
|
193
194
|
const svc = new UpdateService({
|
|
194
195
|
dataDir: tmp,
|
|
@@ -203,6 +204,40 @@ describe('UpdateService', () => {
|
|
|
203
204
|
expect(calls.some((c) => c[2] === '@serfab/cadre-host@0.7.0')).toBe(true);
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
it('recovers from a stale applyInProgress at construction time', async () => {
|
|
208
|
+
// Simulate a crash: write a state file that records an in-flight apply.
|
|
209
|
+
const seed = new UpdateStateStore(tmp);
|
|
210
|
+
seed.update({
|
|
211
|
+
applyInProgress: {
|
|
212
|
+
fromVersion: '0.6.0',
|
|
213
|
+
toVersion: '0.7.0',
|
|
214
|
+
startedAt: '2026-05-15T18:00:00.000Z',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const svc = new UpdateService({
|
|
219
|
+
dataDir: tmp,
|
|
220
|
+
currentVersion: '0.6.0',
|
|
221
|
+
fetcher: fakeFetch({}),
|
|
222
|
+
manifestUrl: 'https://example.com/m',
|
|
223
|
+
});
|
|
224
|
+
const state = await svc.getState();
|
|
225
|
+
expect(state.applyInProgress).toBeUndefined();
|
|
226
|
+
expect(state.lastError?.code).toBe('apply_failed');
|
|
227
|
+
expect(state.lastError?.message).toMatch(/interrupted/i);
|
|
228
|
+
|
|
229
|
+
// Now apply() must not short-circuit on apply_in_progress; with a stub
|
|
230
|
+
// manifest mismatch we expect the usual no_update_available path instead.
|
|
231
|
+
const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
|
|
232
|
+
const svc2 = new UpdateService({
|
|
233
|
+
dataDir: tmp,
|
|
234
|
+
currentVersion: '0.7.0',
|
|
235
|
+
fetcher: fakeFetch(envelope),
|
|
236
|
+
manifestUrl: 'https://example.com/m',
|
|
237
|
+
});
|
|
238
|
+
await expect(svc2.apply()).rejects.toMatchObject({ code: 'no_update_available' });
|
|
239
|
+
});
|
|
240
|
+
|
|
206
241
|
it('start()/stop(): timer can be armed and cleared', async () => {
|
|
207
242
|
const svc = new UpdateService({
|
|
208
243
|
dataDir: tmp,
|
package/src/update/apply.ts
CHANGED
|
@@ -116,12 +116,25 @@ async function tryRollback(
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/**
|
|
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
|
+
*/
|
|
120
131
|
export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
|
|
121
132
|
return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
const spawnSpec = buildNpmSpawnSpec(args);
|
|
134
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
135
|
+
shell: spawnSpec.shell,
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
});
|
|
125
138
|
let stdout = '';
|
|
126
139
|
let stderr = '';
|
|
127
140
|
child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
|
|
@@ -143,6 +156,35 @@ export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
|
|
|
143
156
|
});
|
|
144
157
|
}
|
|
145
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
|
+
|
|
146
188
|
function tail(s: string, n: number): string {
|
|
147
189
|
return s.length <= n ? s : '…' + s.slice(-n);
|
|
148
190
|
}
|
package/src/update/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
DEFAULT_MANIFEST_URL,
|
|
24
24
|
fetchManifest,
|
|
25
25
|
} from './manifest.js';
|
|
26
|
+
import { isPlaceholderReleaseKey } from './release-key.js';
|
|
26
27
|
import { UpdateStateStore } from './store.js';
|
|
27
28
|
import {
|
|
28
29
|
UpdateErrorException,
|
|
@@ -94,6 +95,31 @@ export class UpdateService {
|
|
|
94
95
|
this.checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
|
|
95
96
|
this.settings = opts.settings ?? { autoApply: false };
|
|
96
97
|
this.manifestUrl = resolveManifestUrl(opts.manifestUrl, this.settings.manifestUrl);
|
|
98
|
+
this.recoverInterruptedApply();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* If `update-state.json` still records an in-flight apply at construction
|
|
103
|
+
* time, the previous cadre-host process died mid-apply (OOM, power loss,
|
|
104
|
+
* sigkill). Without intervention, every subsequent `apply()` would short-
|
|
105
|
+
* circuit on `apply_in_progress` forever. We clear the marker and surface a
|
|
106
|
+
* `lastError` so the UI banner can flag that an apply was interrupted —
|
|
107
|
+
* the npm install may or may not have completed, so the operator should
|
|
108
|
+
* verify with `npm ls -g`.
|
|
109
|
+
*/
|
|
110
|
+
private recoverInterruptedApply(): void {
|
|
111
|
+
const state = this.store.load();
|
|
112
|
+
if (!state.applyInProgress) return;
|
|
113
|
+
const { fromVersion, toVersion } = state.applyInProgress;
|
|
114
|
+
log('clearing stale applyInProgress (%s → %s); previous apply was interrupted', fromVersion, toVersion);
|
|
115
|
+
this.store.update({
|
|
116
|
+
applyInProgress: undefined,
|
|
117
|
+
lastError: {
|
|
118
|
+
code: 'apply_failed',
|
|
119
|
+
message: `previous apply (${fromVersion} → ${toVersion}) was interrupted (cadre-host restarted before it completed); the install may or may not have succeeded — verify with 'npm ls -g'`,
|
|
120
|
+
at: new Date().toISOString(),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
/** Read the persisted state for UI consumption. */
|
|
@@ -128,6 +154,12 @@ export class UpdateService {
|
|
|
128
154
|
* debug; signature failures are recorded as `lastError` so the UI can warn.
|
|
129
155
|
*/
|
|
130
156
|
async check(): Promise<void> {
|
|
157
|
+
if (isPlaceholderReleaseKey()) {
|
|
158
|
+
// A build that shipped the all-zeros placeholder rejects every authentic
|
|
159
|
+
// manifest as signature_invalid; surface that here so it's diagnosable
|
|
160
|
+
// from logs rather than only as an opaque verification failure.
|
|
161
|
+
log('release verification key is the all-zeros placeholder; production manifests will fail signature_invalid (set CADRE_HOST_UPDATE_DEV_KEY for dev/CI only)');
|
|
162
|
+
}
|
|
131
163
|
let manifest: UpdateManifest;
|
|
132
164
|
try {
|
|
133
165
|
manifest = await fetchManifest({
|
|
@@ -294,10 +326,16 @@ function resolveManifestUrl(explicit: string | undefined, fromSettings: string |
|
|
|
294
326
|
return explicit ?? fromSettings ?? DEFAULT_MANIFEST_URL;
|
|
295
327
|
}
|
|
296
328
|
|
|
297
|
-
export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest } from './types.js';
|
|
329
|
+
export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest, SignedManifest } from './types.js';
|
|
298
330
|
export { UpdateErrorException, type UpdateErrorCode } from './types.js';
|
|
299
331
|
export { compareVersions, parseVersion } from './version.js';
|
|
300
|
-
export { fetchManifest, verifyManifest, canonicalJson,
|
|
332
|
+
export { fetchManifest, verifyManifest, canonicalJson, signManifest, validateManifestFields } from './manifest.js';
|
|
301
333
|
export { UpdateStateStore } from './store.js';
|
|
302
334
|
export { applyUpdate, defaultNpmExecutor, type NpmExecutor, type ServiceRestarter } from './apply.js';
|
|
303
|
-
export {
|
|
335
|
+
export {
|
|
336
|
+
getReleasePublicKey,
|
|
337
|
+
getReleasePublicKeyBase64,
|
|
338
|
+
ed25519FromRaw,
|
|
339
|
+
isPlaceholderReleaseKey,
|
|
340
|
+
} from './release-key.js';
|
|
341
|
+
export { buildManifest, derivePublicKeyBase64, signAndSelfVerify, type ManifestFields } from './sign.js';
|