arkgate 4.6.6 → 4.6.7

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.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Host PreToolUse payload mapping (Claude/Grok/Cursor/Antigravity/Codex).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Map Google Antigravity write tools (PascalCase args) onto Claude Write/Edit/MultiEdit.
9
+ * @returns {{ toolName: string, toolInput: object }|null}
10
+ */
11
+ export function mapAntigravityToolCall(toolCall) {
12
+ if (!toolCall || typeof toolCall !== 'object') return null;
13
+ const name = toolCall.name ?? '';
14
+ const args = toolCall.args && typeof toolCall.args === 'object' ? toolCall.args : {};
15
+ const filePath = args.TargetFile ?? args.targetFile ?? args.file_path ?? args.path;
16
+ if (name === 'write_to_file') {
17
+ return {
18
+ toolName: 'Write',
19
+ toolInput: {
20
+ file_path: filePath,
21
+ content: args.CodeContent ?? args.codeContent ?? args.content ?? '',
22
+ },
23
+ operation: 'write_to_file',
24
+ };
25
+ }
26
+ if (name === 'replace_file_content') {
27
+ return {
28
+ toolName: 'Edit',
29
+ toolInput: {
30
+ file_path: filePath,
31
+ old_string: args.TargetContent ?? args.targetContent ?? args.old_string ?? '',
32
+ new_string: args.ReplacementContent ?? args.replacementContent ?? args.new_string ?? '',
33
+ replace_all: Boolean(args.AllowMultiple ?? args.allowMultiple),
34
+ },
35
+ operation: 'replace_file_content',
36
+ };
37
+ }
38
+ if (name === 'multi_replace_file_content') {
39
+ const chunks = Array.isArray(args.ReplacementChunks)
40
+ ? args.ReplacementChunks
41
+ : Array.isArray(args.replacementChunks)
42
+ ? args.replacementChunks
43
+ : [];
44
+ return {
45
+ toolName: 'MultiEdit',
46
+ toolInput: {
47
+ file_path: filePath,
48
+ edits: chunks.map((chunk) => ({
49
+ old_string: chunk?.TargetContent ?? chunk?.targetContent ?? chunk?.old_string ?? '',
50
+ new_string:
51
+ chunk?.ReplacementContent ?? chunk?.replacementContent ?? chunk?.new_string ?? '',
52
+ replace_all: Boolean(chunk?.AllowMultiple ?? chunk?.allowMultiple),
53
+ })),
54
+ },
55
+ operation: 'multi_replace_file_content',
56
+ };
57
+ }
58
+ return {
59
+ toolName: name,
60
+ toolInput: { ...args, file_path: filePath },
61
+ operation: name,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Normalize agent PreToolUse payloads.
67
+ * Claude Code: { tool_name, tool_input: { file_path, content | old_string/new_string } }
68
+ * Grok Build: { toolName, toolInput: { file_path, content | old_string/new_string } }
69
+ * (aliases Write/Edit/MultiEdit → write/search_replace; matcher keeps both)
70
+ * Antigravity: { toolCall: { name, args: { TargetFile, CodeContent, … } } }
71
+ * Cursor: { tool_name, tool_input, hook_event_name?, workspace_roots? }
72
+ * Write uses `contents`; StrReplace maps to Edit (path/old_string/new_string).
73
+ * Codex: { tool_name: "apply_patch", tool_input: { command: "*** Begin Patch..." } }
74
+ */
75
+ export function normalizeHookPayload(payload, grokHookEvent = Boolean(process.env.GROK_HOOK_EVENT)) {
76
+ const antigravityStyle =
77
+ payload != null && typeof payload === 'object' && 'toolCall' in payload;
78
+ if (antigravityStyle) {
79
+ const mapped = mapAntigravityToolCall(payload.toolCall);
80
+ const filePath =
81
+ mapped?.toolInput?.file_path ??
82
+ mapped?.toolInput?.filePath ??
83
+ mapped?.toolInput?.path ??
84
+ mapped?.toolInput?.target_file;
85
+ return {
86
+ toolName: mapped?.toolName ?? '',
87
+ toolInput: { ...(mapped?.toolInput ?? {}), file_path: filePath },
88
+ grokStyle: true, // decision JSON on stdout (deny)
89
+ antigravityStyle: true,
90
+ cursorStyle: false,
91
+ operation: mapped?.operation ?? mapped?.toolName ?? null,
92
+ };
93
+ }
94
+
95
+ const rawName = payload?.tool_name ?? payload?.toolName ?? '';
96
+ const toolInputRaw = payload?.tool_input ?? payload?.toolInput ?? {};
97
+ const toolInput =
98
+ toolInputRaw && typeof toolInputRaw === 'object' ? { ...toolInputRaw } : {};
99
+ // Cursor Write uses `contents`; Claude/Grok use `content`.
100
+ if (toolInput.content == null && typeof toolInput.contents === 'string') {
101
+ toolInput.content = toolInput.contents;
102
+ }
103
+ const nameMap = {
104
+ Write: 'Write',
105
+ write: 'Write',
106
+ Edit: 'Edit',
107
+ search_replace: 'Edit',
108
+ StrReplace: 'Edit',
109
+ MultiEdit: 'MultiEdit',
110
+ ApplyPatch: 'ApplyPatch',
111
+ apply_patch: 'ApplyPatch',
112
+ write_to_file: 'Write',
113
+ replace_file_content: 'Edit',
114
+ multi_replace_file_content: 'MultiEdit',
115
+ };
116
+ const toolName = nameMap[rawName] ?? rawName;
117
+ const filePath =
118
+ toolInput.file_path ?? toolInput.filePath ?? toolInput.path ?? toolInput.target_file;
119
+ const cursorStyle =
120
+ Boolean(process.env.CURSOR_PROJECT_DIR) ||
121
+ Boolean(process.env.CURSOR_VERSION) ||
122
+ (payload != null &&
123
+ typeof payload === 'object' &&
124
+ (payload.hook_event_name === 'preToolUse' ||
125
+ Array.isArray(payload.workspace_roots) ||
126
+ rawName === 'StrReplace' ||
127
+ (rawName === 'Write' && typeof toolInputRaw?.contents === 'string')));
128
+ return {
129
+ toolName,
130
+ toolInput: { ...toolInput, file_path: filePath },
131
+ // Grok-style camelCase (or GROK_HOOK_EVENT) → also emit deny JSON on stdout.
132
+ grokStyle:
133
+ grokHookEvent ||
134
+ (payload != null && typeof payload === 'object' && 'toolName' in payload),
135
+ antigravityStyle: false,
136
+ cursorStyle,
137
+ operation: rawName === 'StrReplace' ? 'StrReplace' : null,
138
+ };
139
+ }
140
+
141
+ export function applyCodexUpdatePatch(current, lines) {
142
+ let source = current.split('\n');
143
+ let cursor = 0;
144
+ const hunks = [];
145
+ let hunk = null;
146
+ for (const line of lines) {
147
+ if (line.startsWith('@@')) {
148
+ if (hunk) hunks.push(hunk);
149
+ hunk = { anchor: line.slice(2).trim(), entries: [] };
150
+ } else if (/^[ +\-]/.test(line)) {
151
+ if (!hunk) return null;
152
+ hunk.entries.push(line);
153
+ }
154
+ }
155
+ if (hunk) hunks.push(hunk);
156
+ for (const { anchor, entries } of hunks) {
157
+ if (anchor) {
158
+ const anchorAt = source.findIndex((line, index) => index >= cursor && line === anchor);
159
+ if (anchorAt < 0) return null;
160
+ cursor = anchorAt + 1;
161
+ }
162
+ const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
163
+ const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
164
+ let found = -1;
165
+ for (let at = cursor; at <= source.length - oldLines.length; at += 1) {
166
+ if (oldLines.every((line, index) => source[at + index] === line)) {
167
+ found = at;
168
+ break;
169
+ }
170
+ }
171
+ if (found < 0) return null;
172
+ source.splice(found, oldLines.length, ...newLines);
173
+ cursor = found + newLines.length;
174
+ }
175
+ return source.join('\n');
176
+ }
177
+
178
+ export function codexPatchWrites(patch, root) {
179
+ if (typeof patch !== 'string') {
180
+ return { writes: [], complete: false };
181
+ }
182
+ const lines = patch.split('\n');
183
+ const begin = lines.indexOf('*** Begin Patch');
184
+ const end = lines.indexOf('*** End Patch', begin + 1);
185
+ if (begin < 0 || end <= begin) return { writes: [], complete: false };
186
+ const writes = [];
187
+ const seenPaths = new Set();
188
+ let complete = [
189
+ ...lines.slice(0, begin),
190
+ ...lines.slice(end + 1),
191
+ ].every((line) => line.trim() === '');
192
+ let sawFileDirective = false;
193
+ for (let index = begin + 1; index < end; index += 1) {
194
+ const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
195
+ if (!match) {
196
+ if (lines[index].trim() !== '') complete = false;
197
+ continue;
198
+ }
199
+ sawFileDirective = true;
200
+ const [, action, relativePath] = match;
201
+ const body = [];
202
+ for (index += 1; index < end && !lines[index].startsWith('*** '); index += 1) {
203
+ body.push(lines[index]);
204
+ }
205
+ index -= 1;
206
+ const filePath = path.resolve(root, relativePath);
207
+ const rel = path.relative(root, filePath);
208
+ if (
209
+ seenPaths.has(filePath) ||
210
+ rel.startsWith(`..${path.sep}`) ||
211
+ rel === '..' ||
212
+ path.isAbsolute(rel)
213
+ ) {
214
+ complete = false;
215
+ continue;
216
+ }
217
+ seenPaths.add(filePath);
218
+ const canonicalRelativePath = rel.split(path.sep).join('/');
219
+ if (action === 'Delete') {
220
+ if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
221
+ complete = false;
222
+ continue;
223
+ }
224
+ writes.push({ path: canonicalRelativePath, filePath, delete: true });
225
+ continue;
226
+ }
227
+ let content;
228
+ if (action === 'Add') {
229
+ if (
230
+ body.length === 0 ||
231
+ fs.existsSync(filePath) ||
232
+ body.some((line) => !line.startsWith('+'))
233
+ ) {
234
+ complete = false;
235
+ continue;
236
+ }
237
+ content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
238
+ if (body.some((line) => line.startsWith('+'))) content += '\n';
239
+ } else {
240
+ if (
241
+ !body.some((line) => line.startsWith('@@')) ||
242
+ body.some((line) => !line.startsWith('@@') && !/^[ +\-]/.test(line))
243
+ ) {
244
+ complete = false;
245
+ continue;
246
+ }
247
+ let current;
248
+ try {
249
+ current = fs.readFileSync(filePath, 'utf8');
250
+ } catch {
251
+ complete = false;
252
+ continue;
253
+ }
254
+ content = applyCodexUpdatePatch(current, body);
255
+ if (content === null) complete = false;
256
+ }
257
+ if (typeof content === 'string') {
258
+ writes.push({ path: canonicalRelativePath, filePath, content });
259
+ }
260
+ }
261
+ return { writes, complete: complete && sawFileDirective };
262
+ }
263
+
264
+ /**
265
+ * Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
266
+ * to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
267
+ * snippet out of context. Replacement uses a function argument so `$&`-style sequences in
268
+ * generated code are inserted literally, never interpreted as replacement patterns.
269
+ */
270
+ export function proposedSource(toolName, toolInput) {
271
+ if (toolName === 'Write') return toolInput.content ?? toolInput.contents;
272
+
273
+ let text = '';
274
+ try {
275
+ text = fs.readFileSync(toolInput.file_path, 'utf8');
276
+ } catch {
277
+ // New file created via Edit: fall through with an empty base.
278
+ }
279
+ const edits = toolName === 'MultiEdit' ? toolInput.edits ?? [] : [toolInput];
280
+ for (const edit of edits) {
281
+ const from = edit.old_string ?? '';
282
+ const to = edit.new_string ?? '';
283
+ if (from === '') {
284
+ text = to;
285
+ } else if (edit.replace_all) {
286
+ text = text.split(from).join(to);
287
+ } else {
288
+ text = text.replace(from, () => to);
289
+ }
290
+ }
291
+ return text;
292
+ }
293
+
294
+ /** Antigravity PreToolUse requires stdout `decision` on every response (allow included). */
295
+ export function emitAntigravityAllow(output, antigravityStyle) {
296
+ if (!antigravityStyle) return;
297
+ output.stdout(`${JSON.stringify({ decision: 'allow' })}\n`);
298
+ }
299
+
300
+ /** Cursor preToolUse accepts explicit allow; exit 0 alone also works. */
301
+ export function emitCursorAllow(output, cursorStyle) {
302
+ if (!cursorStyle) return;
303
+ output.stdout(`${JSON.stringify({ permission: 'allow' })}\n`);
304
+ }
305
+
306
+ export function emitHostAllow(output, { antigravityStyle, cursorStyle }) {
307
+ emitAntigravityAllow(output, antigravityStyle);
308
+ emitCursorAllow(output, cursorStyle);
309
+ }
310
+
311
+ /**
312
+ * Socket-style write-gate deny: two lines first. Pass/fail, no score.
313
+ * Rule id stays on a following line, not the first sentence.
314
+ */
315
+ export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLines = [] }) {
316
+ const target = file || 'this write';
317
+ const why = String(reason || 'this change breaks the architecture layers').replace(/\s+/g, ' ').trim();
318
+ const next =
319
+ nextAction && /place|move|import|port/i.test(nextAction)
320
+ ? nextAction
321
+ : 'Move the import or run /ark-place. Do not weaken ark.config.json.';
322
+ const lines = [`blocked ${target} — ${why}`, `Next: ${next}`];
323
+ if (ruleId) lines.push(`[${ruleId}]`);
324
+ for (const extra of extraLines) {
325
+ if (extra) lines.push(extra);
326
+ }
327
+ return lines.join('\n');
328
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Package-manager detection and emitted install/run command shapes.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /** The three package managers Ark emits commands for. */
8
+ const LOCKFILES = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', npm: 'package-lock.json' };
9
+
10
+ function readPackageJson(root) {
11
+ const file = path.join(root, 'package.json');
12
+ if (!fs.existsSync(file)) return null;
13
+ try {
14
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * The Corepack `packageManager` field (and the newer `devEngines.packageManager`) is the
22
+ * project's OWN authoritative statement of its package manager. When present it wins over any
23
+ * lockfile guess. Returns 'pnpm' | 'yarn' | 'npm' | undefined.
24
+ */
25
+ function declaredPackageManager(root) {
26
+ let pkg;
27
+ try {
28
+ pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ const raw =
33
+ (typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined) ??
34
+ (typeof pkg.devEngines?.packageManager?.name === 'string'
35
+ ? pkg.devEngines.packageManager.name
36
+ : undefined);
37
+ const name = raw?.trim().toLowerCase();
38
+ return name === 'pnpm' || name === 'yarn' || name === 'npm' ? name : undefined;
39
+ }
40
+
41
+ /** Lockfiles present in the project root, in { pnpm, yarn, npm } key order. */
42
+ export function presentLockfiles(root) {
43
+ return Object.entries(LOCKFILES)
44
+ .filter(([, file]) => fs.existsSync(path.join(root, file)))
45
+ .map(([pm]) => pm);
46
+ }
47
+
48
+ /**
49
+ * Detect the project's package manager: 'pnpm' | 'yarn' | 'npm'.
50
+ *
51
+ * Priority: (1) the `packageManager` / `devEngines` field (the project's own declaration);
52
+ * (2) a single lockfile; (3) on CONFLICT (more than one lockfile and no declaration) prefer
53
+ * npm whenever a package-lock.json is present. Rationale: `npx` runs fine inside a pnpm/yarn
54
+ * repo, but `pnpm exec` / `yarn` in an npm repo BREAKS (frozen-lockfile / no-TTY / a spurious
55
+ * pnpm-lock). So a stray pnpm-lock.yaml left in an npm project must NOT hijack it into pnpm —
56
+ * package-lock.json wins the tie, and the field is the escape hatch for a genuine pnpm repo
57
+ * that still carries a package-lock.json. Falls back to npm when nothing is detectable.
58
+ */
59
+ export function detectPackageManager(root) {
60
+ const declared = declaredPackageManager(root);
61
+ if (declared) return declared;
62
+ const locks = presentLockfiles(root);
63
+ if (locks.length <= 1) return locks[0] ?? 'npm';
64
+ if (locks.includes('npm')) return 'npm';
65
+ return locks[0]; // pnpm over yarn when only those two collide
66
+ }
67
+
68
+ // pnpm 10+ `pnpm exec` runs a deps-status pre-check that fails with ERR_PNPM_IGNORED_BUILDS
69
+ // when the repo has un-approved native build scripts (sharp, esbuild, tailwind oxide, …) —
70
+ // the common state of real pnpm apps. Skip that gate so Ark's emitted commands still run.
71
+ const PNPM_EXEC = 'pnpm --config.verify-deps-before-run=false exec';
72
+ const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
73
+
74
+ /**
75
+ * The command prefix that runs an INSTALLED package binary, matched to the project's
76
+ * package manager. `npx` is used for npm and as the safe fallback.
77
+ *
78
+ * This is the single source of truth that makes every command Ark EMITS — the AGENTS.md
79
+ * contract, .mcp.json, the Claude/Codex hooks, the check:architecture script, the
80
+ * SessionStart summary and every console hint — respect a pnpm-only or yarn repo instead
81
+ * of hardcoding `npx`. (A "pnpm only, never npx" repo treats an emitted `npx` as a policy
82
+ * violation.) `packageManager()` in ark-check.mjs builds the CI-workflow variant on the
83
+ * same detection.
84
+ */
85
+ export function execRunner(root) {
86
+ return RUNNER_BY_PM[detectPackageManager(root)];
87
+ }
88
+
89
+ /** Full runnable command string for an installed Ark binary, package-manager aware. */
90
+ export function arkCommand(root, bin, argsStr = '') {
91
+ return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
92
+ }
93
+
94
+ /**
95
+ * Split { command, args } form for JSON/TOML configs (.mcp.json, config.toml) that spawn
96
+ * the binary directly. `pnpm exec ark-mcp` becomes command "pnpm" + args ["exec","ark-mcp",…]
97
+ * so the runner is a real argv[0], not a space-joined string a shell would mis-split.
98
+ */
99
+ export function execCommandParts(root, bin, binArgs = []) {
100
+ const runner = execRunner(root);
101
+ if (runner === PNPM_EXEC || runner.startsWith('pnpm ')) {
102
+ return {
103
+ command: 'pnpm',
104
+ args: ['--config.verify-deps-before-run=false', 'exec', bin, ...binArgs],
105
+ };
106
+ }
107
+ if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
108
+ return { command: 'npx', args: [bin, ...binArgs] };
109
+ }
110
+
111
+ /**
112
+ * True when this directory is a pnpm workspace root (needs `pnpm add -w` for root deps).
113
+ * Nested packages under the workspace are not roots.
114
+ */
115
+ export function isPnpmWorkspaceRoot(root) {
116
+ return fs.existsSync(path.join(root, 'pnpm-workspace.yaml'));
117
+ }
118
+
119
+ /**
120
+ * True when package.json declares npm/yarn workspaces (yarn classic needs `-W` at root).
121
+ */
122
+ export function isNpmYarnWorkspaceRoot(root) {
123
+ const pkg = readPackageJson(root);
124
+ if (!pkg) return false;
125
+ const ws = pkg.workspaces;
126
+ return Array.isArray(ws) || (ws && typeof ws === 'object' && Array.isArray(ws.packages));
127
+ }
128
+
129
+ /**
130
+ * Normalize a version/range/spec into an installable package argument for arkgate.
131
+ * Accepts `latest`, `^3.8.2`, `arkgate@latest`, or a full package name.
132
+ */
133
+ export function normalizeArkgateInstallSpec(versionSpec) {
134
+ const raw = typeof versionSpec === 'string' && versionSpec.trim() ? versionSpec.trim() : 'latest';
135
+ if (raw.startsWith('arkgate@') || raw === 'arkgate') return raw === 'arkgate' ? 'arkgate@latest' : raw;
136
+ if (raw.includes('/') || raw.startsWith('file:') || raw.startsWith('link:')) return raw;
137
+ return `arkgate@${raw}`;
138
+ }
139
+
140
+ /**
141
+ * Package-manager argv to add a dev dependency (e.g. arkgate@latest).
142
+ * pnpm workspace roots get `-w`; yarn classic workspaces get `-W`.
143
+ *
144
+ * @param {string} root
145
+ * @param {string} [versionSpec] package name or name@version (default arkgate@latest)
146
+ * @returns {[string, string[]]}
147
+ */
148
+ export function packageInstallArgv(root, versionSpec = 'latest') {
149
+ const pkgSpec = normalizeArkgateInstallSpec(versionSpec);
150
+ const pm = detectPackageManager(root);
151
+ if (pm === 'pnpm') {
152
+ const args = ['add', '-D', pkgSpec];
153
+ if (isPnpmWorkspaceRoot(root)) args.push('-w');
154
+ return ['pnpm', args];
155
+ }
156
+ if (pm === 'yarn') {
157
+ const args = ['add', '-D', pkgSpec];
158
+ if (isNpmYarnWorkspaceRoot(root)) args.push('-W');
159
+ return ['yarn', args];
160
+ }
161
+ return ['npm', ['install', '-D', pkgSpec]];
162
+ }
163
+
164
+ /** Package-manager aware "install a dev dependency" hint (e.g. for a missing typescript). */
165
+ export function installDevHint(root, pkg) {
166
+ const pm = detectPackageManager(root);
167
+ if (pm === 'pnpm') {
168
+ return isPnpmWorkspaceRoot(root) ? `pnpm add -D ${pkg} -w` : `pnpm add -D ${pkg}`;
169
+ }
170
+ if (pm === 'yarn') {
171
+ return isNpmYarnWorkspaceRoot(root) ? `yarn add -D ${pkg} -W` : `yarn add -D ${pkg}`;
172
+ }
173
+ return `npm install -D ${pkg}`;
174
+ }
@@ -17,10 +17,14 @@ function readJsonFile(filePath, label) {
17
17
  }
18
18
  }
19
19
 
20
+ /** Kill hung git instead of stalling CI. */
21
+ export const SPAWN_TIMEOUT_MS = 8000;
22
+
20
23
  function runGit(cwd, args) {
21
24
  return spawnSync('git', ['-C', cwd, ...args], {
22
25
  encoding: 'utf8',
23
26
  stdio: ['ignore', 'pipe', 'pipe'],
27
+ timeout: SPAWN_TIMEOUT_MS,
24
28
  });
25
29
  }
26
30
 
@@ -34,10 +34,14 @@ export {
34
34
  suggestStewards,
35
35
  };
36
36
 
37
+ /** Kill hung git instead of stalling CI. */
38
+ export const SPAWN_TIMEOUT_MS = 8000;
39
+
37
40
  function runGit(cwd, args) {
38
41
  return spawnSync('git', ['-C', cwd, ...args], {
39
42
  encoding: 'utf8',
40
43
  stdio: ['ignore', 'pipe', 'pipe'],
44
+ timeout: SPAWN_TIMEOUT_MS,
41
45
  });
42
46
  }
43
47