@serfab/cadre-host 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/server/static.js +38 -38
  2. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  3. package/dist/ui/index.html +12 -12
  4. package/package.json +4 -4
  5. package/service/cadre-host.service.tmpl +16 -16
  6. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  7. package/service/install-service.ps1 +98 -98
  8. package/service/uninstall-service.ps1 +19 -19
  9. package/src/__tests__/build-targets.test.ts +26 -26
  10. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  11. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  12. package/src/__tests__/cli.smoke.test.ts +46 -46
  13. package/src/__tests__/global-setup.ts +51 -51
  14. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  15. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  16. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  17. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  18. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  19. package/src/__tests__/orchestrator-push.test.ts +145 -145
  20. package/src/__tests__/orchestrator.test.ts +951 -951
  21. package/src/auth/__tests__/duration.test.ts +28 -28
  22. package/src/auth/__tests__/self-label.test.ts +85 -85
  23. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  24. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  25. package/src/auth/duration.ts +33 -33
  26. package/src/auth/index.ts +21 -21
  27. package/src/auth/self-label.ts +74 -74
  28. package/src/auth/trust-circle-store.ts +155 -155
  29. package/src/auth/types.ts +99 -99
  30. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  31. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  32. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  33. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  34. package/src/donation/__tests__/grant-service.test.ts +213 -213
  35. package/src/donation/__tests__/grant-store.test.ts +113 -113
  36. package/src/donation/donation-service.ts +962 -962
  37. package/src/donation/donation-store.ts +171 -171
  38. package/src/donation/donation-supervisor.ts +388 -388
  39. package/src/donation/grant-service.ts +200 -200
  40. package/src/donation/grant-store.ts +152 -152
  41. package/src/donation/index.ts +64 -64
  42. package/src/donation/types.ts +243 -243
  43. package/src/installer/__tests__/identity.test.ts +34 -34
  44. package/src/installer/__tests__/paths.test.ts +47 -47
  45. package/src/installer/__tests__/service-host.test.ts +62 -62
  46. package/src/installer/__tests__/wizard.test.ts +64 -64
  47. package/src/installer/identity.ts +49 -49
  48. package/src/installer/paths.ts +109 -109
  49. package/src/installer/platform.ts +27 -27
  50. package/src/installer/service-host/index.ts +21 -21
  51. package/src/installer/service-host/template.ts +21 -21
  52. package/src/installer/wizard.ts +103 -103
  53. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  54. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  55. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  56. package/src/nat/__tests__/external-ip.test.ts +124 -124
  57. package/src/nat/__tests__/nat-service.test.ts +497 -497
  58. package/src/nat/__tests__/nat-store.test.ts +93 -93
  59. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  60. package/src/nat/__tests__/reachability.test.ts +24 -24
  61. package/src/nat/__tests__/secrets.test.ts +178 -178
  62. package/src/nat/address-resolver.ts +51 -51
  63. package/src/nat/ddns/duckdns.ts +76 -76
  64. package/src/nat/ddns/index.ts +55 -55
  65. package/src/nat/ddns/updater.ts +243 -243
  66. package/src/nat/external-ip.ts +154 -154
  67. package/src/nat/index.ts +71 -71
  68. package/src/nat/nat-service.ts +501 -501
  69. package/src/nat/nat-store.ts +154 -154
  70. package/src/nat/port-mapper.ts +329 -329
  71. package/src/nat/reachability.ts +34 -34
  72. package/src/nat/secrets/file-store.ts +130 -130
  73. package/src/nat/secrets/index.ts +112 -112
  74. package/src/nat/secrets/keytar-store.ts +67 -67
  75. package/src/nat/types.ts +159 -159
  76. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  77. package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
  78. package/src/orchestrator/index.ts +10 -10
  79. package/src/orchestrator/log-rotator.ts +53 -53
  80. package/src/orchestrator/node-identity.ts +40 -40
  81. package/src/orchestrator/pid-liveness.ts +24 -24
  82. package/src/orchestrator/port-allocator.ts +91 -91
  83. package/src/orchestrator/state-store.ts +90 -90
  84. package/src/orchestrator/types.ts +141 -141
  85. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  86. package/src/owner/index.ts +12 -12
  87. package/src/owner/owner-node-client.ts +259 -259
  88. package/src/push/__tests__/push.test.ts +90 -90
  89. package/src/push/index.ts +146 -146
  90. package/src/server/__tests__/bus.test.ts +38 -38
  91. package/src/server/__tests__/error-handler.test.ts +118 -118
  92. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  93. package/src/server/__tests__/grants-route.test.ts +263 -263
  94. package/src/server/__tests__/nodes-route.test.ts +259 -259
  95. package/src/server/__tests__/origin-guard.test.ts +72 -72
  96. package/src/server/__tests__/publishers.test.ts +216 -216
  97. package/src/server/__tests__/server.smoke.test.ts +253 -253
  98. package/src/server/__tests__/settings-route.test.ts +194 -194
  99. package/src/server/__tests__/sse-route.test.ts +119 -119
  100. package/src/server/__tests__/static.test.ts +145 -145
  101. package/src/server/__tests__/status-route.test.ts +134 -134
  102. package/src/server/__tests__/strands-route.test.ts +233 -233
  103. package/src/server/error-handler.ts +178 -178
  104. package/src/server/events/bus.ts +42 -42
  105. package/src/server/events/sse-route.ts +81 -81
  106. package/src/server/events/types.ts +23 -23
  107. package/src/server/index.ts +267 -267
  108. package/src/server/origin-guard.ts +84 -84
  109. package/src/server/routes/grants-admin.ts +41 -41
  110. package/src/server/routes/grants.ts +210 -210
  111. package/src/server/routes/nat.ts +63 -63
  112. package/src/server/routes/nodes.ts +170 -170
  113. package/src/server/routes/settings.ts +123 -123
  114. package/src/server/routes/status.ts +118 -118
  115. package/src/server/routes/strands.ts +54 -54
  116. package/src/server/routes/trust-circle.ts +49 -49
  117. package/src/server/routes/update.ts +43 -43
  118. package/src/server/server.ts +122 -122
  119. package/src/server/settings-store.ts +37 -37
  120. package/src/server/static.ts +207 -207
  121. package/src/strands/__tests__/strand-service.test.ts +165 -165
  122. package/src/strands/index.ts +14 -14
  123. package/src/strands/strand-service.ts +135 -135
  124. package/src/strands/types.ts +102 -102
  125. package/src/update/__tests__/apply.test.ts +158 -158
  126. package/src/update/__tests__/manifest.test.ts +128 -128
  127. package/src/update/__tests__/release-key.test.ts +42 -42
  128. package/src/update/__tests__/sign.test.ts +78 -78
  129. package/src/update/__tests__/store.test.ts +81 -81
  130. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  131. package/src/update/__tests__/version.test.ts +31 -31
  132. package/src/update/apply.ts +190 -190
  133. package/src/update/index.ts +341 -341
  134. package/src/update/release-key.ts +71 -71
  135. package/src/update/sign.ts +74 -74
  136. package/src/update/store.ts +101 -101
  137. package/src/update/types.ts +109 -109
  138. package/src/update/version.ts +80 -80
@@ -1,190 +1,190 @@
1
- /**
2
- * Apply an update.
3
- *
4
- * Apply == reinstall the package globally via `npm install -g <pkg>@<ver>`.
5
- * Rollback == reinstall the previously-recorded version. Both are dispatched
6
- * through an injectable `NpmExecutor` so tests don't shell out.
7
- *
8
- * Service-host restart is best-effort: the binary swap succeeded even if
9
- * `systemctl/launchctl/nssm restart` returned non-zero, so the state stays
10
- * successful and the caller surfaces a warning. (User can restart by hand.)
11
- */
12
-
13
- import { spawn } from 'node:child_process';
14
- import debug from 'debug';
15
-
16
- import {
17
- UpdateErrorException,
18
- type UpdateApplyResult,
19
- } from './types.js';
20
-
21
- const log = debug('cadre:host:update-apply');
22
-
23
- const DEFAULT_NPM_TIMEOUT_MS = 5 * 60 * 1000;
24
-
25
- export interface NpmResult {
26
- exitCode: number;
27
- stdout: string;
28
- stderr: string;
29
- }
30
-
31
- /** Pluggable npm runner — replaced in tests. */
32
- export type NpmExecutor = (args: string[]) => Promise<NpmResult>;
33
-
34
- /** Optional service-host restart hook. Returns undefined on success, an error message on failure. */
35
- export type ServiceRestarter = () => Promise<string | undefined>;
36
-
37
- export interface ApplyOptions {
38
- npmPackage: string;
39
- fromVersion: string;
40
- toVersion: string;
41
- npmExecutor?: NpmExecutor;
42
- restart?: ServiceRestarter;
43
- timeoutMs?: number;
44
- }
45
-
46
- /**
47
- * Drive the install + restart. Throws `UpdateErrorException('apply_failed')`
48
- * if the install itself fails (after attempting rollback). On install
49
- * success but restart failure, returns a result with the restart error set.
50
- */
51
- export async function applyUpdate(opts: ApplyOptions): Promise<UpdateApplyResult> {
52
- const executor = opts.npmExecutor ?? defaultNpmExecutor(opts.timeoutMs ?? DEFAULT_NPM_TIMEOUT_MS);
53
-
54
- const installArgs = ['install', '-g', `${opts.npmPackage}@${opts.toVersion}`];
55
- let installResult: NpmResult;
56
- try {
57
- installResult = await executor(installArgs);
58
- } catch (err) {
59
- await tryRollback(opts.npmPackage, opts.fromVersion, executor);
60
- throw new UpdateErrorException(
61
- 'apply_failed',
62
- `npm install threw: ${(err as Error).message}`,
63
- );
64
- }
65
- if (installResult.exitCode !== 0) {
66
- const stderrTail = tail(installResult.stderr || installResult.stdout, 400);
67
- const rollbackErr = await tryRollback(opts.npmPackage, opts.fromVersion, executor);
68
- if (rollbackErr) {
69
- throw new UpdateErrorException(
70
- 'rollback_failed',
71
- `npm install failed (exit ${installResult.exitCode}); rollback also failed: ${rollbackErr}. Original error: ${stderrTail}`,
72
- );
73
- }
74
- throw new UpdateErrorException(
75
- 'apply_failed',
76
- `npm install failed (exit ${installResult.exitCode}): ${stderrTail}`,
77
- );
78
- }
79
- log('npm install -g %s@%s succeeded', opts.npmPackage, opts.toVersion);
80
-
81
- let restartError: string | undefined;
82
- if (opts.restart) {
83
- try {
84
- restartError = await opts.restart();
85
- } catch (err) {
86
- restartError = (err as Error).message;
87
- }
88
- if (restartError) {
89
- log('service restart failed: %s', restartError);
90
- }
91
- }
92
-
93
- return {
94
- fromVersion: opts.fromVersion,
95
- toVersion: opts.toVersion,
96
- restarted: opts.restart !== undefined && restartError === undefined,
97
- ...(restartError ? { restartError } : {}),
98
- };
99
- }
100
-
101
- /** Best-effort rollback. Returns an error string if the rollback itself failed. */
102
- async function tryRollback(
103
- pkg: string,
104
- fromVersion: string,
105
- executor: NpmExecutor,
106
- ): Promise<string | undefined> {
107
- log('rolling back to %s@%s', pkg, fromVersion);
108
- try {
109
- const rollback = await executor(['install', '-g', `${pkg}@${fromVersion}`]);
110
- if (rollback.exitCode !== 0) {
111
- return `npm install exit ${rollback.exitCode}: ${tail(rollback.stderr || rollback.stdout, 400)}`;
112
- }
113
- return undefined;
114
- } catch (err) {
115
- return `npm install threw: ${(err as Error).message}`;
116
- }
117
- }
118
-
119
- /**
120
- * Default executor: spawn `npm` with the given args. Adds a hard timeout.
121
- *
122
- * On Windows, npm ships as `npm.cmd` (a batch file). CreateProcess can't run
123
- * batch files directly, so a shell is required — but Node's DEP0190 warns
124
- * against `spawn(cmd, args, { shell: true })` because args are concatenated
125
- * (not escaped) into the resulting command line. To dodge the deprecation we
126
- * pre-quote each arg ourselves and pass the result as a single command string
127
- * with an empty args array. Inputs come from a signed manifest after
128
- * regex-validated package + semver checks, so injection surface is minimal;
129
- * quoting is defense in depth.
130
- */
131
- export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
132
- return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
133
- const spawnSpec = buildNpmSpawnSpec(args);
134
- const child = spawn(spawnSpec.command, spawnSpec.args, {
135
- shell: spawnSpec.shell,
136
- windowsHide: true,
137
- });
138
- let stdout = '';
139
- let stderr = '';
140
- child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
141
- child.stderr?.on('data', (chunk: Buffer | string) => { stderr += String(chunk); });
142
-
143
- const timer = setTimeout(() => {
144
- stderr += `\n[cadre-host] npm install timed out after ${timeoutMs}ms, killing`;
145
- child.kill('SIGTERM');
146
- }, timeoutMs);
147
-
148
- child.on('error', (err) => {
149
- clearTimeout(timer);
150
- resolveExec({ exitCode: 127, stdout, stderr: stderr + `\nspawn failed: ${err.message}` });
151
- });
152
- child.on('close', (code) => {
153
- clearTimeout(timer);
154
- resolveExec({ exitCode: code ?? -1, stdout, stderr });
155
- });
156
- });
157
- }
158
-
159
- /**
160
- * Build the `(command, args, shell)` triple passed to `spawn`. Exported for
161
- * tests — the spawn shape differs per platform and the quoting matters.
162
- */
163
- export function buildNpmSpawnSpec(args: string[]): { command: string; args: string[]; shell: boolean } {
164
- if (process.platform === 'win32') {
165
- const commandLine = ['npm', ...args.map(quoteWindowsArg)].join(' ');
166
- return { command: commandLine, args: [], shell: true };
167
- }
168
- return { command: 'npm', args, shell: false };
169
- }
170
-
171
- /** cmd.exe quoting — wrap args containing whitespace or shell metachars. */
172
- export function quoteWindowsArg(arg: string): string {
173
- // \x00 is an intentional security guard: reject NUL/CR/LF in args before they
174
- // reach the cmd.exe command line. The control char is the point of the check.
175
- // eslint-disable-next-line no-control-regex
176
- if (/[\x00\r\n]/.test(arg)) {
177
- throw new Error('refusing to spawn npm with control character in argument');
178
- }
179
- if (arg.length === 0) return '""';
180
- if (!/[\s"&|<>^()%!]/.test(arg)) return arg;
181
- // Escape backslashes preceding a quote per MSVCRT/cmd rules, then double the
182
- // quote itself (the cmd-friendly form rather than backslash-quote).
183
- const escaped = arg.replace(/(\\*)"/g, (_m, slashes: string) => slashes + slashes + '\\"');
184
- const trailing = escaped.replace(/(\\*)$/, (_m, slashes: string) => slashes + slashes);
185
- return `"${trailing}"`;
186
- }
187
-
188
- function tail(s: string, n: number): string {
189
- return s.length <= n ? s : '…' + s.slice(-n);
190
- }
1
+ /**
2
+ * Apply an update.
3
+ *
4
+ * Apply == reinstall the package globally via `npm install -g <pkg>@<ver>`.
5
+ * Rollback == reinstall the previously-recorded version. Both are dispatched
6
+ * through an injectable `NpmExecutor` so tests don't shell out.
7
+ *
8
+ * Service-host restart is best-effort: the binary swap succeeded even if
9
+ * `systemctl/launchctl/nssm restart` returned non-zero, so the state stays
10
+ * successful and the caller surfaces a warning. (User can restart by hand.)
11
+ */
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import debug from 'debug';
15
+
16
+ import {
17
+ UpdateErrorException,
18
+ type UpdateApplyResult,
19
+ } from './types.js';
20
+
21
+ const log = debug('cadre:host:update-apply');
22
+
23
+ const DEFAULT_NPM_TIMEOUT_MS = 5 * 60 * 1000;
24
+
25
+ export interface NpmResult {
26
+ exitCode: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ }
30
+
31
+ /** Pluggable npm runner — replaced in tests. */
32
+ export type NpmExecutor = (args: string[]) => Promise<NpmResult>;
33
+
34
+ /** Optional service-host restart hook. Returns undefined on success, an error message on failure. */
35
+ export type ServiceRestarter = () => Promise<string | undefined>;
36
+
37
+ export interface ApplyOptions {
38
+ npmPackage: string;
39
+ fromVersion: string;
40
+ toVersion: string;
41
+ npmExecutor?: NpmExecutor;
42
+ restart?: ServiceRestarter;
43
+ timeoutMs?: number;
44
+ }
45
+
46
+ /**
47
+ * Drive the install + restart. Throws `UpdateErrorException('apply_failed')`
48
+ * if the install itself fails (after attempting rollback). On install
49
+ * success but restart failure, returns a result with the restart error set.
50
+ */
51
+ export async function applyUpdate(opts: ApplyOptions): Promise<UpdateApplyResult> {
52
+ const executor = opts.npmExecutor ?? defaultNpmExecutor(opts.timeoutMs ?? DEFAULT_NPM_TIMEOUT_MS);
53
+
54
+ const installArgs = ['install', '-g', `${opts.npmPackage}@${opts.toVersion}`];
55
+ let installResult: NpmResult;
56
+ try {
57
+ installResult = await executor(installArgs);
58
+ } catch (err) {
59
+ await tryRollback(opts.npmPackage, opts.fromVersion, executor);
60
+ throw new UpdateErrorException(
61
+ 'apply_failed',
62
+ `npm install threw: ${(err as Error).message}`,
63
+ );
64
+ }
65
+ if (installResult.exitCode !== 0) {
66
+ const stderrTail = tail(installResult.stderr || installResult.stdout, 400);
67
+ const rollbackErr = await tryRollback(opts.npmPackage, opts.fromVersion, executor);
68
+ if (rollbackErr) {
69
+ throw new UpdateErrorException(
70
+ 'rollback_failed',
71
+ `npm install failed (exit ${installResult.exitCode}); rollback also failed: ${rollbackErr}. Original error: ${stderrTail}`,
72
+ );
73
+ }
74
+ throw new UpdateErrorException(
75
+ 'apply_failed',
76
+ `npm install failed (exit ${installResult.exitCode}): ${stderrTail}`,
77
+ );
78
+ }
79
+ log('npm install -g %s@%s succeeded', opts.npmPackage, opts.toVersion);
80
+
81
+ let restartError: string | undefined;
82
+ if (opts.restart) {
83
+ try {
84
+ restartError = await opts.restart();
85
+ } catch (err) {
86
+ restartError = (err as Error).message;
87
+ }
88
+ if (restartError) {
89
+ log('service restart failed: %s', restartError);
90
+ }
91
+ }
92
+
93
+ return {
94
+ fromVersion: opts.fromVersion,
95
+ toVersion: opts.toVersion,
96
+ restarted: opts.restart !== undefined && restartError === undefined,
97
+ ...(restartError ? { restartError } : {}),
98
+ };
99
+ }
100
+
101
+ /** Best-effort rollback. Returns an error string if the rollback itself failed. */
102
+ async function tryRollback(
103
+ pkg: string,
104
+ fromVersion: string,
105
+ executor: NpmExecutor,
106
+ ): Promise<string | undefined> {
107
+ log('rolling back to %s@%s', pkg, fromVersion);
108
+ try {
109
+ const rollback = await executor(['install', '-g', `${pkg}@${fromVersion}`]);
110
+ if (rollback.exitCode !== 0) {
111
+ return `npm install exit ${rollback.exitCode}: ${tail(rollback.stderr || rollback.stdout, 400)}`;
112
+ }
113
+ return undefined;
114
+ } catch (err) {
115
+ return `npm install threw: ${(err as Error).message}`;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Default executor: spawn `npm` with the given args. Adds a hard timeout.
121
+ *
122
+ * On Windows, npm ships as `npm.cmd` (a batch file). CreateProcess can't run
123
+ * batch files directly, so a shell is required — but Node's DEP0190 warns
124
+ * against `spawn(cmd, args, { shell: true })` because args are concatenated
125
+ * (not escaped) into the resulting command line. To dodge the deprecation we
126
+ * pre-quote each arg ourselves and pass the result as a single command string
127
+ * with an empty args array. Inputs come from a signed manifest after
128
+ * regex-validated package + semver checks, so injection surface is minimal;
129
+ * quoting is defense in depth.
130
+ */
131
+ export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
132
+ return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
133
+ const spawnSpec = buildNpmSpawnSpec(args);
134
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
135
+ shell: spawnSpec.shell,
136
+ windowsHide: true,
137
+ });
138
+ let stdout = '';
139
+ let stderr = '';
140
+ child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
141
+ child.stderr?.on('data', (chunk: Buffer | string) => { stderr += String(chunk); });
142
+
143
+ const timer = setTimeout(() => {
144
+ stderr += `\n[cadre-host] npm install timed out after ${timeoutMs}ms, killing`;
145
+ child.kill('SIGTERM');
146
+ }, timeoutMs);
147
+
148
+ child.on('error', (err) => {
149
+ clearTimeout(timer);
150
+ resolveExec({ exitCode: 127, stdout, stderr: stderr + `\nspawn failed: ${err.message}` });
151
+ });
152
+ child.on('close', (code) => {
153
+ clearTimeout(timer);
154
+ resolveExec({ exitCode: code ?? -1, stdout, stderr });
155
+ });
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Build the `(command, args, shell)` triple passed to `spawn`. Exported for
161
+ * tests — the spawn shape differs per platform and the quoting matters.
162
+ */
163
+ export function buildNpmSpawnSpec(args: string[]): { command: string; args: string[]; shell: boolean } {
164
+ if (process.platform === 'win32') {
165
+ const commandLine = ['npm', ...args.map(quoteWindowsArg)].join(' ');
166
+ return { command: commandLine, args: [], shell: true };
167
+ }
168
+ return { command: 'npm', args, shell: false };
169
+ }
170
+
171
+ /** cmd.exe quoting — wrap args containing whitespace or shell metachars. */
172
+ export function quoteWindowsArg(arg: string): string {
173
+ // \x00 is an intentional security guard: reject NUL/CR/LF in args before they
174
+ // reach the cmd.exe command line. The control char is the point of the check.
175
+ // eslint-disable-next-line no-control-regex
176
+ if (/[\x00\r\n]/.test(arg)) {
177
+ throw new Error('refusing to spawn npm with control character in argument');
178
+ }
179
+ if (arg.length === 0) return '""';
180
+ if (!/[\s"&|<>^()%!]/.test(arg)) return arg;
181
+ // Escape backslashes preceding a quote per MSVCRT/cmd rules, then double the
182
+ // quote itself (the cmd-friendly form rather than backslash-quote).
183
+ const escaped = arg.replace(/(\\*)"/g, (_m, slashes: string) => slashes + slashes + '\\"');
184
+ const trailing = escaped.replace(/(\\*)$/, (_m, slashes: string) => slashes + slashes);
185
+ return `"${trailing}"`;
186
+ }
187
+
188
+ function tail(s: string, n: number): string {
189
+ return s.length <= n ? s : '…' + s.slice(-n);
190
+ }