@cruxy/cli 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The terminal-restore backstop (Q4 track 1).
3
+ *
4
+ * `TuiRenderer.close()` hands the terminal back — it erases the frame, and from
5
+ * track 3 on it also leaves the alternate screen and shows the cursor again.
6
+ * That covers exactly one way a run ends: the orderly one. Every other way, the
7
+ * process dies with the shell still painted and the terminal still configured
8
+ * the way the TUI left it, and the user's next prompt lands in the middle of a
9
+ * frame that nothing will ever erase. `kill -TERM`, a hangup when the terminal
10
+ * window closes, an `ssh` session dropping, `kill -INT` from another pane — all
11
+ * of them terminate the process by DEFAULT DISPOSITION, which runs no JavaScript
12
+ * at all.
13
+ *
14
+ * So the restore is registered with the process rather than left to the caller:
15
+ *
16
+ * - **`SIGINT` / `SIGTERM` / `SIGHUP`** — restore, then exit with the shell's
17
+ * conventional 128 + signal code. `process.exit` rather than re-raising is
18
+ * deliberate: Node restores the original termios (`ResetStdio`, an `atexit`
19
+ * hook) on a normal exit but not when the default disposition fells the
20
+ * process, and the TUI holds stdin in RAW MODE for most of its life. Dying
21
+ * from the signal itself would leave a terminal with no echo and no line
22
+ * editing — a worse outcome than the painted frame this module exists to
23
+ * clean up.
24
+ * - **`exit`** — the last line, for the paths no signal handler sees: an
25
+ * uncaught throw, an explicit `process.exit` somewhere else, and the signal
26
+ * handlers that other modules install first ({@link ../utils/child-tree.js}
27
+ * registers `SIGINT` too, and whichever ran first calls `process.exit`
28
+ * straight away). Synchronous work only, which a write to a TTY is.
29
+ *
30
+ * Handlers are installed on the FIRST registration and removed when the last
31
+ * guard goes away, so a closed TUI leaves the process exactly as it found it and
32
+ * N renderers (a test file builds dozens) still cost one listener per event.
33
+ * This is the same shape as the child-process exit backstop in
34
+ * {@link ../utils/child-tree.js}, for the same reason.
35
+ */
36
+ /** Signals that end a run before the normal teardown gets a turn. */
37
+ export const RESTORE_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
38
+ /**
39
+ * The exit code a shell reports for a process felled by each signal: 128 + n.
40
+ * Stated rather than inherited, because these handlers exit explicitly (see the
41
+ * raw-mode note above) and an exit code of 0 after a `kill -TERM` would tell
42
+ * every script that reads one that the run succeeded.
43
+ */
44
+ export const SIGNAL_EXIT_CODES = {
45
+ SIGINT: 130,
46
+ SIGTERM: 143,
47
+ SIGHUP: 129,
48
+ };
49
+ /** Live guards, in registration order. Each entry is its own idempotent runner. */
50
+ const guards = new Set();
51
+ /** The installed listeners, or null when no guard is registered. */
52
+ let handlers = null;
53
+ /**
54
+ * Run every registered restore, once each. Exported because it IS what the
55
+ * handlers run: a test that calls this is testing the real path rather than a
56
+ * reimplementation of it.
57
+ *
58
+ * Iterates a copy — a restore deregisters itself as it runs — and a restore
59
+ * that throws does not stop the others. There is nowhere to report an error
60
+ * from here (the process is on its way out, and the screen is the thing that
61
+ * would show it), and one guard failing must not leave the next one's terminal
62
+ * in the alternate screen forever.
63
+ */
64
+ export function restoreAllScreens() {
65
+ for (const run of [...guards])
66
+ run();
67
+ }
68
+ function installHandlers(host) {
69
+ if (handlers !== null)
70
+ return;
71
+ const entries = [
72
+ ["exit", restoreAllScreens],
73
+ ...RESTORE_SIGNALS.map((signal) => [
74
+ signal,
75
+ () => {
76
+ restoreAllScreens();
77
+ host.exit(SIGNAL_EXIT_CODES[signal]);
78
+ },
79
+ ]),
80
+ ];
81
+ for (const [event, listener] of entries)
82
+ host.on(event, listener);
83
+ handlers = { host, entries };
84
+ }
85
+ function removeHandlers() {
86
+ if (handlers === null)
87
+ return;
88
+ for (const [event, listener] of handlers.entries) {
89
+ handlers.host.off(event, listener);
90
+ }
91
+ handlers = null;
92
+ }
93
+ /**
94
+ * Register `restore` to run if the process is signalled or exits, and return the
95
+ * handle that also lets the owner run it directly.
96
+ *
97
+ * The first registration installs the handlers; the last one to go removes
98
+ * them. `host` is read only on that first call — it names the process the
99
+ * handlers are installed on, and there is only ever one.
100
+ */
101
+ export function installScreenGuard(restore, host = process) {
102
+ let restored = false;
103
+ let live = true;
104
+ const deregister = () => {
105
+ if (!live)
106
+ return;
107
+ live = false;
108
+ guards.delete(run);
109
+ if (guards.size === 0)
110
+ removeHandlers();
111
+ };
112
+ const run = () => {
113
+ if (restored)
114
+ return;
115
+ restored = true;
116
+ // Deregistered BEFORE the restore runs, so a throwing restore still gives
117
+ // the handlers up rather than pinning them for the life of the process.
118
+ deregister();
119
+ try {
120
+ restore();
121
+ }
122
+ catch {
123
+ // See `restoreAllScreens`: there is no surface left to report on.
124
+ }
125
+ };
126
+ guards.add(run);
127
+ installHandlers(host);
128
+ return {
129
+ restoreScreen: run,
130
+ restored: () => restored,
131
+ dispose: deregister,
132
+ };
133
+ }
134
+ /** Guards currently registered — for tests, the way `trackedTreeCount` is. */
135
+ export function screenGuardCount() {
136
+ return guards.size;
137
+ }
@@ -1,3 +1,4 @@
1
+ import { isSet } from "../render/capabilities.js";
1
2
  /**
2
3
  * Whether the environment can host the full-viewport TUI.
3
4
  *
@@ -18,3 +19,24 @@
18
19
  export function supportsTui(caps) {
19
20
  return caps.interactive && !caps.screenReader;
20
21
  }
22
+ /**
23
+ * Whether the TUI runs on the ALTERNATE SCREEN (Q4 track 3) — the terminal's
24
+ * second buffer, the one `less` and `vim` use.
25
+ *
26
+ * Gated on {@link supportsTui} and nothing new: a terminal that cannot host the
27
+ * shell has no business being switched to another buffer, and the two questions
28
+ * have exactly one answer between them. Adding a second capability check here
29
+ * would only create a state where the TUI runs but its screen management does
30
+ * not — which is the drift the one gate exists to prevent.
31
+ *
32
+ * `CRUXY_NO_ALT_SCREEN` opts out, following the same set-and-non-empty rule as
33
+ * every other cruxy flag. The opt-out exists because the alternate screen is not
34
+ * universally available or wanted: multiplexers and terminal emulators can be
35
+ * configured to refuse it, some capture-and-replay tooling reads only the normal
36
+ * buffer, and a user who prefers the shell to keep the frame in its scrollback
37
+ * should not have to stop using the TUI to get that. Opting out changes nothing
38
+ * else — the shell, the row addressing and the restore backstop are unaffected.
39
+ */
40
+ export function usesAltScreen(caps, env = process.env) {
41
+ return supportsTui(caps) && !isSet(env.CRUXY_NO_ALT_SCREEN);
42
+ }
@@ -1,4 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ // The concrete module, not `lsp/index.js`: that barrel pulls in the transport,
4
+ // the pool and the client, and this panel exists to keep cost off the startup
5
+ // path. `resolveBinary` is a leaf over `node:fs`.
6
+ import { resolveBinary } from "../lsp/registry.js";
2
7
  /**
3
8
  * Host tool versions for the rail (P4 track 5).
4
9
  *
@@ -20,9 +25,82 @@ import { execFile } from "node:child_process";
20
25
  * DAEMON and reports a stopped Docker as absent. This panel is reporting what
21
26
  * is INSTALLED, so it asks the client (`docker --version`), which answers with
22
27
  * the daemon down.
28
+ *
29
+ * WHICH tsc, though. The panel sits beside a repo, and the toolchain that
30
+ * matters there is the repo's: a project pinning TypeScript 5.4 in its
31
+ * devDependencies was being reported at whatever version happens to be global,
32
+ * or as "not found" when nothing is global at all — the ordinary case, since
33
+ * `pnpm`/`npx` run these out of `node_modules/.bin` and most people never
34
+ * install them globally. So each tool is looked for in `node_modules/.bin`,
35
+ * walking up from the working directory the way Node's own resolution does,
36
+ * before falling back to `PATH`.
37
+ *
38
+ * That lookup is {@link resolveBinary}, the LSP registry's — which also fixes a
39
+ * latent Windows bug this module had on the PATH half. Windows installs
40
+ * npm-shipped tools as `.cmd` shims, and `execFile("tsc")` there resolves
41
+ * nothing: `tsc.cmd` needs the `PATHEXT` sweep the registry already does by
42
+ * stat, and then needs `cmd.exe` to run it at all.
23
43
  */
24
44
  /** Hard ceiling on one probe; a hung binary must never wedge the panel. */
25
45
  const PROBE_TIMEOUT_MS = 5000;
46
+ /**
47
+ * Every `node_modules/.bin` from `from` up to the filesystem root, nearest
48
+ * first — the same walk Node's module resolution does, and the same order
49
+ * `pnpm`/`npx` pick a binary in. Directories that do not exist cost nothing:
50
+ * `resolveBinary` stats the candidate and moves on.
51
+ */
52
+ export function binDirsFrom(from) {
53
+ const dirs = [];
54
+ let dir = path.resolve(from);
55
+ for (;;) {
56
+ dirs.push(path.join(dir, "node_modules", ".bin"));
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir)
59
+ return dirs;
60
+ dir = parent;
61
+ }
62
+ }
63
+ /**
64
+ * Where a probe should actually spawn `bin` from: the nearest project-local
65
+ * copy, else whatever `PATH` offers, else `null` — which is an ANSWER ("not
66
+ * installed"), reached without spawning anything.
67
+ */
68
+ export function resolveToolBinary(bin, from) {
69
+ return resolveBinary(bin, binDirsFrom(from)) ?? resolveBinary(bin);
70
+ }
71
+ /**
72
+ * How to invoke a resolved binary.
73
+ *
74
+ * On win32 a `.cmd`/`.bat` is a script, not an image: `CreateProcess` cannot run
75
+ * one, and Node has REFUSED to try since the CVE-2024-27980 fix — it throws
76
+ * EINVAL rather than silently handing the arguments to a shell. So a shim is run
77
+ * through `cmd.exe` explicitly. Everything else is spawned directly, unchanged.
78
+ *
79
+ * THE DOUBLE WRAP IS NOT A TYPO, and it is the whole reason this is a named
80
+ * function with its own tests. cmd.exe strips the first and last quote of the
81
+ * `/c` string when the string both begins and ends with one. Quoting only the
82
+ * path gives `cmd /c "C:\Program Files\x\t.cmd"` — begins and ends with a quote,
83
+ * so cmd removes both and then tries to run `C:\Program`. Appending `--version`
84
+ * happens to hide it (the string no longer ends in a quote), which is the worst
85
+ * kind of working: it breaks the day a probe takes no arguments, and every tool
86
+ * here is one `args: []` away from that.
87
+ *
88
+ * Wrapping the whole command line in a second pair makes `/s` the rule instead
89
+ * of an accident: `/s` strips exactly the outer pair and preserves the rest
90
+ * verbatim, so the inner quotes reach the parser intact either way. Node must
91
+ * not re-quote on top, hence `windowsVerbatimArguments` at the spawn.
92
+ */
93
+ export function invocationFor(file, args, platform = process.platform, comSpec = process.env.ComSpec ?? "cmd.exe") {
94
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
95
+ const line = [`"${file}"`, ...args].join(" ");
96
+ return {
97
+ file: comSpec,
98
+ args: ["/d", "/s", "/c", `"${line}"`],
99
+ verbatim: true,
100
+ };
101
+ }
102
+ return { file, args: [...args], verbatim: false };
103
+ }
26
104
  /**
27
105
  * Pull a semantic version out of a tool's `--version` line.
28
106
  *
@@ -35,24 +113,47 @@ const PROBE_TIMEOUT_MS = 5000;
35
113
  export function parseVersion(output) {
36
114
  return /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
37
115
  }
38
- /** Default probe: spawn, capture, and treat any failure as "not installed". */
39
- const spawnProbe = (bin, args) => new Promise((resolve) => {
40
- try {
41
- execFile(bin, [...args], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS, windowsHide: true }, (err, stdout, stderr) => {
42
- // Some tools print their version to stderr; a non-zero exit with a
43
- // parseable version still tells us the tool is there.
44
- const text = `${stdout ?? ""}${stderr ?? ""}`;
45
- if (err && text.trim() === "")
46
- resolve(null);
47
- else
48
- resolve(text);
49
- });
50
- }
51
- catch {
52
- // execFile can throw synchronously on a malformed binary path.
53
- resolve(null);
54
- }
55
- });
116
+ /**
117
+ * The default probe: resolve, spawn, capture and treat any failure as "not
118
+ * installed". Resolution comes first, so a tool that is nowhere costs a few
119
+ * `stat` calls rather than a process that has to fail.
120
+ *
121
+ * `from` exists so the win32 e2e can point a real probe at a real `.cmd` shim in
122
+ * a temp project. Left unset it reads `process.cwd()` PER CALL, never once at
123
+ * module load the panel must follow the session's directory, not the one the
124
+ * process happened to start in.
125
+ */
126
+ export function makeSpawnProbe(from) {
127
+ return (bin, args) => new Promise((resolve) => {
128
+ const resolved = resolveToolBinary(bin, from ?? process.cwd());
129
+ if (resolved === null) {
130
+ resolve(null);
131
+ return;
132
+ }
133
+ const call = invocationFor(resolved, args);
134
+ try {
135
+ execFile(call.file, call.args, {
136
+ encoding: "utf8",
137
+ timeout: PROBE_TIMEOUT_MS,
138
+ windowsHide: true,
139
+ ...(call.verbatim ? { windowsVerbatimArguments: true } : {}),
140
+ }, (err, stdout, stderr) => {
141
+ // Some tools print their version to stderr; a non-zero exit with a
142
+ // parseable version still tells us the tool is there.
143
+ const text = `${stdout ?? ""}${stderr ?? ""}`;
144
+ if (err && text.trim() === "")
145
+ resolve(null);
146
+ else
147
+ resolve(text);
148
+ });
149
+ }
150
+ catch {
151
+ // execFile can throw synchronously on a malformed binary path.
152
+ resolve(null);
153
+ }
154
+ });
155
+ }
156
+ const spawnProbe = makeSpawnProbe();
56
157
  /**
57
158
  * The tools reported, in display order.
58
159
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "undici": "^6.21.0",
37
37
  "zod": "^3.23.8",
38
38
  "zod-to-json-schema": "^3.23.5",
39
- "@cruxy/sdk": "0.5.0"
39
+ "@cruxy/sdk": "0.6.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"