Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c067abc68f | |||
| b0cf2132d1 |
@@ -54,6 +54,9 @@ sudo pacman -S git tmux # Arch
|
|||||||
|
|
||||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||||
|
On a native Windows install, workspace terminals are unavailable, but Settings →
|
||||||
|
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
||||||
|
and runs a `.cmd` updater or npm through `cmd.exe`.
|
||||||
|
|
||||||
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
||||||
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
|
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
|
||||||
|
|||||||
@@ -6955,7 +6955,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-[10px] text-status-warning/90 mt-1"
|
class="text-[10px] text-status-warning/90 mt-1"
|
||||||
>
|
>
|
||||||
UI build v2.0.0 (rebuild client to match)
|
UI build v2.0.1 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
|
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
|
||||||
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
|
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
|
||||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||||
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.6+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
|
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
|
||||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
|
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
|
||||||
|
|
||||||
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.0.0
|
version: 2.0.1
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -330,3 +330,65 @@ describe("install/update fields and trust tiers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("which() on Windows", () => {
|
||||||
|
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||||
|
const realPathext = process.env.PATHEXT;
|
||||||
|
const winDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-win-"));
|
||||||
|
// Executable bits only for the Linux host running the suite: Windows'
|
||||||
|
// X_OK check is plain existence.
|
||||||
|
for (const f of ["claude.exe", "codex.cmd"])
|
||||||
|
fs.writeFileSync(path.join(winDir, f), "", { mode: 0o755 });
|
||||||
|
const setPlatform = (p) => Object.defineProperty(process, "platform", { value: p });
|
||||||
|
after(() => {
|
||||||
|
Object.defineProperty(process, "platform", realPlatform);
|
||||||
|
if (realPathext === undefined) delete process.env.PATHEXT;
|
||||||
|
else process.env.PATHEXT = realPathext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries each PATHEXT extension after the bare name", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
process.env.PATHEXT = ".EXE;.CMD";
|
||||||
|
assert.equal(aiTools.which("claude", winDir), path.join(winDir, "claude.exe"));
|
||||||
|
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips npm's extensionless sh shim, which Windows cannot run", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
process.env.PATHEXT = ".EXE;.CMD";
|
||||||
|
fs.writeFileSync(path.join(winDir, "codex"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
try {
|
||||||
|
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
|
||||||
|
assert.equal(aiTools.which("claude.exe", winDir), path.join(winDir, "claude.exe"));
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(path.join(winDir, "codex"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .EXE;.CMD;.BAT without PATHEXT", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
delete process.env.PATHEXT;
|
||||||
|
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs a .cmd/.bat through cmd.exe, its path quoted for Program Files", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
const npm = "C:\\Program Files\\nodejs\\npm.cmd";
|
||||||
|
assert.deepEqual(aiTools.spawnTarget(npm), { file: `"${npm}"`, shell: true });
|
||||||
|
assert.deepEqual(aiTools.spawnTarget("C:\\x\\claude.exe"), {
|
||||||
|
file: "C:\\x\\claude.exe",
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
setPlatform("linux");
|
||||||
|
assert.deepEqual(aiTools.spawnTarget("/usr/bin/npm.cmd"), {
|
||||||
|
file: "/usr/bin/npm.cmd",
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no extension anywhere else", () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
process.env.PATHEXT = ".EXE;.CMD";
|
||||||
|
assert.equal(aiTools.which("claude", winDir), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ function runArgv(j, argv) {
|
|||||||
resolve(code == null ? 1 : code);
|
resolve(code == null ? 1 : code);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
child = spawnImpl(argv[0], argv.slice(1), {
|
const { file, shell } = aiTools.spawnTarget(argv[0]);
|
||||||
|
child = spawnImpl(file, argv.slice(1), {
|
||||||
|
shell,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: { ...process.env, PATH: versions.jobPath() },
|
env: { ...process.env, PATH: versions.jobPath() },
|
||||||
detached: true, // its own process group, so a timeout reaches grandchildren
|
detached: true, // its own process group, so a timeout reaches grandchildren
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ function npmBin() {
|
|||||||
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
|
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = (file, args) =>
|
const run = (bin, args) => {
|
||||||
exec(file, args, { timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
|
const { file, shell } = aiTools.spawnTarget(bin);
|
||||||
|
return exec(file, args, { shell, timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
|
||||||
|
};
|
||||||
|
|
||||||
const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0];
|
const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0];
|
||||||
|
|
||||||
|
|||||||
+38
-7
@@ -260,19 +260,49 @@ const has = (id) => typeof id === "string" && cat().has(id);
|
|||||||
function which(name, pathStr = process.env.PATH || "") {
|
function which(name, pathStr = process.env.PATH || "") {
|
||||||
for (const dir of pathStr.split(path.delimiter)) {
|
for (const dir of pathStr.split(path.delimiter)) {
|
||||||
if (!dir) continue;
|
if (!dir) continue;
|
||||||
const p = path.join(dir, name);
|
for (const candidate of executableNames(name)) {
|
||||||
try {
|
const p = path.join(dir, candidate);
|
||||||
if (fs.statSync(p).isFile()) {
|
try {
|
||||||
fs.accessSync(p, fs.constants.X_OK);
|
if (fs.statSync(p).isFile()) {
|
||||||
return p;
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not here */
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* not here */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file names `name` can run as. On Windows only a `PATHEXT` extension
|
||||||
|
* makes a file runnable (`claude.exe`, `codex.cmd`): the bare name is tried
|
||||||
|
* only when it already carries one, because npm's extensionless `codex` next
|
||||||
|
* to `codex.cmd` is a POSIX sh shim Windows cannot start.
|
||||||
|
*/
|
||||||
|
function executableNames(name) {
|
||||||
|
if (process.platform !== "win32") return [name];
|
||||||
|
const exts = (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean);
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (exts.some((e) => lower.endsWith(e.toLowerCase()))) return [name];
|
||||||
|
return exts.map((e) => name + e.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to start `file` with spawn/execFile: on Windows a `.cmd`/`.bat` (npm,
|
||||||
|
* an npm-installed CLI) runs only through cmd.exe — Node refuses it otherwise
|
||||||
|
* (EINVAL, CVE-2024-27980) — so it gets `shell: true` and a quoted path, as
|
||||||
|
* `C:\Program Files\nodejs\npm.cmd` holds a space. Only for argv built from
|
||||||
|
* release catalog files, never from a request: cmd.exe re-parses the args.
|
||||||
|
*/
|
||||||
|
function spawnTarget(file) {
|
||||||
|
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
|
||||||
|
return { file: `"${file}"`, shell: true };
|
||||||
|
}
|
||||||
|
return { file, shell: false };
|
||||||
|
}
|
||||||
|
|
||||||
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
|
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
|
||||||
function detect(id, { fresh = false } = {}) {
|
function detect(id, { fresh = false } = {}) {
|
||||||
if (!fresh && detected.has(id)) return detected.get(id);
|
if (!fresh && detected.has(id)) return detected.get(id);
|
||||||
@@ -426,6 +456,7 @@ module.exports = {
|
|||||||
view,
|
view,
|
||||||
validate,
|
validate,
|
||||||
which,
|
which,
|
||||||
|
spawnTarget,
|
||||||
siteOf,
|
siteOf,
|
||||||
ROUTER_CAPABLE,
|
ROUTER_CAPABLE,
|
||||||
CONFIG_SECRET_RE,
|
CONFIG_SECRET_RE,
|
||||||
|
|||||||
Reference in New Issue
Block a user