@cruxy/cli 0.29.2 → 0.29.4
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/dist/agent/loop.js +6 -0
- package/dist/agent/prompts.d.ts +8 -0
- package/dist/agent/prompts.js +26 -0
- package/dist/cli/commands/mcp.js +1 -1
- package/dist/cli/commands/test.js +1 -0
- package/dist/config/credentials.d.ts +3 -4
- package/dist/config/credentials.js +74 -21
- package/dist/config/owner-only.d.ts +19 -0
- package/dist/config/owner-only.js +114 -0
- package/dist/config/schema.d.ts +77 -32
- package/dist/config/schema.js +16 -0
- package/dist/errors/constructors.d.ts +9 -0
- package/dist/errors/constructors.js +27 -0
- package/dist/errors/types.d.ts +12 -0
- package/dist/errors/types.js +18 -0
- package/dist/indexing/service.js +4 -1
- package/dist/lsp/transport.js +5 -5
- package/dist/mcp/transport.js +6 -6
- package/dist/memory/types.d.ts +2 -2
- package/dist/sandbox/docker-runtime.js +7 -15
- package/dist/testing/run-tests-tool.js +1 -0
- package/dist/testing/runner.js +7 -17
- package/dist/testing/types.d.ts +6 -1
- package/dist/tools/shell/exec.js +8 -19
- package/dist/tools/shell/resolve-shell.d.ts +81 -0
- package/dist/tools/shell/resolve-shell.js +158 -0
- package/dist/utils/child-tree.d.ts +9 -11
- package/dist/utils/child-tree.js +11 -25
- package/dist/utils/process-tree.d.ts +16 -0
- package/dist/utils/process-tree.js +84 -0
- package/package.json +4 -2
|
@@ -142,6 +142,33 @@ export function authInvalid(underlying) {
|
|
|
142
142
|
underlying,
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* A credential could not be persisted with owner-only permissions, so it was
|
|
147
|
+
* NOT written (C.27c). Chiefly a Windows case: the store's ACL could not be
|
|
148
|
+
* restricted to the current user (non-NTFS filesystem, `icacls`/SID
|
|
149
|
+
* unavailable), and we refuse to leave a secret at inheritable permissions while
|
|
150
|
+
* claiming otherwise. Provider keys point at the env-var fallback; MCP tokens
|
|
151
|
+
* have no such fallback, so the message says so plainly.
|
|
152
|
+
*/
|
|
153
|
+
export function credentialsUnprotected(kind, path, underlying) {
|
|
154
|
+
const nextSteps = kind === "provider"
|
|
155
|
+
? [
|
|
156
|
+
"set the key via the CRUXY_API_KEY environment variable instead — env is never written to disk and always wins",
|
|
157
|
+
"or store it on a filesystem that supports owner-only permissions (NTFS, not FAT/exFAT)",
|
|
158
|
+
]
|
|
159
|
+
: [
|
|
160
|
+
"an MCP bearer token has no environment fallback — it can only live in the owner-only store",
|
|
161
|
+
"store it on a filesystem that supports owner-only permissions (NTFS, not FAT/exFAT)",
|
|
162
|
+
];
|
|
163
|
+
return new CruxyError({
|
|
164
|
+
code: ErrorCode.CredentialsUnprotected,
|
|
165
|
+
title: `refusing to write a credential that cannot be made owner-only: ${path}`,
|
|
166
|
+
cause: scrubbedMessageOf(underlying),
|
|
167
|
+
nextSteps,
|
|
168
|
+
underlying,
|
|
169
|
+
meta: { path, kind },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
145
172
|
// ── network (exit 5) ──────────────────────────────────────────────────────────
|
|
146
173
|
export function gatewayUnreachable(underlying) {
|
|
147
174
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export declare const ErrorCode: {
|
|
|
29
29
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
30
30
|
readonly AuthInvalid: "CRUXY_E_AUTH_INVALID";
|
|
31
31
|
readonly ForgeAuth: "CRUXY_E_FORGE_AUTH";
|
|
32
|
+
/** A credential could not be persisted with owner-only permissions (C.27c) —
|
|
33
|
+
* e.g. on Windows the store's ACL could not be restricted to the current user
|
|
34
|
+
* (non-NTFS filesystem, `icacls`/SID unavailable). Refused loudly rather than
|
|
35
|
+
* written world-inheritable: a secret is never persisted at permissions we
|
|
36
|
+
* could not verify as owner-only. */
|
|
37
|
+
readonly CredentialsUnprotected: "CRUXY_E_CREDENTIALS_UNPROTECTED";
|
|
32
38
|
readonly GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE";
|
|
33
39
|
readonly GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED";
|
|
34
40
|
readonly Api: "CRUXY_E_API";
|
|
@@ -201,6 +207,12 @@ export declare const ErrorCode: {
|
|
|
201
207
|
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
202
208
|
* rather than pretending there are simply no jobs. */
|
|
203
209
|
readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
|
|
210
|
+
/** No usable host shell was found to run commands through. On Windows this
|
|
211
|
+
* means neither Git Bash nor PowerShell could be located and no
|
|
212
|
+
* `shell.executable` override was set. Fail loud at session start — NEVER
|
|
213
|
+
* silently fall back to cmd.exe running bash-shaped commands (which
|
|
214
|
+
* mis-parses `;`/`&&`/quoting and can report a false success). */
|
|
215
|
+
readonly NoShell: "CRUXY_E_NO_SHELL";
|
|
204
216
|
/** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
|
|
205
217
|
* a hard stop (the iteration cap or a token budget) or was cancelled, rather
|
|
206
218
|
* than finishing on its own. Fail loud with a non-zero exit so CI never reads a
|
package/dist/errors/types.js
CHANGED
|
@@ -33,6 +33,12 @@ export const ErrorCode = {
|
|
|
33
33
|
AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
|
|
34
34
|
AuthInvalid: "CRUXY_E_AUTH_INVALID",
|
|
35
35
|
ForgeAuth: "CRUXY_E_FORGE_AUTH",
|
|
36
|
+
/** A credential could not be persisted with owner-only permissions (C.27c) —
|
|
37
|
+
* e.g. on Windows the store's ACL could not be restricted to the current user
|
|
38
|
+
* (non-NTFS filesystem, `icacls`/SID unavailable). Refused loudly rather than
|
|
39
|
+
* written world-inheritable: a secret is never persisted at permissions we
|
|
40
|
+
* could not verify as owner-only. */
|
|
41
|
+
CredentialsUnprotected: "CRUXY_E_CREDENTIALS_UNPROTECTED",
|
|
36
42
|
// network (exit 5)
|
|
37
43
|
GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE",
|
|
38
44
|
GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
|
|
@@ -222,6 +228,13 @@ export const ErrorCode = {
|
|
|
222
228
|
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
223
229
|
* rather than pretending there are simply no jobs. */
|
|
224
230
|
JobsDisabled: "CRUXY_E_JOBS_DISABLED",
|
|
231
|
+
// host shell (exit 21) — the run_command / run_tests execution substrate
|
|
232
|
+
/** No usable host shell was found to run commands through. On Windows this
|
|
233
|
+
* means neither Git Bash nor PowerShell could be located and no
|
|
234
|
+
* `shell.executable` override was set. Fail loud at session start — NEVER
|
|
235
|
+
* silently fall back to cmd.exe running bash-shaped commands (which
|
|
236
|
+
* mis-parses `;`/`&&`/quoting and can report a false success). */
|
|
237
|
+
NoShell: "CRUXY_E_NO_SHELL",
|
|
225
238
|
// one-shot run outcome (exit 20)
|
|
226
239
|
/** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
|
|
227
240
|
* a hard stop (the iteration cap or a token budget) or was cancelled, rather
|
|
@@ -249,6 +262,7 @@ const EXIT_CODES = {
|
|
|
249
262
|
[ErrorCode.AuthMissingKey]: 4,
|
|
250
263
|
[ErrorCode.AuthInvalid]: 4,
|
|
251
264
|
[ErrorCode.ForgeAuth]: 4,
|
|
265
|
+
[ErrorCode.CredentialsUnprotected]: 4,
|
|
252
266
|
[ErrorCode.GatewayUnreachable]: 5,
|
|
253
267
|
[ErrorCode.GitPushFailed]: 5,
|
|
254
268
|
[ErrorCode.Api]: 6,
|
|
@@ -353,6 +367,10 @@ const EXIT_CODES = {
|
|
|
353
367
|
// cancelled) without completing gets its own greppable exit code, so CI can tell
|
|
354
368
|
// "the agent gave up" apart from a provider/auth/config failure.
|
|
355
369
|
[ErrorCode.AgentIncomplete]: 20,
|
|
370
|
+
// Host shell. No usable shell to execute commands through — a fail-loud
|
|
371
|
+
// execution-substrate stop (kin to CRUXY_E_SANDBOX_UNAVAILABLE, but for the
|
|
372
|
+
// host path), with its own greppable exit code.
|
|
373
|
+
[ErrorCode.NoShell]: 21,
|
|
356
374
|
};
|
|
357
375
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
358
376
|
export function exitCodeFor(code) {
|
package/dist/indexing/service.js
CHANGED
|
@@ -131,7 +131,10 @@ async function openStore(root, kind, logger) {
|
|
|
131
131
|
// an in-memory index with a warning (no quality loss, just no persistence).
|
|
132
132
|
if (kind === "sqlite")
|
|
133
133
|
throw indexStoreUnavailable(err);
|
|
134
|
-
logger.warn(`
|
|
134
|
+
logger.warn(`persistent index unavailable (${err.message}); using an ephemeral ` +
|
|
135
|
+
`in-memory index — it reindexes from scratch each session. To enable persistence, ` +
|
|
136
|
+
`let better-sqlite3 build (install your platform's C/C++ build tools) or run on Node ≥22 ` +
|
|
137
|
+
`(which ships a prebuilt binary).`);
|
|
135
138
|
return { store: new InMemoryVectorStore(), storePath: null };
|
|
136
139
|
}
|
|
137
140
|
}
|
package/dist/lsp/transport.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnTree } from "../utils/process-tree.js";
|
|
2
2
|
import { killTree, killTrackedTrees, registerForCleanup, trackedTreeCount, } from "../utils/child-tree.js";
|
|
3
3
|
// Re-exported under their historical LSP names so callers and tests keep
|
|
4
4
|
// importing them from here; the machinery now lives in the shared child-tree
|
|
@@ -31,11 +31,11 @@ export class StdioTransport {
|
|
|
31
31
|
/** Deregisters this process from the process-exit kill-tree backstop. */
|
|
32
32
|
unregisterCleanup;
|
|
33
33
|
constructor(spec, root) {
|
|
34
|
-
// `
|
|
35
|
-
//
|
|
36
|
-
|
|
34
|
+
// `spawnTree` heads a killable tree with the platform-correct grouping —
|
|
35
|
+
// POSIX process group, or win32 `windowsHide` (NOT `detached`, which opens a
|
|
36
|
+
// console there) — so `killTree` reaps the whole server tree on either OS.
|
|
37
|
+
this.child = spawnTree(spec.command, spec.args, {
|
|
37
38
|
cwd: root,
|
|
38
|
-
detached: true,
|
|
39
39
|
stdio: ["pipe", "pipe", "pipe"],
|
|
40
40
|
});
|
|
41
41
|
this.unregisterCleanup = registerForCleanup(this.child.pid);
|
package/dist/mcp/transport.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnTree } from "../utils/process-tree.js";
|
|
2
2
|
import { killTree, registerForCleanup } from "../utils/child-tree.js";
|
|
3
3
|
/**
|
|
4
4
|
* JSON-RPC 2.0 over an MCP server's stdio (C.27). Owns the child process: spawns
|
|
@@ -30,12 +30,12 @@ export class McpStdioTransport {
|
|
|
30
30
|
disposed = false;
|
|
31
31
|
unregisterCleanup;
|
|
32
32
|
constructor(spec, root) {
|
|
33
|
-
// `
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
33
|
+
// `spawnTree` heads a killable tree with the platform-correct grouping —
|
|
34
|
+
// POSIX process group, or win32 `windowsHide` (NOT `detached`, which opens a
|
|
35
|
+
// console there) — same discipline as run_command (C.16) and the LSP
|
|
36
|
+
// transport (C.12); `killTree` reaps the whole server tree on either OS.
|
|
37
|
+
this.child = spawnTree(spec.command, spec.args, {
|
|
37
38
|
cwd: root,
|
|
38
|
-
detached: true,
|
|
39
39
|
stdio: ["pipe", "pipe", "pipe"],
|
|
40
40
|
env: { ...process.env, ...(spec.env ?? {}) },
|
|
41
41
|
});
|
package/dist/memory/types.d.ts
CHANGED
|
@@ -45,14 +45,14 @@ export declare const MemoryEntrySchema: z.ZodObject<{
|
|
|
45
45
|
/** ISO 8601 timestamp the entry was recorded. */
|
|
46
46
|
createdAt: z.ZodString;
|
|
47
47
|
}, "strict", z.ZodTypeAny, {
|
|
48
|
-
id: string;
|
|
49
48
|
kind: "fact" | "decision" | "preference";
|
|
49
|
+
id: string;
|
|
50
50
|
createdAt: string;
|
|
51
51
|
content: string;
|
|
52
52
|
scope: "project" | "user";
|
|
53
53
|
}, {
|
|
54
|
-
id: string;
|
|
55
54
|
kind: "fact" | "decision" | "preference";
|
|
55
|
+
id: string;
|
|
56
56
|
createdAt: string;
|
|
57
57
|
content: string;
|
|
58
58
|
scope: "project" | "user";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { killTree, spawnTree } from "../utils/process-tree.js";
|
|
3
4
|
import { sandboxExec, sandboxImage } from "../errors/index.js";
|
|
4
5
|
/**
|
|
5
6
|
* The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
|
|
@@ -62,8 +63,11 @@ export class DockerRuntime {
|
|
|
62
63
|
const capture = new OutputCapture(opts.maxOutputBytes, opts.capture);
|
|
63
64
|
let child;
|
|
64
65
|
try {
|
|
65
|
-
// `
|
|
66
|
-
|
|
66
|
+
// `spawnTree` groups the docker client (POSIX process group / win32 OS
|
|
67
|
+
// tree) so a timeout reaps the whole tree via `killTree` on either OS —
|
|
68
|
+
// the docker CLIENT runs on the host, so on Windows this needs the
|
|
69
|
+
// taskkill walk, not a POSIX-only negative-PID signal.
|
|
70
|
+
child = spawnTree(this.bin, argv);
|
|
67
71
|
}
|
|
68
72
|
catch (err) {
|
|
69
73
|
reject(sandboxExec(err));
|
|
@@ -121,9 +125,8 @@ export class DockerRuntime {
|
|
|
121
125
|
/** Best-effort container teardown after a timeout kill. */
|
|
122
126
|
forceRemove(container) {
|
|
123
127
|
try {
|
|
124
|
-
const rm =
|
|
128
|
+
const rm = spawnTree(this.bin, ["rm", "-f", container], {
|
|
125
129
|
stdio: "ignore",
|
|
126
|
-
detached: true,
|
|
127
130
|
});
|
|
128
131
|
rm.on("error", () => { });
|
|
129
132
|
rm.unref();
|
|
@@ -253,14 +256,3 @@ class OutputCapture {
|
|
|
253
256
|
return { output: all.toString("utf8"), truncated: this.truncated };
|
|
254
257
|
}
|
|
255
258
|
}
|
|
256
|
-
/** Kill the docker client's process group (POSIX; matches run_command). */
|
|
257
|
-
function killTree(pid) {
|
|
258
|
-
if (pid === undefined)
|
|
259
|
-
return;
|
|
260
|
-
try {
|
|
261
|
-
process.kill(-pid, "SIGKILL");
|
|
262
|
-
}
|
|
263
|
-
catch {
|
|
264
|
-
// Already exited, or no group — nothing to kill.
|
|
265
|
-
}
|
|
266
|
-
}
|
|
@@ -141,6 +141,7 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
141
141
|
cwd: ctx.cwd,
|
|
142
142
|
timeoutMs: ctx.config.shell.timeoutMs,
|
|
143
143
|
captureBytes: ctx.config.test.captureBytes,
|
|
144
|
+
shell: ctx.config.shell,
|
|
144
145
|
});
|
|
145
146
|
budget.record(result.passed);
|
|
146
147
|
const payload = renderResult(result, resolved, {
|
package/dist/testing/runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { killTree } from "../utils/process-tree.js";
|
|
2
|
+
import { spawnShell } from "../tools/shell/resolve-shell.js";
|
|
2
3
|
import { parseFailures } from "./parse.js";
|
|
3
4
|
/**
|
|
4
5
|
* The shipped {@link TestRunner}: spawn the command via the system shell (the
|
|
@@ -15,11 +16,11 @@ export class CommandTestRunner {
|
|
|
15
16
|
const capture = new TailCapture(opts.captureBytes);
|
|
16
17
|
let child;
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
19
|
+
// Same seam as run_command: `spawnShell` routes through the resolved
|
|
20
|
+
// shell (Git Bash / PowerShell on Windows) and groups it as a killable
|
|
21
|
+
// tree (POSIX process group / win32 OS tree) so a timeout reaps the whole
|
|
22
|
+
// tree via `killTree`.
|
|
23
|
+
child = spawnShell(command, opts.shell, { cwd: opts.cwd });
|
|
23
24
|
}
|
|
24
25
|
catch (err) {
|
|
25
26
|
resolve(failed(null, err.message, startedAt));
|
|
@@ -111,14 +112,3 @@ export class TailCapture {
|
|
|
111
112
|
return this.truncated ? `… [earlier output truncated]\n${body}` : body;
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
|
-
/** Kill the whole process group (POSIX; matches run_command's behavior). */
|
|
115
|
-
function killTree(pid) {
|
|
116
|
-
if (pid === undefined)
|
|
117
|
-
return;
|
|
118
|
-
try {
|
|
119
|
-
process.kill(-pid, "SIGKILL");
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
// Already exited, or no group — nothing to kill.
|
|
123
|
-
}
|
|
124
|
-
}
|
package/dist/testing/types.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* under a hard cap. The cardinal rule lives in `runner.ts`: **passed is
|
|
5
5
|
* derived from the exit code and nothing else.**
|
|
6
6
|
*/
|
|
7
|
+
import type { ShellSelection } from "../tools/shell/resolve-shell.js";
|
|
7
8
|
/** A resolved test command and where it came from — shown, never guessed. */
|
|
8
9
|
export interface TestCommand {
|
|
9
10
|
command: string;
|
|
@@ -40,10 +41,14 @@ export interface TestRunOptions {
|
|
|
40
41
|
timeoutMs: number;
|
|
41
42
|
/** Cap on captured output bytes (tail-biased). */
|
|
42
43
|
captureBytes: number;
|
|
44
|
+
/** Shell selection, so the host runner routes through the resolved shell (Git
|
|
45
|
+
* Bash / PowerShell on Windows) — the same seam as run_command. The sandbox
|
|
46
|
+
* runner ignores it (the container has its own shell). */
|
|
47
|
+
shell: ShellSelection;
|
|
43
48
|
}
|
|
44
49
|
/**
|
|
45
50
|
* The swappable execution seam (same discipline as VectorStore/ForgeProvider):
|
|
46
|
-
* the shipped {@link CommandTestRunner} spawns the command via the
|
|
51
|
+
* the shipped {@link CommandTestRunner} spawns the command via the resolved
|
|
47
52
|
* shell; tests inject fakes, and a future framework-native runner (e.g. a
|
|
48
53
|
* vitest API runner) slots in without touching the tool.
|
|
49
54
|
*/
|
package/dist/tools/shell/exec.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { killTree } from "../../utils/process-tree.js";
|
|
2
|
+
import { spawnShell } from "./resolve-shell.js";
|
|
2
3
|
/**
|
|
3
4
|
* Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
|
|
4
5
|
* it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
|
|
@@ -50,9 +51,12 @@ async function runSandboxed(command, ctx) {
|
|
|
50
51
|
function runBounded(command, ctx) {
|
|
51
52
|
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
52
53
|
return new Promise((resolve) => {
|
|
53
|
-
// `
|
|
54
|
-
//
|
|
55
|
-
|
|
54
|
+
// `spawnShell` routes through the resolved shell (POSIX default shell, Git
|
|
55
|
+
// Bash, or PowerShell — see resolve-shell.ts) as the head of a killable tree
|
|
56
|
+
// (its own process group on POSIX; the OS parent-PID tree on win32), so the
|
|
57
|
+
// whole tree — the shell plus anything it spawns — can be killed via
|
|
58
|
+
// `killTree`. A win32 host with no usable shell threw at session start.
|
|
59
|
+
const child = spawnShell(command, ctx.config.shell, { cwd: ctx.cwd });
|
|
56
60
|
const chunks = [];
|
|
57
61
|
let captured = 0;
|
|
58
62
|
let truncated = false;
|
|
@@ -150,18 +154,3 @@ function runBounded(command, ctx) {
|
|
|
150
154
|
});
|
|
151
155
|
});
|
|
152
156
|
}
|
|
153
|
-
/**
|
|
154
|
-
* Kill the command's entire process group. POSIX-specific (negative pid targets
|
|
155
|
-
* the group); fine on our darwin/linux targets. Swallows errors — the process
|
|
156
|
-
* may already be gone.
|
|
157
|
-
*/
|
|
158
|
-
function killTree(pid) {
|
|
159
|
-
if (pid === undefined)
|
|
160
|
-
return;
|
|
161
|
-
try {
|
|
162
|
-
process.kill(-pid, "SIGKILL");
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
// Already exited, or no group — nothing to kill.
|
|
166
|
-
}
|
|
167
|
-
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* WHICH shell a host command runs through, and — just as important — WHICH
|
|
4
|
+
* dialect the model must emit for it. `run_command` and `run_tests` both spawn
|
|
5
|
+
* with the platform default shell (`{ shell: true }` → `/bin/sh` on POSIX,
|
|
6
|
+
* `%ComSpec%`/cmd.exe on win32). The model emits bash, so on Windows a command
|
|
7
|
+
* like `echo x; exit 1` runs as a single `echo`, exits 0, and reads as a false
|
|
8
|
+
* success. This module makes Windows host execution first-class instead:
|
|
9
|
+
*
|
|
10
|
+
* Tier 0 `shell.executable` override → use it verbatim (the user owns it).
|
|
11
|
+
* Tier 1 Git Bash (Git for Windows) → full POSIX parity, no model changes.
|
|
12
|
+
* Tier 2 PowerShell (pwsh, else powershell.exe) → the model is told, via a
|
|
13
|
+
* system-prompt directive keyed on {@link ShellPlan.dialect}, to emit
|
|
14
|
+
* native PowerShell.
|
|
15
|
+
* Tier 3 nothing usable → throw CRUXY_E_NO_SHELL. We NEVER hand bash-shaped
|
|
16
|
+
* strings to cmd.exe silently — that is the exact false-green above.
|
|
17
|
+
*
|
|
18
|
+
* A non-default shell is ALWAYS invoked with explicit argv (`bash -c <cmd>`,
|
|
19
|
+
* `powershell -NoProfile -Command <cmd>`), never Node's `shell: <path>` string
|
|
20
|
+
* form: Node builds that command line with cmd.exe quoting rules, which mangle a
|
|
21
|
+
* command bound for bash/PowerShell. POSIX with no override keeps `shell: true`
|
|
22
|
+
* verbatim — a zero-change path off Windows.
|
|
23
|
+
*/
|
|
24
|
+
export type ShellDialect = "posix" | "powershell" | "cmd";
|
|
25
|
+
export type ShellSource = "default" | "config" | "git-bash" | "powershell";
|
|
26
|
+
/**
|
|
27
|
+
* The shell-selection fields the resolver reads — a structural subset of the
|
|
28
|
+
* config's `ShellConfig` (which also carries `timeoutMs`/`maxOutputBytes`). Kept
|
|
29
|
+
* narrow so callers and tests pass only what selection needs.
|
|
30
|
+
*/
|
|
31
|
+
export interface ShellSelection {
|
|
32
|
+
executable?: string;
|
|
33
|
+
dialect?: ShellDialect;
|
|
34
|
+
}
|
|
35
|
+
/** How to spawn a host command, and which dialect the model should emit for it. */
|
|
36
|
+
export type ShellPlan =
|
|
37
|
+
/** POSIX default: `spawn(command, { shell: true })`, byte-identical to before. */
|
|
38
|
+
{
|
|
39
|
+
kind: "system";
|
|
40
|
+
dialect: "posix";
|
|
41
|
+
source: "default";
|
|
42
|
+
}
|
|
43
|
+
/** Explicit shell binary + fixed flags; the command is the final argv element. */
|
|
44
|
+
| {
|
|
45
|
+
kind: "explicit";
|
|
46
|
+
file: string;
|
|
47
|
+
flags: readonly string[];
|
|
48
|
+
dialect: ShellDialect;
|
|
49
|
+
source: ShellSource;
|
|
50
|
+
};
|
|
51
|
+
/** Injected so the resolver's win32 logic is unit-testable on a POSIX CI host. */
|
|
52
|
+
export interface ShellEnv {
|
|
53
|
+
platform: NodeJS.Platform;
|
|
54
|
+
env: NodeJS.ProcessEnv;
|
|
55
|
+
exists: (candidate: string) => boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Infer a dialect from a shell path's basename, but ONLY for the unambiguous
|
|
59
|
+
* names — anything exotic (`nu`, `fish`, `xonsh`, …) returns undefined so the
|
|
60
|
+
* caller demands an explicit `shell.dialect` rather than guessing wrong.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sniffDialect(executable: string): ShellDialect | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the shell PLAN from config + environment. Pure over its injected
|
|
65
|
+
* {@link ShellEnv}, so the win32 tiers are exercised on any CI host. Throws
|
|
66
|
+
* CRUXY_E_NO_SHELL (no usable shell) or CRUXY_E_CONFIG_INVALID (an override
|
|
67
|
+
* whose dialect can't be determined) — both fail loud, never a silent fallback.
|
|
68
|
+
*/
|
|
69
|
+
export declare function computeShellPlan(shell: ShellSelection, env?: ShellEnv): ShellPlan;
|
|
70
|
+
/** The memoized production resolver. Throws (fail loud) exactly as compute does. */
|
|
71
|
+
export declare function resolveShell(shell: ShellSelection): ShellPlan;
|
|
72
|
+
/** Test hook: drop the memoized plan so the next resolve re-detects. */
|
|
73
|
+
export declare function __resetShellResolution(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Spawn `command` through the resolved shell as a killable process tree — the
|
|
76
|
+
* single seam both `run_command` and `run_tests` route through. `system` keeps
|
|
77
|
+
* the exact `{ shell: true }` spawn; every other plan invokes the shell binary
|
|
78
|
+
* with explicit argv so the command reaches bash/PowerShell unmangled (see the
|
|
79
|
+
* module header).
|
|
80
|
+
*/
|
|
81
|
+
export declare function spawnShell(command: string, shell: ShellSelection, options?: SpawnOptions): ChildProcess;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnTree } from "../../utils/process-tree.js";
|
|
4
|
+
import { CruxyError, ErrorCode } from "../../errors/types.js";
|
|
5
|
+
const realEnv = {
|
|
6
|
+
platform: process.platform,
|
|
7
|
+
env: process.env,
|
|
8
|
+
exists: existsSync,
|
|
9
|
+
};
|
|
10
|
+
/** The invocation flags for each dialect (the command follows as one argv slot). */
|
|
11
|
+
function flagsFor(dialect) {
|
|
12
|
+
switch (dialect) {
|
|
13
|
+
case "powershell":
|
|
14
|
+
// `-NoProfile` skips slow/interfering user profiles; `-Command` takes the
|
|
15
|
+
// command as one string argument.
|
|
16
|
+
return ["-NoProfile", "-Command"];
|
|
17
|
+
case "cmd":
|
|
18
|
+
return ["/d", "/s", "/c"];
|
|
19
|
+
case "posix":
|
|
20
|
+
return ["-c"];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function explicit(file, dialect, source) {
|
|
24
|
+
return { kind: "explicit", file, flags: flagsFor(dialect), dialect, source };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Infer a dialect from a shell path's basename, but ONLY for the unambiguous
|
|
28
|
+
* names — anything exotic (`nu`, `fish`, `xonsh`, …) returns undefined so the
|
|
29
|
+
* caller demands an explicit `shell.dialect` rather than guessing wrong.
|
|
30
|
+
*/
|
|
31
|
+
export function sniffDialect(executable) {
|
|
32
|
+
// Split on either separator so a POSIX or a Windows path both reduce to the
|
|
33
|
+
// bare shell name, then drop a trailing `.exe`.
|
|
34
|
+
const base = executable.split(/[\\/]/).pop() ?? executable;
|
|
35
|
+
const name = base.toLowerCase().replace(/\.exe$/, "");
|
|
36
|
+
switch (name) {
|
|
37
|
+
case "bash":
|
|
38
|
+
case "sh":
|
|
39
|
+
return "posix";
|
|
40
|
+
case "pwsh":
|
|
41
|
+
case "powershell":
|
|
42
|
+
return "powershell";
|
|
43
|
+
case "cmd":
|
|
44
|
+
return "cmd";
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True for the WSL launcher (`C:\Windows\System32\bash.exe`) — a Linux shell
|
|
50
|
+
* with `/mnt/c` path semantics, never Git Bash. Explicitly rejected. */
|
|
51
|
+
function isWslBash(candidate) {
|
|
52
|
+
return /\\system32\\/i.test(candidate);
|
|
53
|
+
}
|
|
54
|
+
/** Probe the well-known Git-for-Windows install locations for `bash.exe`. */
|
|
55
|
+
function findGitBash(env) {
|
|
56
|
+
const roots = [
|
|
57
|
+
env.env["ProgramFiles"],
|
|
58
|
+
env.env["ProgramW6432"],
|
|
59
|
+
env.env["ProgramFiles(x86)"],
|
|
60
|
+
env.env["LOCALAPPDATA"] &&
|
|
61
|
+
path.win32.join(env.env["LOCALAPPDATA"], "Programs"),
|
|
62
|
+
].filter((r) => Boolean(r));
|
|
63
|
+
for (const root of roots) {
|
|
64
|
+
const candidate = path.win32.join(root, "Git", "bin", "bash.exe");
|
|
65
|
+
// Path-probe only — never `where bash`, which resolves the WSL launcher.
|
|
66
|
+
if (isWslBash(candidate))
|
|
67
|
+
continue;
|
|
68
|
+
if (env.exists(candidate))
|
|
69
|
+
return candidate;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Resolve a bare executable name against PATH (+ the win32 PATH dir walk). */
|
|
74
|
+
function onWindowsPath(name, env) {
|
|
75
|
+
const raw = env.env["PATH"] ?? env.env["Path"] ?? "";
|
|
76
|
+
for (const dir of raw.split(";").filter(Boolean)) {
|
|
77
|
+
const full = path.win32.join(dir, name);
|
|
78
|
+
if (env.exists(full))
|
|
79
|
+
return full;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/** PowerShell 7+ (`pwsh`) preferred; Windows PowerShell 5.1 the guaranteed floor. */
|
|
84
|
+
function findPowerShell(env) {
|
|
85
|
+
return onWindowsPath("pwsh.exe", env) ?? onWindowsPath("powershell.exe", env);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the shell PLAN from config + environment. Pure over its injected
|
|
89
|
+
* {@link ShellEnv}, so the win32 tiers are exercised on any CI host. Throws
|
|
90
|
+
* CRUXY_E_NO_SHELL (no usable shell) or CRUXY_E_CONFIG_INVALID (an override
|
|
91
|
+
* whose dialect can't be determined) — both fail loud, never a silent fallback.
|
|
92
|
+
*/
|
|
93
|
+
export function computeShellPlan(shell, env = realEnv) {
|
|
94
|
+
// Tier 0: explicit override always wins, on every platform.
|
|
95
|
+
if (shell.executable) {
|
|
96
|
+
const dialect = shell.dialect ?? sniffDialect(shell.executable);
|
|
97
|
+
if (!dialect) {
|
|
98
|
+
throw new CruxyError({
|
|
99
|
+
code: ErrorCode.ConfigInvalid,
|
|
100
|
+
title: "Cannot determine the dialect of the configured shell",
|
|
101
|
+
cause: `shell.executable is "${shell.executable}", but its command dialect could not be inferred from its name.`,
|
|
102
|
+
nextSteps: ["Set shell.dialect to one of: posix, powershell, cmd."],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return explicit(shell.executable, dialect, "config");
|
|
106
|
+
}
|
|
107
|
+
// POSIX with no override: the historical default shell, byte-for-byte.
|
|
108
|
+
if (env.platform !== "win32") {
|
|
109
|
+
return { kind: "system", dialect: "posix", source: "default" };
|
|
110
|
+
}
|
|
111
|
+
// Tier 1: Git Bash → full POSIX parity, model unchanged.
|
|
112
|
+
const bash = findGitBash(env);
|
|
113
|
+
if (bash)
|
|
114
|
+
return explicit(bash, "posix", "git-bash");
|
|
115
|
+
// Tier 2: PowerShell → the model is told to emit PowerShell (see prompts.ts).
|
|
116
|
+
const ps = findPowerShell(env);
|
|
117
|
+
if (ps)
|
|
118
|
+
return explicit(ps, "powershell", "powershell");
|
|
119
|
+
// Tier 3: nothing usable — fail loud, never cmd.exe-pretends-to-be-bash.
|
|
120
|
+
throw new CruxyError({
|
|
121
|
+
code: ErrorCode.NoShell,
|
|
122
|
+
title: "No usable shell found to run commands",
|
|
123
|
+
cause: "On Windows, cruxy runs commands through Git Bash or PowerShell; neither was found, and no shell.executable override is set.",
|
|
124
|
+
nextSteps: [
|
|
125
|
+
"Install Git for Windows (https://git-scm.com/download/win) to get Git Bash.",
|
|
126
|
+
"Or set shell.executable in your cruxy config to a shell of your choice (and shell.dialect if its name is non-standard).",
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Resolve once per process (JC#8): detection touches the filesystem, and the
|
|
131
|
+
// answer is stable for a session. `computeShellPlan` stays the injectable,
|
|
132
|
+
// un-memoized unit the tests drive.
|
|
133
|
+
let cached;
|
|
134
|
+
/** The memoized production resolver. Throws (fail loud) exactly as compute does. */
|
|
135
|
+
export function resolveShell(shell) {
|
|
136
|
+
return (cached ??= computeShellPlan(shell));
|
|
137
|
+
}
|
|
138
|
+
/** Test hook: drop the memoized plan so the next resolve re-detects. */
|
|
139
|
+
export function __resetShellResolution() {
|
|
140
|
+
cached = undefined;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Spawn `command` through the resolved shell as a killable process tree — the
|
|
144
|
+
* single seam both `run_command` and `run_tests` route through. `system` keeps
|
|
145
|
+
* the exact `{ shell: true }` spawn; every other plan invokes the shell binary
|
|
146
|
+
* with explicit argv so the command reaches bash/PowerShell unmangled (see the
|
|
147
|
+
* module header).
|
|
148
|
+
*/
|
|
149
|
+
export function spawnShell(command, shell, options = {}) {
|
|
150
|
+
const plan = resolveShell(shell);
|
|
151
|
+
if (plan.kind === "system") {
|
|
152
|
+
return spawnTree(command, [], { ...options, shell: true });
|
|
153
|
+
}
|
|
154
|
+
return spawnTree(plan.file, [...plan.flags, command], {
|
|
155
|
+
...options,
|
|
156
|
+
shell: false,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The process-EXIT backstop for long-lived child trees (C.12 LSP servers, C.27
|
|
3
|
+
* MCP servers). The platform-aware spawn/kill primitives themselves now live in
|
|
4
|
+
* {@link ./process-tree.js} — {@link killTree} is re-exported here unchanged so
|
|
5
|
+
* existing LSP/MCP importers keep their import path, and so this backstop and
|
|
6
|
+
* those transports reap trees the SAME way on every platform (negative-PID
|
|
7
|
+
* `SIGKILL` on POSIX, `taskkill /T /F` on win32 — no more orphaned grandchildren
|
|
8
|
+
* on Windows).
|
|
7
9
|
*
|
|
8
10
|
* A per-session graceful shutdown covers the normal path, but a hard exit
|
|
9
11
|
* (Ctrl-C, an uncaught throw) would otherwise orphan these trees. So every live
|
|
@@ -15,12 +17,8 @@
|
|
|
15
17
|
* and MCP (newline-delimited JSON) share the SAME backstop, so `killTrackedTrees`
|
|
16
18
|
* on exit reaps both and there is a single source of truth for "no orphans".
|
|
17
19
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
* a direct kill when there is no group (or on win32). Swallows errors — the
|
|
21
|
-
* process may already be gone.
|
|
22
|
-
*/
|
|
23
|
-
export declare function killTree(pid: number | undefined): void;
|
|
20
|
+
import { killTree } from "./process-tree.js";
|
|
21
|
+
export { killTree };
|
|
24
22
|
/**
|
|
25
23
|
* Force-kill the process group of every tracked-but-not-yet-shut-down child,
|
|
26
24
|
* then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
|