@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.
- package/README.md +101 -0
- package/dist/auth/duration.d.ts +1 -0
- package/dist/auth/duration.js +32 -0
- package/dist/auth/duration.js.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/trust-circle-store.d.ts +26 -0
- package/dist/auth/trust-circle-store.js +129 -0
- package/dist/auth/trust-circle-store.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +91 -0
- package/dist/auth/trust-circle.js +230 -0
- package/dist/auth/trust-circle.js.map +1 -0
- package/dist/auth/types.d.ts +81 -0
- package/dist/auth/types.js +24 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/bin/host.d.ts +13 -0
- package/dist/bin/host.js +826 -0
- package/dist/bin/host.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/browser.d.ts +14 -0
- package/dist/installer/browser.js +35 -0
- package/dist/installer/browser.js.map +1 -0
- package/dist/installer/config.d.ts +43 -0
- package/dist/installer/config.js +113 -0
- package/dist/installer/config.js.map +1 -0
- package/dist/installer/identity.d.ts +23 -0
- package/dist/installer/identity.js +36 -0
- package/dist/installer/identity.js.map +1 -0
- package/dist/installer/index.d.ts +83 -0
- package/dist/installer/index.js +204 -0
- package/dist/installer/index.js.map +1 -0
- package/dist/installer/paths.d.ts +33 -0
- package/dist/installer/paths.js +84 -0
- package/dist/installer/paths.js.map +1 -0
- package/dist/installer/platform.d.ts +9 -0
- package/dist/installer/platform.js +21 -0
- package/dist/installer/platform.js.map +1 -0
- package/dist/installer/service-host/index.d.ts +11 -0
- package/dist/installer/service-host/index.js +16 -0
- package/dist/installer/service-host/index.js.map +1 -0
- package/dist/installer/service-host/launchd.d.ts +16 -0
- package/dist/installer/service-host/launchd.js +79 -0
- package/dist/installer/service-host/launchd.js.map +1 -0
- package/dist/installer/service-host/nssm.d.ts +20 -0
- package/dist/installer/service-host/nssm.js +88 -0
- package/dist/installer/service-host/nssm.js.map +1 -0
- package/dist/installer/service-host/systemd.d.ts +16 -0
- package/dist/installer/service-host/systemd.js +78 -0
- package/dist/installer/service-host/systemd.js.map +1 -0
- package/dist/installer/service-host/template.d.ts +6 -0
- package/dist/installer/service-host/template.js +20 -0
- package/dist/installer/service-host/template.js.map +1 -0
- package/dist/installer/service-host/types.d.ts +40 -0
- package/dist/installer/service-host/types.js +6 -0
- package/dist/installer/service-host/types.js.map +1 -0
- package/dist/installer/wizard.d.ts +31 -0
- package/dist/installer/wizard.js +67 -0
- package/dist/installer/wizard.js.map +1 -0
- package/dist/nat/address-resolver.d.ts +22 -0
- package/dist/nat/address-resolver.js +31 -0
- package/dist/nat/address-resolver.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +12 -0
- package/dist/nat/ddns/duckdns.js +57 -0
- package/dist/nat/ddns/duckdns.js.map +1 -0
- package/dist/nat/ddns/index.d.ts +30 -0
- package/dist/nat/ddns/index.js +21 -0
- package/dist/nat/ddns/index.js.map +1 -0
- package/dist/nat/ddns/updater.d.ts +68 -0
- package/dist/nat/ddns/updater.js +194 -0
- package/dist/nat/ddns/updater.js.map +1 -0
- package/dist/nat/external-ip.d.ts +62 -0
- package/dist/nat/external-ip.js +114 -0
- package/dist/nat/external-ip.js.map +1 -0
- package/dist/nat/index.d.ts +22 -0
- package/dist/nat/index.js +16 -0
- package/dist/nat/index.js.map +1 -0
- package/dist/nat/nat-service.d.ts +101 -0
- package/dist/nat/nat-service.js +284 -0
- package/dist/nat/nat-service.js.map +1 -0
- package/dist/nat/nat-store.d.ts +19 -0
- package/dist/nat/nat-store.js +136 -0
- package/dist/nat/nat-store.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +98 -0
- package/dist/nat/port-mapper.js +226 -0
- package/dist/nat/port-mapper.js.map +1 -0
- package/dist/nat/reachability.d.ts +19 -0
- package/dist/nat/reachability.js +29 -0
- package/dist/nat/reachability.js.map +1 -0
- package/dist/nat/secrets/file-store.d.ts +23 -0
- package/dist/nat/secrets/file-store.js +102 -0
- package/dist/nat/secrets/file-store.js.map +1 -0
- package/dist/nat/secrets/index.d.ts +36 -0
- package/dist/nat/secrets/index.js +56 -0
- package/dist/nat/secrets/index.js.map +1 -0
- package/dist/nat/secrets/keytar-store.d.ts +18 -0
- package/dist/nat/secrets/keytar-store.js +52 -0
- package/dist/nat/secrets/keytar-store.js.map +1 -0
- package/dist/nat/types.d.ts +120 -0
- package/dist/nat/types.js +24 -0
- package/dist/nat/types.js.map +1 -0
- package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
- package/dist/orchestrator/host-process-orchestrator.js +557 -0
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.js +2 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/orchestrator/log-rotator.d.ts +25 -0
- package/dist/orchestrator/log-rotator.js +56 -0
- package/dist/orchestrator/log-rotator.js.map +1 -0
- package/dist/orchestrator/pid-liveness.d.ts +11 -0
- package/dist/orchestrator/pid-liveness.js +26 -0
- package/dist/orchestrator/pid-liveness.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +16 -0
- package/dist/orchestrator/port-allocator.js +38 -0
- package/dist/orchestrator/port-allocator.js.map +1 -0
- package/dist/orchestrator/state-store.d.ts +32 -0
- package/dist/orchestrator/state-store.js +44 -0
- package/dist/orchestrator/state-store.js.map +1 -0
- package/dist/orchestrator/types.d.ts +85 -0
- package/dist/orchestrator/types.js +31 -0
- package/dist/orchestrator/types.js.map +1 -0
- package/dist/server/error-handler.d.ts +26 -0
- package/dist/server/error-handler.js +95 -0
- package/dist/server/error-handler.js.map +1 -0
- package/dist/server/events/bus.d.ts +17 -0
- package/dist/server/events/bus.js +36 -0
- package/dist/server/events/bus.js.map +1 -0
- package/dist/server/events/sse-route.d.ts +22 -0
- package/dist/server/events/sse-route.js +65 -0
- package/dist/server/events/sse-route.js.map +1 -0
- package/dist/server/events/types.d.ts +25 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/events/types.js.map +1 -0
- package/dist/server/index.d.ts +67 -0
- package/dist/server/index.js +149 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/origin-guard.d.ts +24 -0
- package/dist/server/origin-guard.js +72 -0
- package/dist/server/origin-guard.js.map +1 -0
- package/dist/server/routes/nat.d.ts +16 -0
- package/dist/server/routes/nat.js +42 -0
- package/dist/server/routes/nat.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +23 -0
- package/dist/server/routes/nodes.js +128 -0
- package/dist/server/routes/nodes.js.map +1 -0
- package/dist/server/routes/settings.d.ts +26 -0
- package/dist/server/routes/settings.js +97 -0
- package/dist/server/routes/settings.js.map +1 -0
- package/dist/server/routes/status.d.ts +42 -0
- package/dist/server/routes/status.js +76 -0
- package/dist/server/routes/status.js.map +1 -0
- package/dist/server/routes/trust-circle.d.ts +17 -0
- package/dist/server/routes/trust-circle.js +35 -0
- package/dist/server/routes/trust-circle.js.map +1 -0
- package/dist/server/routes/update.d.ts +16 -0
- package/dist/server/routes/update.js +29 -0
- package/dist/server/routes/update.js.map +1 -0
- package/dist/server/server.d.ts +40 -0
- package/dist/server/server.js +90 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/settings-store.d.ts +24 -0
- package/dist/server/settings-store.js +27 -0
- package/dist/server/settings-store.js.map +1 -0
- package/dist/server/static.d.ts +25 -0
- package/dist/server/static.js +177 -0
- package/dist/server/static.js.map +1 -0
- package/dist/ui/assets/index-BwU5mJGF.css +1 -0
- package/dist/ui/assets/index-C9uChk2P.js +14 -0
- package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/update/apply.d.ts +37 -0
- package/dist/update/apply.js +102 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/index.d.ts +101 -0
- package/dist/update/index.js +228 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/manifest.d.ts +48 -0
- package/dist/update/manifest.js +152 -0
- package/dist/update/manifest.js.map +1 -0
- package/dist/update/release-key.d.ts +24 -0
- package/dist/update/release-key.js +52 -0
- package/dist/update/release-key.js.map +1 -0
- package/dist/update/store.d.ts +22 -0
- package/dist/update/store.js +81 -0
- package/dist/update/store.js.map +1 -0
- package/dist/update/types.d.ts +90 -0
- package/dist/update/types.js +22 -0
- package/dist/update/types.js.map +1 -0
- package/dist/update/version.d.ts +18 -0
- package/dist/update/version.js +76 -0
- package/dist/update/version.js.map +1 -0
- package/package.json +80 -0
- package/service/README.md +86 -0
- package/service/cadre-host.service.tmpl +16 -0
- package/service/com.serfab.cadre-host.plist.tmpl +35 -0
- package/service/install-service.ps1 +98 -0
- package/service/uninstall-service.ps1 +19 -0
- package/src/__tests__/cli-invite.smoke.test.ts +187 -0
- package/src/__tests__/cli-nat.smoke.test.ts +207 -0
- package/src/__tests__/cli.smoke.test.ts +41 -0
- package/src/__tests__/orchestrator.test.ts +570 -0
- package/src/auth/__tests__/duration.test.ts +28 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
- package/src/auth/__tests__/trust-circle.test.ts +334 -0
- package/src/auth/duration.ts +33 -0
- package/src/auth/index.ts +19 -0
- package/src/auth/trust-circle-store.ts +155 -0
- package/src/auth/trust-circle.ts +304 -0
- package/src/auth/types.ts +96 -0
- package/src/bin/host.ts +905 -0
- package/src/index.ts +167 -0
- package/src/installer/__tests__/config.test.ts +86 -0
- package/src/installer/__tests__/identity.test.ts +34 -0
- package/src/installer/__tests__/installer.smoke.test.ts +124 -0
- package/src/installer/__tests__/paths.test.ts +47 -0
- package/src/installer/__tests__/service-host.test.ts +62 -0
- package/src/installer/__tests__/wizard.test.ts +60 -0
- package/src/installer/browser.ts +42 -0
- package/src/installer/config.ts +153 -0
- package/src/installer/identity.ts +49 -0
- package/src/installer/index.ts +303 -0
- package/src/installer/paths.ts +109 -0
- package/src/installer/platform.ts +27 -0
- package/src/installer/service-host/index.ts +21 -0
- package/src/installer/service-host/launchd.ts +90 -0
- package/src/installer/service-host/nssm.ts +100 -0
- package/src/installer/service-host/systemd.ts +88 -0
- package/src/installer/service-host/template.ts +21 -0
- package/src/installer/service-host/types.ts +48 -0
- package/src/installer/wizard.ts +92 -0
- package/src/nat/__tests__/address-resolver.test.ts +82 -0
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
- package/src/nat/__tests__/ddns-updater.test.ts +253 -0
- package/src/nat/__tests__/external-ip.test.ts +124 -0
- package/src/nat/__tests__/nat-service.test.ts +305 -0
- package/src/nat/__tests__/nat-store.test.ts +93 -0
- package/src/nat/__tests__/port-mapper.test.ts +191 -0
- package/src/nat/__tests__/reachability.test.ts +24 -0
- package/src/nat/__tests__/secrets.test.ts +156 -0
- package/src/nat/address-resolver.ts +51 -0
- package/src/nat/ddns/duckdns.ts +76 -0
- package/src/nat/ddns/index.ts +55 -0
- package/src/nat/ddns/updater.ts +243 -0
- package/src/nat/external-ip.ts +154 -0
- package/src/nat/index.ts +71 -0
- package/src/nat/nat-service.ts +375 -0
- package/src/nat/nat-store.ts +154 -0
- package/src/nat/port-mapper.ts +329 -0
- package/src/nat/reachability.ts +34 -0
- package/src/nat/secrets/file-store.ts +130 -0
- package/src/nat/secrets/index.ts +82 -0
- package/src/nat/secrets/keytar-store.ts +67 -0
- package/src/nat/types.ts +157 -0
- package/src/orchestrator/host-process-orchestrator.ts +626 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/log-rotator.ts +53 -0
- package/src/orchestrator/pid-liveness.ts +24 -0
- package/src/orchestrator/port-allocator.ts +43 -0
- package/src/orchestrator/state-store.ts +72 -0
- package/src/orchestrator/types.ts +95 -0
- package/src/server/__tests__/bus.test.ts +38 -0
- package/src/server/__tests__/error-handler.test.ts +96 -0
- package/src/server/__tests__/nodes-route.test.ts +179 -0
- package/src/server/__tests__/origin-guard.test.ts +72 -0
- package/src/server/__tests__/publishers.test.ts +216 -0
- package/src/server/__tests__/server.smoke.test.ts +178 -0
- package/src/server/__tests__/settings-route.test.ts +177 -0
- package/src/server/__tests__/sse-route.test.ts +119 -0
- package/src/server/__tests__/status-route.test.ts +113 -0
- package/src/server/error-handler.ts +108 -0
- package/src/server/events/bus.ts +42 -0
- package/src/server/events/sse-route.ts +81 -0
- package/src/server/events/types.ts +22 -0
- package/src/server/index.ts +209 -0
- package/src/server/origin-guard.ts +84 -0
- package/src/server/routes/nat.ts +63 -0
- package/src/server/routes/nodes.ts +147 -0
- package/src/server/routes/settings.ts +115 -0
- package/src/server/routes/status.ts +108 -0
- package/src/server/routes/trust-circle.ts +49 -0
- package/src/server/routes/update.ts +43 -0
- package/src/server/server.ts +122 -0
- package/src/server/settings-store.ts +37 -0
- package/src/server/static.ts +191 -0
- package/src/update/__tests__/apply.test.ts +101 -0
- package/src/update/__tests__/manifest.test.ts +93 -0
- package/src/update/__tests__/store.test.ts +81 -0
- package/src/update/__tests__/update-service.integration.test.ts +221 -0
- package/src/update/__tests__/version.test.ts +31 -0
- package/src/update/apply.ts +148 -0
- package/src/update/index.ts +303 -0
- package/src/update/manifest.ts +172 -0
- package/src/update/release-key.ts +55 -0
- package/src/update/store.ts +101 -0
- package/src/update/types.ts +109 -0
- package/src/update/version.ts +80 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-asset mount for the SPA bundle.
|
|
3
|
+
*
|
|
4
|
+
* The SPA bundle is produced by `6.5.2-cadre-host-local-ui-spa` into
|
|
5
|
+
* `<package>/dist/ui/`. When that directory exists we serve files from it
|
|
6
|
+
* with an `index.html` SPA fallback. When it's missing (running from source
|
|
7
|
+
* before the SPA ticket lands) we serve a placeholder page that explains
|
|
8
|
+
* how to install/build the SPA.
|
|
9
|
+
*
|
|
10
|
+
* Hand-rolled rather than pulling in `@fastify/static` — the surface area we
|
|
11
|
+
* need is tiny and the dep would only matter under tests that don't exercise
|
|
12
|
+
* static serving anyway.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
16
|
+
import { dirname, extname, join, normalize, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
20
|
+
import debug from 'debug';
|
|
21
|
+
|
|
22
|
+
const log = debug('cadre:host:static');
|
|
23
|
+
|
|
24
|
+
const MIME_TYPES: Record<string, string> = {
|
|
25
|
+
'.html': 'text/html; charset=utf-8',
|
|
26
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
27
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
28
|
+
'.css': 'text/css; charset=utf-8',
|
|
29
|
+
'.json': 'application/json; charset=utf-8',
|
|
30
|
+
'.svg': 'image/svg+xml',
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.ico': 'image/x-icon',
|
|
36
|
+
'.woff': 'font/woff',
|
|
37
|
+
'.woff2': 'font/woff2',
|
|
38
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
39
|
+
'.map': 'application/json; charset=utf-8',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default location of the SPA bundle when `cadre-host` runs from its
|
|
44
|
+
* installed package.
|
|
45
|
+
* - dist/server/static.js → ../ui → packages/cadre-host/dist/ui
|
|
46
|
+
*/
|
|
47
|
+
export function defaultStaticRoot(): string {
|
|
48
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
return resolve(here, '..', 'ui');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StaticMountOptions {
|
|
53
|
+
/** Absolute path to the SPA build output (defaults to `defaultStaticRoot()`). */
|
|
54
|
+
rootDir?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerStaticMount(app: FastifyInstance, opts: StaticMountOptions = {}): void {
|
|
58
|
+
const rootDir = opts.rootDir ?? defaultStaticRoot();
|
|
59
|
+
|
|
60
|
+
// Register *after* all routes so API paths win.
|
|
61
|
+
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
|
62
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
63
|
+
return reply.code(404).send({
|
|
64
|
+
ok: false,
|
|
65
|
+
error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Don't try to serve API paths as static.
|
|
69
|
+
if (
|
|
70
|
+
request.url.startsWith('/api') ||
|
|
71
|
+
request.url.startsWith('/auth') ||
|
|
72
|
+
request.url.startsWith('/nat') ||
|
|
73
|
+
request.url.startsWith('/update')
|
|
74
|
+
) {
|
|
75
|
+
return reply.code(404).send({
|
|
76
|
+
ok: false,
|
|
77
|
+
error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return serveStatic(rootDir, request, reply);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function serveStatic(rootDir: string, request: FastifyRequest, reply: FastifyReply): FastifyReply {
|
|
85
|
+
if (!existsSync(rootDir)) {
|
|
86
|
+
if (request.url === '/' || request.url === '/index.html') {
|
|
87
|
+
return reply.code(200).type('text/html; charset=utf-8').send(placeholderHtml());
|
|
88
|
+
}
|
|
89
|
+
return reply.code(404).send({
|
|
90
|
+
ok: false,
|
|
91
|
+
error: { code: 'not_found', message: 'SPA bundle not installed' },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const requested = stripQuery(request.url);
|
|
96
|
+
const target = resolveSafe(rootDir, requested);
|
|
97
|
+
if (!target) {
|
|
98
|
+
return reply.code(404).send({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: { code: 'not_found', message: `No static asset for ${request.url}` },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const stat = statSync(target);
|
|
105
|
+
if (stat.isFile()) return sendFile(reply, target);
|
|
106
|
+
} catch {
|
|
107
|
+
// fall through to SPA index
|
|
108
|
+
}
|
|
109
|
+
// SPA fallback: serve index.html for any GET that accepts HTML.
|
|
110
|
+
const accept = (request.headers.accept ?? '').toLowerCase();
|
|
111
|
+
if (accept.includes('text/html') || accept === '' || accept === '*/*') {
|
|
112
|
+
const index = join(rootDir, 'index.html');
|
|
113
|
+
if (existsSync(index)) return sendFile(reply, index);
|
|
114
|
+
}
|
|
115
|
+
return reply.code(404).send({
|
|
116
|
+
ok: false,
|
|
117
|
+
error: { code: 'not_found', message: `No static asset for ${request.url}` },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveSafe(rootDir: string, urlPath: string): string | null {
|
|
122
|
+
const cleaned = decodeSafe(urlPath);
|
|
123
|
+
if (cleaned === null) return null;
|
|
124
|
+
const target = normalize(join(rootDir, cleaned === '/' ? '' : cleaned));
|
|
125
|
+
// Disallow path traversal above rootDir.
|
|
126
|
+
if (!target.startsWith(normalize(rootDir))) return null;
|
|
127
|
+
return target;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function decodeSafe(urlPath: string): string | null {
|
|
131
|
+
try {
|
|
132
|
+
const decoded = decodeURIComponent(urlPath);
|
|
133
|
+
if (decoded.includes('\0')) return null;
|
|
134
|
+
return decoded;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stripQuery(url: string): string {
|
|
141
|
+
const q = url.indexOf('?');
|
|
142
|
+
return q === -1 ? url : url.slice(0, q);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sendFile(reply: FastifyReply, path: string): FastifyReply {
|
|
146
|
+
const buf = readFileSync(path);
|
|
147
|
+
const mime = MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
148
|
+
return reply.code(200).type(mime).send(buf);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function placeholderHtml(): string {
|
|
152
|
+
return `<!doctype html>
|
|
153
|
+
<html lang="en">
|
|
154
|
+
<head>
|
|
155
|
+
<meta charset="utf-8" />
|
|
156
|
+
<title>cadre-host — UI bundle missing</title>
|
|
157
|
+
<style>
|
|
158
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; color: #1a1a1a; }
|
|
159
|
+
h1 { font-weight: 600; }
|
|
160
|
+
code { background: #f4f4f4; padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
|
|
161
|
+
pre { background: #f4f4f4; padding: 0.75rem 1rem; border-radius: 0.5rem; overflow: auto; }
|
|
162
|
+
.endpoint { font-family: ui-monospace, monospace; }
|
|
163
|
+
</style>
|
|
164
|
+
</head>
|
|
165
|
+
<body>
|
|
166
|
+
<h1>cadre-host is running — UI bundle is missing</h1>
|
|
167
|
+
<p>
|
|
168
|
+
The HTTP management API is fully available (see endpoints below), but the
|
|
169
|
+
Svelte SPA that normally renders at this URL hasn't been built into this
|
|
170
|
+
install. This page is most likely shown because the package was started
|
|
171
|
+
from a source checkout without running the UI build step.
|
|
172
|
+
</p>
|
|
173
|
+
<p>To build it:</p>
|
|
174
|
+
<pre>yarn workspace @serfab/cadre-host build</pre>
|
|
175
|
+
<p>or just the UI:</p>
|
|
176
|
+
<pre>yarn workspace @serfab/cadre-host build:ui</pre>
|
|
177
|
+
<p>Then refresh this page.</p>
|
|
178
|
+
|
|
179
|
+
<h2 style="font-size:1.1rem;margin-top:2rem;">Available API endpoints</h2>
|
|
180
|
+
<ul>
|
|
181
|
+
<li><span class="endpoint">GET /api/status</span></li>
|
|
182
|
+
<li><span class="endpoint">GET /api/nodes</span></li>
|
|
183
|
+
<li><span class="endpoint">GET /api/settings</span></li>
|
|
184
|
+
<li><span class="endpoint">GET /api/events</span> (Server-Sent Events)</li>
|
|
185
|
+
<li><span class="endpoint">/auth/*</span>, <span class="endpoint">/nat/*</span>, <span class="endpoint">/update/*</span></li>
|
|
186
|
+
</ul>
|
|
187
|
+
<p>See <a href="https://github.com/gotchoices/sereus/blob/master/docs/cadre-host.md">docs/cadre-host.md</a>.</p>
|
|
188
|
+
</body>
|
|
189
|
+
</html>
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { applyUpdate, type NpmExecutor, type NpmResult } from '../apply.js';
|
|
4
|
+
import { UpdateErrorException } from '../types.js';
|
|
5
|
+
|
|
6
|
+
function recordingExecutor(responses: NpmResult[]): { exec: NpmExecutor; calls: string[][] } {
|
|
7
|
+
const calls: string[][] = [];
|
|
8
|
+
let i = 0;
|
|
9
|
+
return {
|
|
10
|
+
calls,
|
|
11
|
+
async exec(args) {
|
|
12
|
+
calls.push(args);
|
|
13
|
+
const res = responses[i++] ?? { exitCode: 0, stdout: '', stderr: '' };
|
|
14
|
+
return res;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('applyUpdate', () => {
|
|
20
|
+
it('happy path: install succeeds, restart called', async () => {
|
|
21
|
+
const { exec, calls } = recordingExecutor([{ exitCode: 0, stdout: 'ok', stderr: '' }]);
|
|
22
|
+
let restartCount = 0;
|
|
23
|
+
const result = await applyUpdate({
|
|
24
|
+
npmPackage: '@serfab/cadre-host',
|
|
25
|
+
fromVersion: '0.6.0',
|
|
26
|
+
toVersion: '0.7.0',
|
|
27
|
+
npmExecutor: exec,
|
|
28
|
+
restart: async () => { restartCount++; return undefined; },
|
|
29
|
+
});
|
|
30
|
+
expect(calls).toEqual([['install', '-g', '@serfab/cadre-host@0.7.0']]);
|
|
31
|
+
expect(restartCount).toBe(1);
|
|
32
|
+
expect(result).toMatchObject({ fromVersion: '0.6.0', toVersion: '0.7.0', restarted: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('install succeeds but restart fails: result reports restartError, no throw', async () => {
|
|
36
|
+
const { exec } = recordingExecutor([{ exitCode: 0, stdout: 'ok', stderr: '' }]);
|
|
37
|
+
const result = await applyUpdate({
|
|
38
|
+
npmPackage: '@serfab/cadre-host',
|
|
39
|
+
fromVersion: '0.6.0',
|
|
40
|
+
toVersion: '0.7.0',
|
|
41
|
+
npmExecutor: exec,
|
|
42
|
+
restart: async () => 'systemctl returned 1',
|
|
43
|
+
});
|
|
44
|
+
expect(result.restarted).toBe(false);
|
|
45
|
+
expect(result.restartError).toBe('systemctl returned 1');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('install fails: rollback invoked, restart NOT called, throws apply_failed', async () => {
|
|
49
|
+
const { exec, calls } = recordingExecutor([
|
|
50
|
+
{ exitCode: 1, stdout: '', stderr: 'EACCES' },
|
|
51
|
+
{ exitCode: 0, stdout: 'rolled back', stderr: '' },
|
|
52
|
+
]);
|
|
53
|
+
let restartCount = 0;
|
|
54
|
+
await expect(applyUpdate({
|
|
55
|
+
npmPackage: '@serfab/cadre-host',
|
|
56
|
+
fromVersion: '0.6.0',
|
|
57
|
+
toVersion: '0.7.0',
|
|
58
|
+
npmExecutor: exec,
|
|
59
|
+
restart: async () => { restartCount++; return undefined; },
|
|
60
|
+
})).rejects.toBeInstanceOf(UpdateErrorException);
|
|
61
|
+
expect(calls).toEqual([
|
|
62
|
+
['install', '-g', '@serfab/cadre-host@0.7.0'],
|
|
63
|
+
['install', '-g', '@serfab/cadre-host@0.6.0'],
|
|
64
|
+
]);
|
|
65
|
+
expect(restartCount).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('install fails and rollback also fails: throws rollback_failed', async () => {
|
|
69
|
+
const { exec } = recordingExecutor([
|
|
70
|
+
{ exitCode: 1, stdout: '', stderr: 'EACCES' },
|
|
71
|
+
{ exitCode: 1, stdout: '', stderr: 'EACCES on rollback' },
|
|
72
|
+
]);
|
|
73
|
+
await expect(applyUpdate({
|
|
74
|
+
npmPackage: '@serfab/cadre-host',
|
|
75
|
+
fromVersion: '0.6.0',
|
|
76
|
+
toVersion: '0.7.0',
|
|
77
|
+
npmExecutor: exec,
|
|
78
|
+
})).rejects.toMatchObject({ code: 'rollback_failed' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('install throws (spawn error): rollback still attempted', async () => {
|
|
82
|
+
let i = 0;
|
|
83
|
+
const calls: string[][] = [];
|
|
84
|
+
const exec: NpmExecutor = async (args) => {
|
|
85
|
+
calls.push(args);
|
|
86
|
+
i++;
|
|
87
|
+
if (i === 1) throw new Error('ENOENT npm');
|
|
88
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
89
|
+
};
|
|
90
|
+
await expect(applyUpdate({
|
|
91
|
+
npmPackage: '@serfab/cadre-host',
|
|
92
|
+
fromVersion: '0.6.0',
|
|
93
|
+
toVersion: '0.7.0',
|
|
94
|
+
npmExecutor: exec,
|
|
95
|
+
})).rejects.toMatchObject({ code: 'apply_failed' });
|
|
96
|
+
expect(calls).toEqual([
|
|
97
|
+
['install', '-g', '@serfab/cadre-host@0.7.0'],
|
|
98
|
+
['install', '-g', '@serfab/cadre-host@0.6.0'],
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { canonicalJson, signManifestForTesting, verifyManifest } from '../manifest.js';
|
|
5
|
+
import type { UpdateManifest } from '../types.js';
|
|
6
|
+
|
|
7
|
+
/** Generate a fresh Ed25519 keypair and surface the raw 32-byte public key. */
|
|
8
|
+
function freshKeypair(): { publicKey: KeyObject; privateKey: KeyObject; publicRawB64: string } {
|
|
9
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
10
|
+
// Extract the raw 32-byte public key from SPKI DER (strip the 12-byte header).
|
|
11
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' });
|
|
12
|
+
const raw = spki.subarray(12);
|
|
13
|
+
return { publicKey, privateKey, publicRawB64: Buffer.from(raw).toString('base64') };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sampleManifest: UpdateManifest = {
|
|
17
|
+
v: 1,
|
|
18
|
+
version: '0.7.0',
|
|
19
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
20
|
+
channels: { npm: { package: '@serfab/cadre-host', tag: 'latest' } },
|
|
21
|
+
releaseNotesUrl: 'https://example.com/notes',
|
|
22
|
+
minPreviousVersion: '0.6.0',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('canonicalJson', () => {
|
|
26
|
+
it('sorts keys deterministically and omits whitespace', () => {
|
|
27
|
+
expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
|
|
28
|
+
expect(canonicalJson({ outer: { z: 1, a: 2 }, top: true })).toBe('{"outer":{"a":2,"z":1},"top":true}');
|
|
29
|
+
expect(canonicalJson([3, 2, 1])).toBe('[3,2,1]');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('omits undefined fields', () => {
|
|
33
|
+
expect(canonicalJson({ a: 1, b: undefined })).toBe('{"a":1}');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('verifyManifest', () => {
|
|
38
|
+
let kp: ReturnType<typeof freshKeypair>;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
kp = freshKeypair();
|
|
42
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = kp.publicRawB64;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
delete process.env.CADRE_HOST_UPDATE_DEV_KEY;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('round-trips: sign + verify', () => {
|
|
50
|
+
const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
|
|
51
|
+
const out = verifyManifest(envelope);
|
|
52
|
+
expect(out).toEqual(sampleManifest);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects a mutated payload (signature_invalid)', () => {
|
|
56
|
+
const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
|
|
57
|
+
const tampered = { ...envelope, manifest: { ...envelope.manifest, version: '99.0.0' } };
|
|
58
|
+
expect(() => verifyManifest(tampered)).toThrow(/signature/i);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects a mutated signature (signature_invalid)', () => {
|
|
62
|
+
const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
|
|
63
|
+
const sigBytes = Buffer.from(envelope.sig.slice('ed25519:'.length), 'base64');
|
|
64
|
+
sigBytes[0] ^= 0xff;
|
|
65
|
+
const tampered = { ...envelope, sig: `ed25519:${sigBytes.toString('base64')}` };
|
|
66
|
+
expect(() => verifyManifest(tampered)).toThrow(/signature/i);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects an envelope signed by a different key (signature_invalid)', () => {
|
|
70
|
+
const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
|
|
71
|
+
// Swap the env-configured public key to a different fresh one.
|
|
72
|
+
const other = freshKeypair();
|
|
73
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
74
|
+
expect(() => verifyManifest(envelope)).toThrow(/signature/i);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects malformed envelopes (manifest_invalid)', () => {
|
|
78
|
+
expect(() => verifyManifest(null)).toThrow(/malformed/);
|
|
79
|
+
expect(() => verifyManifest({})).toThrow(/malformed/);
|
|
80
|
+
expect(() => verifyManifest({ manifest: 'nope', sig: 'ed25519:AAAA' })).toThrow(/malformed/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects manifests missing required fields (manifest_invalid)', () => {
|
|
84
|
+
const bad = { manifest: { v: 1, version: '0.7.0' }, sig: 'ed25519:AAAA' };
|
|
85
|
+
expect(() => verifyManifest(bad)).toThrow(/missing required fields/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects unsupported signature schemes', () => {
|
|
89
|
+
const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
|
|
90
|
+
const tampered = { ...envelope, sig: envelope.sig.replace('ed25519:', 'rsa:') };
|
|
91
|
+
expect(() => verifyManifest(tampered)).toThrow(/unsupported signature scheme/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { UpdateStateStore } from '../store.js';
|
|
7
|
+
import type { UpdateState } from '../types.js';
|
|
8
|
+
|
|
9
|
+
describe('UpdateStateStore', () => {
|
|
10
|
+
let tmp: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmp = mkdtempSync(join(tmpdir(), 'cadre-update-store-'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns an empty state when the file is missing', () => {
|
|
21
|
+
const store = new UpdateStateStore(tmp);
|
|
22
|
+
expect(store.load()).toEqual({ version: 1 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('round-trips a populated state', () => {
|
|
26
|
+
const store = new UpdateStateStore(tmp);
|
|
27
|
+
const state: UpdateState = {
|
|
28
|
+
version: 1,
|
|
29
|
+
lastChecked: '2026-05-15T18:00:00.000Z',
|
|
30
|
+
available: {
|
|
31
|
+
version: '0.7.0',
|
|
32
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
33
|
+
releaseNotesUrl: 'https://example.com/notes',
|
|
34
|
+
},
|
|
35
|
+
applyInProgress: {
|
|
36
|
+
fromVersion: '0.6.0',
|
|
37
|
+
toVersion: '0.7.0',
|
|
38
|
+
startedAt: '2026-05-15T18:01:00.000Z',
|
|
39
|
+
},
|
|
40
|
+
lastError: {
|
|
41
|
+
code: 'apply_failed',
|
|
42
|
+
message: 'npm fell over',
|
|
43
|
+
at: '2026-05-15T18:02:00.000Z',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
store.save(state);
|
|
47
|
+
|
|
48
|
+
// Re-open to bypass the in-memory cache.
|
|
49
|
+
const reopened = new UpdateStateStore(tmp);
|
|
50
|
+
expect(reopened.load()).toEqual(state);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects unsupported state versions', () => {
|
|
54
|
+
writeFileSync(join(tmp, 'update-state.json'), JSON.stringify({ version: 99 }));
|
|
55
|
+
const store = new UpdateStateStore(tmp);
|
|
56
|
+
expect(() => store.load()).toThrow(/unsupported version/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects malformed JSON', () => {
|
|
60
|
+
writeFileSync(join(tmp, 'update-state.json'), 'definitely not json');
|
|
61
|
+
const store = new UpdateStateStore(tmp);
|
|
62
|
+
expect(() => store.load()).toThrow(/not valid JSON/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('update() merges patches into the persisted state', () => {
|
|
66
|
+
const store = new UpdateStateStore(tmp);
|
|
67
|
+
store.update({ lastChecked: '2026-05-15T18:00:00.000Z' });
|
|
68
|
+
store.update({ available: { version: '0.7.0', publishedAt: '2026-05-15T18:00:00.000Z' } });
|
|
69
|
+
const state = store.load();
|
|
70
|
+
expect(state.lastChecked).toBe('2026-05-15T18:00:00.000Z');
|
|
71
|
+
expect(state.available).toEqual({ version: '0.7.0', publishedAt: '2026-05-15T18:00:00.000Z' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('clearField() drops a key from the persisted state', () => {
|
|
75
|
+
const store = new UpdateStateStore(tmp);
|
|
76
|
+
store.update({ available: { version: '0.7.0', publishedAt: '2026-05-15T18:00:00.000Z' } });
|
|
77
|
+
store.clearField('available');
|
|
78
|
+
const reopened = new UpdateStateStore(tmp);
|
|
79
|
+
expect(reopened.load().available).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
import { signManifestForTesting } from '../manifest.js';
|
|
8
|
+
import { UpdateService } from '../index.js';
|
|
9
|
+
import type { UpdateManifest } from '../types.js';
|
|
10
|
+
|
|
11
|
+
function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
|
|
12
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
13
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' });
|
|
14
|
+
return { privateKey, publicRawB64: Buffer.from(spki.subarray(12)).toString('base64') };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function manifest(version: string, opts: Partial<UpdateManifest> = {}): UpdateManifest {
|
|
18
|
+
return {
|
|
19
|
+
v: 1,
|
|
20
|
+
version,
|
|
21
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
22
|
+
channels: { npm: { package: '@serfab/cadre-host', tag: 'latest' } },
|
|
23
|
+
...opts,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeFetch(envelope: unknown): typeof fetch {
|
|
28
|
+
return (async (_url: string, _init?: RequestInit) => {
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
status: 200,
|
|
32
|
+
json: async () => envelope,
|
|
33
|
+
} as Response;
|
|
34
|
+
}) as typeof fetch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('UpdateService', () => {
|
|
38
|
+
let tmp: string;
|
|
39
|
+
let kp: ReturnType<typeof freshKeypair>;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
tmp = mkdtempSync(join(tmpdir(), 'cadre-update-svc-'));
|
|
43
|
+
kp = freshKeypair();
|
|
44
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = kp.publicRawB64;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
delete process.env.CADRE_HOST_UPDATE_DEV_KEY;
|
|
49
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('check(): records an available update when manifest > current', async () => {
|
|
53
|
+
const envelope = signManifestForTesting(manifest('0.7.0', { releaseNotesUrl: 'https://x' }), kp.privateKey);
|
|
54
|
+
const svc = new UpdateService({
|
|
55
|
+
dataDir: tmp,
|
|
56
|
+
currentVersion: '0.6.0',
|
|
57
|
+
fetcher: fakeFetch(envelope),
|
|
58
|
+
manifestUrl: 'https://example.com/m',
|
|
59
|
+
});
|
|
60
|
+
await svc.check();
|
|
61
|
+
const state = await svc.getState();
|
|
62
|
+
expect(state.available).toEqual({
|
|
63
|
+
version: '0.7.0',
|
|
64
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
65
|
+
releaseNotesUrl: 'https://x',
|
|
66
|
+
});
|
|
67
|
+
expect(state.lastChecked).toBeDefined();
|
|
68
|
+
expect(state.lastError).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('check(): clears stale available when up-to-date', async () => {
|
|
72
|
+
const envelope = signManifestForTesting(manifest('0.6.0'), kp.privateKey);
|
|
73
|
+
const svc = new UpdateService({
|
|
74
|
+
dataDir: tmp,
|
|
75
|
+
currentVersion: '0.7.0',
|
|
76
|
+
fetcher: fakeFetch(envelope),
|
|
77
|
+
manifestUrl: 'https://example.com/m',
|
|
78
|
+
});
|
|
79
|
+
await svc.check();
|
|
80
|
+
const state = await svc.getState();
|
|
81
|
+
expect(state.available).toBeUndefined();
|
|
82
|
+
expect(state.lastChecked).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('check(): silent on network failure', async () => {
|
|
86
|
+
const svc = new UpdateService({
|
|
87
|
+
dataDir: tmp,
|
|
88
|
+
currentVersion: '0.6.0',
|
|
89
|
+
fetcher: (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch,
|
|
90
|
+
manifestUrl: 'https://example.com/m',
|
|
91
|
+
});
|
|
92
|
+
await svc.check();
|
|
93
|
+
const state = await svc.getState();
|
|
94
|
+
expect(state.lastChecked).toBeUndefined();
|
|
95
|
+
expect(state.lastError).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('check(): records signature_invalid as lastError', async () => {
|
|
99
|
+
const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
|
|
100
|
+
// Swap the trusted key after signing.
|
|
101
|
+
const other = freshKeypair();
|
|
102
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
103
|
+
const svc = new UpdateService({
|
|
104
|
+
dataDir: tmp,
|
|
105
|
+
currentVersion: '0.6.0',
|
|
106
|
+
fetcher: fakeFetch(envelope),
|
|
107
|
+
manifestUrl: 'https://example.com/m',
|
|
108
|
+
});
|
|
109
|
+
await svc.check();
|
|
110
|
+
const state = await svc.getState();
|
|
111
|
+
expect(state.lastError?.code).toBe('signature_invalid');
|
|
112
|
+
expect(state.available).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('apply(): records applyInProgress, calls npm + restart, clears on success', async () => {
|
|
116
|
+
const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
|
|
117
|
+
const calls: string[][] = [];
|
|
118
|
+
const svc = new UpdateService({
|
|
119
|
+
dataDir: tmp,
|
|
120
|
+
currentVersion: '0.6.0',
|
|
121
|
+
fetcher: fakeFetch(envelope),
|
|
122
|
+
manifestUrl: 'https://example.com/m',
|
|
123
|
+
npmExecutor: async (args) => { calls.push(args); return { exitCode: 0, stdout: '', stderr: '' }; },
|
|
124
|
+
restart: async () => undefined,
|
|
125
|
+
});
|
|
126
|
+
const result = await svc.apply();
|
|
127
|
+
expect(result).toMatchObject({ fromVersion: '0.6.0', toVersion: '0.7.0', restarted: true });
|
|
128
|
+
expect(calls).toEqual([['install', '-g', '@serfab/cadre-host@0.7.0']]);
|
|
129
|
+
const state = await svc.getState();
|
|
130
|
+
expect(state.applyInProgress).toBeUndefined();
|
|
131
|
+
expect(state.lastError).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('apply(): rejects when current is below minPreviousVersion', async () => {
|
|
135
|
+
const envelope = signManifestForTesting(manifest('0.8.0', { minPreviousVersion: '0.7.0' }), kp.privateKey);
|
|
136
|
+
const svc = new UpdateService({
|
|
137
|
+
dataDir: tmp,
|
|
138
|
+
currentVersion: '0.6.0',
|
|
139
|
+
fetcher: fakeFetch(envelope),
|
|
140
|
+
manifestUrl: 'https://example.com/m',
|
|
141
|
+
});
|
|
142
|
+
await expect(svc.apply()).rejects.toMatchObject({ code: 'min_previous_version' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('apply(): rejects when current >= latest', async () => {
|
|
146
|
+
const envelope = signManifestForTesting(manifest('0.6.0'), kp.privateKey);
|
|
147
|
+
const svc = new UpdateService({
|
|
148
|
+
dataDir: tmp,
|
|
149
|
+
currentVersion: '0.7.0',
|
|
150
|
+
fetcher: fakeFetch(envelope),
|
|
151
|
+
manifestUrl: 'https://example.com/m',
|
|
152
|
+
});
|
|
153
|
+
await expect(svc.apply()).rejects.toMatchObject({ code: 'no_update_available' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('apply(): records lastError + clears applyInProgress when npm install fails', async () => {
|
|
157
|
+
const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
|
|
158
|
+
let installCount = 0;
|
|
159
|
+
const svc = new UpdateService({
|
|
160
|
+
dataDir: tmp,
|
|
161
|
+
currentVersion: '0.6.0',
|
|
162
|
+
fetcher: fakeFetch(envelope),
|
|
163
|
+
manifestUrl: 'https://example.com/m',
|
|
164
|
+
npmExecutor: async () => {
|
|
165
|
+
installCount++;
|
|
166
|
+
return { exitCode: installCount === 1 ? 1 : 0, stdout: '', stderr: installCount === 1 ? 'EACCES' : '' };
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await expect(svc.apply()).rejects.toMatchObject({ code: 'apply_failed' });
|
|
170
|
+
const state = await svc.getState();
|
|
171
|
+
expect(state.applyInProgress).toBeUndefined();
|
|
172
|
+
expect(state.lastError?.code).toBe('apply_failed');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('settings: onSettingsChanged fires when putSettings mutates state', async () => {
|
|
176
|
+
const svc = new UpdateService({
|
|
177
|
+
dataDir: tmp,
|
|
178
|
+
currentVersion: '0.6.0',
|
|
179
|
+
fetcher: fakeFetch({}),
|
|
180
|
+
manifestUrl: 'https://example.com/m',
|
|
181
|
+
settings: { autoApply: false },
|
|
182
|
+
});
|
|
183
|
+
let observed: { autoApply: boolean } | null = null;
|
|
184
|
+
svc.onSettingsChanged((next) => { observed = { autoApply: next.autoApply }; });
|
|
185
|
+
await svc.putSettings({ autoApply: true });
|
|
186
|
+
expect(observed).toEqual({ autoApply: true });
|
|
187
|
+
expect(svc.getSettings().autoApply).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('autoApply: check() triggers apply when settings.autoApply is true', async () => {
|
|
191
|
+
const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
|
|
192
|
+
const calls: string[][] = [];
|
|
193
|
+
const svc = new UpdateService({
|
|
194
|
+
dataDir: tmp,
|
|
195
|
+
currentVersion: '0.6.0',
|
|
196
|
+
fetcher: fakeFetch(envelope),
|
|
197
|
+
manifestUrl: 'https://example.com/m',
|
|
198
|
+
settings: { autoApply: true },
|
|
199
|
+
npmExecutor: async (args) => { calls.push(args); return { exitCode: 0, stdout: '', stderr: '' }; },
|
|
200
|
+
restart: async () => undefined,
|
|
201
|
+
});
|
|
202
|
+
await svc.check();
|
|
203
|
+
expect(calls.some((c) => c[2] === '@serfab/cadre-host@0.7.0')).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('start()/stop(): timer can be armed and cleared', async () => {
|
|
207
|
+
const svc = new UpdateService({
|
|
208
|
+
dataDir: tmp,
|
|
209
|
+
currentVersion: '0.6.0',
|
|
210
|
+
fetcher: (async () => { throw new Error('no'); }) as typeof fetch,
|
|
211
|
+
manifestUrl: 'https://example.com/m',
|
|
212
|
+
checkIntervalMs: 1_000_000,
|
|
213
|
+
});
|
|
214
|
+
svc.start();
|
|
215
|
+
// Calling start a second time is a no-op (no thrown error).
|
|
216
|
+
svc.start();
|
|
217
|
+
svc.stop();
|
|
218
|
+
// Calling stop without an active timer is safe.
|
|
219
|
+
svc.stop();
|
|
220
|
+
});
|
|
221
|
+
});
|