@serfab/cadre-host 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +9 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/settings-store.d.ts +1 -1
  15. package/dist/server/settings-store.js +5 -1
  16. package/dist/server/settings-store.js.map +1 -1
  17. package/dist/server/static.js +38 -38
  18. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  19. package/dist/ui/index.html +12 -12
  20. package/package.json +4 -4
  21. package/service/cadre-host.service.tmpl +16 -16
  22. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  23. package/service/install-service.ps1 +98 -98
  24. package/service/uninstall-service.ps1 +19 -19
  25. package/src/__tests__/build-targets.test.ts +26 -26
  26. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  27. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  28. package/src/__tests__/cli.smoke.test.ts +51 -46
  29. package/src/__tests__/global-setup.ts +56 -51
  30. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  31. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  32. package/src/__tests__/orchestrator-node-identity.test.ts +5 -5
  33. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  34. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  35. package/src/__tests__/orchestrator-push.test.ts +145 -145
  36. package/src/__tests__/orchestrator.test.ts +951 -951
  37. package/src/auth/__tests__/duration.test.ts +28 -28
  38. package/src/auth/__tests__/self-label.test.ts +85 -85
  39. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  40. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  41. package/src/auth/duration.ts +33 -33
  42. package/src/auth/index.ts +21 -21
  43. package/src/auth/self-label.ts +74 -74
  44. package/src/auth/trust-circle-store.ts +155 -155
  45. package/src/auth/types.ts +99 -99
  46. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  47. package/src/bin/host.ts +1326 -1299
  48. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  49. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  50. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  51. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  52. package/src/donation/__tests__/grant-service.test.ts +213 -213
  53. package/src/donation/__tests__/grant-store.test.ts +113 -113
  54. package/src/donation/donation-service.ts +962 -962
  55. package/src/donation/donation-store.ts +171 -171
  56. package/src/donation/donation-supervisor.ts +388 -388
  57. package/src/donation/grant-service.ts +200 -200
  58. package/src/donation/grant-store.ts +152 -152
  59. package/src/donation/index.ts +64 -64
  60. package/src/donation/types.ts +243 -243
  61. package/src/installer/__tests__/config.test.ts +134 -112
  62. package/src/installer/__tests__/identity.test.ts +34 -34
  63. package/src/installer/__tests__/paths.test.ts +47 -47
  64. package/src/installer/__tests__/service-host.test.ts +62 -62
  65. package/src/installer/__tests__/wizard.test.ts +64 -64
  66. package/src/installer/config.ts +5 -43
  67. package/src/installer/identity.ts +56 -49
  68. package/src/installer/paths.ts +109 -109
  69. package/src/installer/platform.ts +27 -27
  70. package/src/installer/service-host/index.ts +21 -21
  71. package/src/installer/service-host/template.ts +21 -21
  72. package/src/installer/wizard.ts +103 -103
  73. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  74. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  75. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  76. package/src/nat/__tests__/external-ip.test.ts +124 -124
  77. package/src/nat/__tests__/nat-service.test.ts +18 -0
  78. package/src/nat/__tests__/nat-store.test.ts +93 -93
  79. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  80. package/src/nat/__tests__/reachability.test.ts +24 -24
  81. package/src/nat/__tests__/secrets.test.ts +178 -178
  82. package/src/nat/address-resolver.ts +51 -51
  83. package/src/nat/ddns/duckdns.ts +76 -76
  84. package/src/nat/ddns/index.ts +55 -55
  85. package/src/nat/ddns/updater.ts +243 -243
  86. package/src/nat/external-ip.ts +154 -154
  87. package/src/nat/index.ts +71 -71
  88. package/src/nat/nat-service.ts +501 -501
  89. package/src/nat/nat-store.ts +154 -154
  90. package/src/nat/port-mapper.ts +329 -329
  91. package/src/nat/reachability.ts +34 -34
  92. package/src/nat/secrets/file-store.ts +130 -130
  93. package/src/nat/secrets/index.ts +112 -112
  94. package/src/nat/secrets/keytar-store.ts +67 -67
  95. package/src/nat/types.ts +159 -159
  96. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  97. package/src/orchestrator/host-process-orchestrator.ts +9 -4
  98. package/src/orchestrator/index.ts +10 -10
  99. package/src/orchestrator/log-rotator.ts +53 -53
  100. package/src/orchestrator/node-identity.ts +40 -40
  101. package/src/orchestrator/pid-liveness.ts +24 -24
  102. package/src/orchestrator/port-allocator.ts +91 -91
  103. package/src/orchestrator/state-store.ts +90 -90
  104. package/src/orchestrator/types.ts +141 -141
  105. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  106. package/src/owner/index.ts +12 -12
  107. package/src/owner/owner-node-client.ts +259 -259
  108. package/src/push/__tests__/push.test.ts +90 -90
  109. package/src/push/index.ts +146 -146
  110. package/src/server/__tests__/bus.test.ts +38 -38
  111. package/src/server/__tests__/error-handler.test.ts +118 -118
  112. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  113. package/src/server/__tests__/grants-route.test.ts +263 -263
  114. package/src/server/__tests__/nodes-route.test.ts +259 -259
  115. package/src/server/__tests__/origin-guard.test.ts +72 -72
  116. package/src/server/__tests__/publishers.test.ts +216 -216
  117. package/src/server/__tests__/server.smoke.test.ts +253 -253
  118. package/src/server/__tests__/settings-route.test.ts +195 -194
  119. package/src/server/__tests__/sse-route.test.ts +119 -119
  120. package/src/server/__tests__/static.test.ts +145 -145
  121. package/src/server/__tests__/status-route.test.ts +134 -134
  122. package/src/server/__tests__/strands-route.test.ts +233 -233
  123. package/src/server/error-handler.ts +178 -178
  124. package/src/server/events/bus.ts +42 -42
  125. package/src/server/events/sse-route.ts +81 -81
  126. package/src/server/events/types.ts +23 -23
  127. package/src/server/index.ts +267 -267
  128. package/src/server/origin-guard.ts +84 -84
  129. package/src/server/routes/grants-admin.ts +41 -41
  130. package/src/server/routes/grants.ts +210 -210
  131. package/src/server/routes/nat.ts +63 -63
  132. package/src/server/routes/nodes.ts +170 -170
  133. package/src/server/routes/settings.ts +123 -123
  134. package/src/server/routes/status.ts +118 -118
  135. package/src/server/routes/strands.ts +54 -54
  136. package/src/server/routes/trust-circle.ts +49 -49
  137. package/src/server/routes/update.ts +43 -43
  138. package/src/server/server.ts +122 -122
  139. package/src/server/settings-store.ts +5 -1
  140. package/src/server/static.ts +207 -207
  141. package/src/strands/__tests__/strand-service.test.ts +165 -165
  142. package/src/strands/index.ts +14 -14
  143. package/src/strands/strand-service.ts +135 -135
  144. package/src/strands/types.ts +102 -102
  145. package/src/update/__tests__/apply.test.ts +158 -158
  146. package/src/update/__tests__/manifest.test.ts +128 -128
  147. package/src/update/__tests__/release-key.test.ts +42 -42
  148. package/src/update/__tests__/sign.test.ts +78 -78
  149. package/src/update/__tests__/store.test.ts +81 -81
  150. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  151. package/src/update/__tests__/version.test.ts +31 -31
  152. package/src/update/apply.ts +190 -190
  153. package/src/update/index.ts +341 -341
  154. package/src/update/release-key.ts +71 -71
  155. package/src/update/sign.ts +74 -74
  156. package/src/update/store.ts +101 -101
  157. package/src/update/types.ts +109 -109
  158. package/src/update/version.ts +80 -80
@@ -1,102 +1,102 @@
1
- /**
2
- * Strand-management types for cadre-host.
3
- *
4
- * A *strand* is a shared data network this party participates in. Membership is
5
- * canonical in the cadre control network's `Strand` table, reached over the owner
6
- * node's loopback admin channel (`GET /admin/strands`,
7
- * `DELETE /admin/strands/:id?confirm=1`).
8
- *
9
- * Unlike the trust circle — which layers host-local labels and pending invites on
10
- * top of the node's state — this layer holds **no host-local state at all**. There
11
- * is no `strands.json`. It exists for id validation, for translating the admin
12
- * channel's error codes into a stable vocabulary the management API can map to HTTP
13
- * statuses, and for nothing else. Keep it small.
14
- */
15
-
16
- /** One strand this party belongs to, as projected by the admin channel. */
17
- export interface StrandSummary {
18
- /** The `Strand` row id — what a removal takes. */
19
- id: string;
20
- /** `'o'` = open, `'c'` = closed (the row carries this party's membership key). */
21
- type: 'o' | 'c';
22
- /** Whether the owner node currently has a running instance for the id. */
23
- running: boolean;
24
- /**
25
- * The instance's status when running, else `null`. Deliberately `string` rather
26
- * than cadre-core's `StrandStatus`: this is a wire projection the manager only
27
- * forwards, and widening it keeps cadre-host from tracking a cadre-core union it
28
- * never inspects.
29
- */
30
- status: string | null;
31
- }
32
-
33
- /** The result of listing this party's strands. */
34
- export interface StrandListSnapshot {
35
- strands: StrandSummary[];
36
- /**
37
- * Open control-network connections on the owner node; 0 ⇒ writes commit
38
- * local-only. A snapshot at read time, not a subscription — treat it as advisory.
39
- */
40
- controlConnections: number;
41
- }
42
-
43
- /** The result of removing this party's participation in one strand. */
44
- export interface StrandRemovalResult {
45
- /** The trimmed id the call was about. */
46
- strandId: string;
47
- /** Whether a row existed before the write. */
48
- published: boolean;
49
- /** The found row's type; `null` when no row was found. */
50
- type: 'o' | 'c' | null;
51
- /**
52
- * Whether THIS call issued the delete — not that the row was observed to vanish.
53
- * The node's read and write are not atomic, so a concurrent removal landing in
54
- * between makes this a no-op that still reports `true`.
55
- */
56
- removed: boolean;
57
- /**
58
- * The node saw 0 control connections at the end of the call. Sampled on EVERY
59
- * call, including one that found no row and wrote nothing — read it as "this
60
- * machine currently sees no siblings", and only as "your delete may not have
61
- * travelled" when `removed` is also true.
62
- */
63
- alone: boolean;
64
- }
65
-
66
- /** Error codes thrown by StrandService. */
67
- export type StrandErrorCode =
68
- /** The id was blank or whitespace-only, here or at the node. */
69
- | 'invalid_id'
70
- /** The node refused a closed-strand removal that carried no confirmation. */
71
- | 'confirmation_required'
72
- /** The owner node's admin channel is unreachable / not ready. */
73
- | 'node_unavailable'
74
- /** The node answered with a code this layer has no better word for. */
75
- | 'internal';
76
-
77
- /** Typed error carrying a stable `code` for HTTP mapping. */
78
- export class StrandError extends Error {
79
- readonly code: StrandErrorCode;
80
-
81
- constructor(code: StrandErrorCode, message: string) {
82
- super(message);
83
- this.name = 'StrandError';
84
- this.code = code;
85
- }
86
- }
87
-
88
- /**
89
- * Typed handlers exposed to the local UI server for Fastify wiring.
90
- *
91
- * Handlers either return typed results or throw a `StrandError` whose `.code`
92
- * `server/error-handler.ts` maps to an HTTP status:
93
- *
94
- * - `invalid_id` → 400
95
- * - `confirmation_required` → 428
96
- * - `node_unavailable` → 503
97
- * - `internal` → 500
98
- */
99
- export interface StrandHandlers {
100
- listStrands(): Promise<StrandListSnapshot>;
101
- removeStrand(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult>;
102
- }
1
+ /**
2
+ * Strand-management types for cadre-host.
3
+ *
4
+ * A *strand* is a shared data network this party participates in. Membership is
5
+ * canonical in the cadre control network's `Strand` table, reached over the owner
6
+ * node's loopback admin channel (`GET /admin/strands`,
7
+ * `DELETE /admin/strands/:id?confirm=1`).
8
+ *
9
+ * Unlike the trust circle — which layers host-local labels and pending invites on
10
+ * top of the node's state — this layer holds **no host-local state at all**. There
11
+ * is no `strands.json`. It exists for id validation, for translating the admin
12
+ * channel's error codes into a stable vocabulary the management API can map to HTTP
13
+ * statuses, and for nothing else. Keep it small.
14
+ */
15
+
16
+ /** One strand this party belongs to, as projected by the admin channel. */
17
+ export interface StrandSummary {
18
+ /** The `Strand` row id — what a removal takes. */
19
+ id: string;
20
+ /** `'o'` = open, `'c'` = closed (the row carries this party's membership key). */
21
+ type: 'o' | 'c';
22
+ /** Whether the owner node currently has a running instance for the id. */
23
+ running: boolean;
24
+ /**
25
+ * The instance's status when running, else `null`. Deliberately `string` rather
26
+ * than cadre-core's `StrandStatus`: this is a wire projection the manager only
27
+ * forwards, and widening it keeps cadre-host from tracking a cadre-core union it
28
+ * never inspects.
29
+ */
30
+ status: string | null;
31
+ }
32
+
33
+ /** The result of listing this party's strands. */
34
+ export interface StrandListSnapshot {
35
+ strands: StrandSummary[];
36
+ /**
37
+ * Open control-network connections on the owner node; 0 ⇒ writes commit
38
+ * local-only. A snapshot at read time, not a subscription — treat it as advisory.
39
+ */
40
+ controlConnections: number;
41
+ }
42
+
43
+ /** The result of removing this party's participation in one strand. */
44
+ export interface StrandRemovalResult {
45
+ /** The trimmed id the call was about. */
46
+ strandId: string;
47
+ /** Whether a row existed before the write. */
48
+ published: boolean;
49
+ /** The found row's type; `null` when no row was found. */
50
+ type: 'o' | 'c' | null;
51
+ /**
52
+ * Whether THIS call issued the delete — not that the row was observed to vanish.
53
+ * The node's read and write are not atomic, so a concurrent removal landing in
54
+ * between makes this a no-op that still reports `true`.
55
+ */
56
+ removed: boolean;
57
+ /**
58
+ * The node saw 0 control connections at the end of the call. Sampled on EVERY
59
+ * call, including one that found no row and wrote nothing — read it as "this
60
+ * machine currently sees no siblings", and only as "your delete may not have
61
+ * travelled" when `removed` is also true.
62
+ */
63
+ alone: boolean;
64
+ }
65
+
66
+ /** Error codes thrown by StrandService. */
67
+ export type StrandErrorCode =
68
+ /** The id was blank or whitespace-only, here or at the node. */
69
+ | 'invalid_id'
70
+ /** The node refused a closed-strand removal that carried no confirmation. */
71
+ | 'confirmation_required'
72
+ /** The owner node's admin channel is unreachable / not ready. */
73
+ | 'node_unavailable'
74
+ /** The node answered with a code this layer has no better word for. */
75
+ | 'internal';
76
+
77
+ /** Typed error carrying a stable `code` for HTTP mapping. */
78
+ export class StrandError extends Error {
79
+ readonly code: StrandErrorCode;
80
+
81
+ constructor(code: StrandErrorCode, message: string) {
82
+ super(message);
83
+ this.name = 'StrandError';
84
+ this.code = code;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Typed handlers exposed to the local UI server for Fastify wiring.
90
+ *
91
+ * Handlers either return typed results or throw a `StrandError` whose `.code`
92
+ * `server/error-handler.ts` maps to an HTTP status:
93
+ *
94
+ * - `invalid_id` → 400
95
+ * - `confirmation_required` → 428
96
+ * - `node_unavailable` → 503
97
+ * - `internal` → 500
98
+ */
99
+ export interface StrandHandlers {
100
+ listStrands(): Promise<StrandListSnapshot>;
101
+ removeStrand(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult>;
102
+ }
@@ -1,158 +1,158 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { applyUpdate, buildNpmSpawnSpec, quoteWindowsArg, 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
- });
102
-
103
- describe('quoteWindowsArg', () => {
104
- it('leaves safe args untouched', () => {
105
- expect(quoteWindowsArg('install')).toBe('install');
106
- expect(quoteWindowsArg('-g')).toBe('-g');
107
- expect(quoteWindowsArg('@serfab/cadre-host@0.7.0')).toBe('@serfab/cadre-host@0.7.0');
108
- });
109
-
110
- it('quotes args containing whitespace or shell metachars', () => {
111
- expect(quoteWindowsArg('has space')).toBe('"has space"');
112
- expect(quoteWindowsArg('with&amp')).toBe('"with&amp"');
113
- expect(quoteWindowsArg('pipe|me')).toBe('"pipe|me"');
114
- });
115
-
116
- it('escapes embedded quotes and trailing backslashes', () => {
117
- expect(quoteWindowsArg('with"quote')).toBe('"with\\"quote"');
118
- expect(quoteWindowsArg('trailing\\')).toBe('trailing\\');
119
- expect(quoteWindowsArg('trail ing\\')).toBe('"trail ing\\\\"');
120
- });
121
-
122
- it('rejects control characters', () => {
123
- expect(() => quoteWindowsArg('bad\nnewline')).toThrow(/control character/);
124
- expect(() => quoteWindowsArg('bad\x00null')).toThrow(/control character/);
125
- });
126
- });
127
-
128
- describe('buildNpmSpawnSpec', () => {
129
- it('builds a single-string command with empty args on Windows', () => {
130
- const original = process.platform;
131
- Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
132
- try {
133
- const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
134
- expect(spec).toEqual({
135
- command: 'npm install -g @serfab/cadre-host@0.7.0',
136
- args: [],
137
- shell: true,
138
- });
139
- } finally {
140
- Object.defineProperty(process, 'platform', { value: original, configurable: true });
141
- }
142
- });
143
-
144
- it('passes args through verbatim on posix (no shell)', () => {
145
- const original = process.platform;
146
- Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
147
- try {
148
- const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
149
- expect(spec).toEqual({
150
- command: 'npm',
151
- args: ['install', '-g', '@serfab/cadre-host@0.7.0'],
152
- shell: false,
153
- });
154
- } finally {
155
- Object.defineProperty(process, 'platform', { value: original, configurable: true });
156
- }
157
- });
158
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { applyUpdate, buildNpmSpawnSpec, quoteWindowsArg, 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
+ });
102
+
103
+ describe('quoteWindowsArg', () => {
104
+ it('leaves safe args untouched', () => {
105
+ expect(quoteWindowsArg('install')).toBe('install');
106
+ expect(quoteWindowsArg('-g')).toBe('-g');
107
+ expect(quoteWindowsArg('@serfab/cadre-host@0.7.0')).toBe('@serfab/cadre-host@0.7.0');
108
+ });
109
+
110
+ it('quotes args containing whitespace or shell metachars', () => {
111
+ expect(quoteWindowsArg('has space')).toBe('"has space"');
112
+ expect(quoteWindowsArg('with&amp')).toBe('"with&amp"');
113
+ expect(quoteWindowsArg('pipe|me')).toBe('"pipe|me"');
114
+ });
115
+
116
+ it('escapes embedded quotes and trailing backslashes', () => {
117
+ expect(quoteWindowsArg('with"quote')).toBe('"with\\"quote"');
118
+ expect(quoteWindowsArg('trailing\\')).toBe('trailing\\');
119
+ expect(quoteWindowsArg('trail ing\\')).toBe('"trail ing\\\\"');
120
+ });
121
+
122
+ it('rejects control characters', () => {
123
+ expect(() => quoteWindowsArg('bad\nnewline')).toThrow(/control character/);
124
+ expect(() => quoteWindowsArg('bad\x00null')).toThrow(/control character/);
125
+ });
126
+ });
127
+
128
+ describe('buildNpmSpawnSpec', () => {
129
+ it('builds a single-string command with empty args on Windows', () => {
130
+ const original = process.platform;
131
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
132
+ try {
133
+ const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
134
+ expect(spec).toEqual({
135
+ command: 'npm install -g @serfab/cadre-host@0.7.0',
136
+ args: [],
137
+ shell: true,
138
+ });
139
+ } finally {
140
+ Object.defineProperty(process, 'platform', { value: original, configurable: true });
141
+ }
142
+ });
143
+
144
+ it('passes args through verbatim on posix (no shell)', () => {
145
+ const original = process.platform;
146
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
147
+ try {
148
+ const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
149
+ expect(spec).toEqual({
150
+ command: 'npm',
151
+ args: ['install', '-g', '@serfab/cadre-host@0.7.0'],
152
+ shell: false,
153
+ });
154
+ } finally {
155
+ Object.defineProperty(process, 'platform', { value: original, configurable: true });
156
+ }
157
+ });
158
+ });