Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 |
@@ -85,14 +85,14 @@ export function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={message ? messageId : undefined}
|
aria-describedby={message ? messageId : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-5">
|
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ function QuotaPanel({
|
|||||||
onMouseLeave={onLeave}
|
onMouseLeave={onLeave}
|
||||||
onFocus={onEnter}
|
onFocus={onEnter}
|
||||||
onBlur={onLeave}
|
onBlur={onLeave}
|
||||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||||
>
|
>
|
||||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||||
{groups.map(([id, list]) => (
|
{groups.map(([id, list]) => (
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function UpdateNotifier() {
|
|||||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||||
@@ -370,7 +370,7 @@ export function UpdateNotifier() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 py-4 space-y-3">
|
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="cc-confirm-delete-title"
|
aria-labelledby="cc-confirm-delete-title"
|
||||||
aria-describedby="cc-confirm-delete-body"
|
aria-describedby="cc-confirm-delete-body"
|
||||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||||
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
|
|||||||
{t("edit.confirmDelete")}
|
{t("edit.confirmDelete")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-3">
|
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
|
||||||
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
||||||
{t("edit.confirmDeleteBody")}
|
{t("edit.confirmDeleteBody")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1229,7 +1229,7 @@ function ClearDataDialog({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="clear-data-title"
|
aria-labelledby="clear-data-title"
|
||||||
aria-describedby="clear-data-body"
|
aria-describedby="clear-data-body"
|
||||||
className="card w-full max-w-md space-y-4 border-status-danger/40 p-5"
|
className="card max-h-full w-full max-w-md space-y-4 overflow-y-auto border-status-danger/40 p-5"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
|
|||||||
@@ -7233,7 +7233,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.5.0 (rebuild client to match)
|
UI build v2.5.3 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@ waits on an install:
|
|||||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||||
6. **Legacy hook cleanup** — see above.
|
6. **Legacy hook cleanup** — see above.
|
||||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||||
|
|||||||
+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.5.0
|
version: 2.5.3
|
||||||
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.5.0",
|
"version": "2.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.0",
|
"version": "2.5.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.0",
|
"version": "2.5.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { rotateIfLarge } = require("./log-rotate");
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|||||||
+68
-38
@@ -26,6 +26,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { rotateIfLarge } = require("./log-rotate");
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
@@ -383,6 +384,29 @@ function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath
|
|||||||
|
|
||||||
/* ------------------------------------------------------------- dependencies */
|
/* ------------------------------------------------------------- dependencies */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* npm, run by the node executing this file: `{ file, args, env, shell }` for
|
||||||
|
* `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so
|
||||||
|
* nothing depends on PATH — the self-update chain runs with the dashboard
|
||||||
|
* server's environment, and on Windows that PATH need not hold the directory
|
||||||
|
* of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs.
|
||||||
|
* PATH is still prefixed with that node's directory so npm's scripts (the UI
|
||||||
|
* build's `tsc`/`vite` shims) find the same node. A node with no bundled npm
|
||||||
|
* (a distro's split nodejs/npm packages) falls back to `npm` on PATH.
|
||||||
|
*/
|
||||||
|
function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) {
|
||||||
|
const dir = path.dirname(execPath);
|
||||||
|
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
|
||||||
|
const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) };
|
||||||
|
const cli = [
|
||||||
|
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout
|
||||||
|
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout
|
||||||
|
].find((p) => fs.existsSync(p));
|
||||||
|
if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false };
|
||||||
|
const win = process.platform === "win32";
|
||||||
|
return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
||||||
* the root postinstall from pulling the whole Vite client toolchain; the
|
* the root postinstall from pulling the whole Vite client toolchain; the
|
||||||
@@ -395,12 +419,13 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
|||||||
const src = path.join(root, f);
|
const src = path.join(root, f);
|
||||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||||
}
|
}
|
||||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
||||||
const res = spawnSync(
|
const res = spawnSync(npm.file, npm.args, {
|
||||||
npm,
|
cwd: rt,
|
||||||
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
stdio: "inherit",
|
||||||
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
env: npm.env,
|
||||||
);
|
shell: npm.shell,
|
||||||
|
});
|
||||||
return res.status === 0;
|
return res.status === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,6 +854,43 @@ function main() {
|
|||||||
child.unref();
|
child.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exported before the entry block below, not after it: `bootstrap()` requires
|
||||||
|
// plugin-open.js, which requires this module back (`npmCommand`, `runtimeDir`),
|
||||||
|
// and when this file is the entry that require resolves to whatever is on
|
||||||
|
// module.exports at that moment — an empty object if it is assigned last.
|
||||||
|
module.exports = {
|
||||||
|
MIN_NODE,
|
||||||
|
LOCK_STALE_MS,
|
||||||
|
KEEP_RETIRED_ROOTS,
|
||||||
|
supersededBy,
|
||||||
|
pruneRetiredPluginRoots,
|
||||||
|
installedPluginsPath,
|
||||||
|
pluginVersion,
|
||||||
|
runtimeDir,
|
||||||
|
statePath,
|
||||||
|
readState,
|
||||||
|
writeState,
|
||||||
|
depsHash,
|
||||||
|
fastPathOk,
|
||||||
|
nodeVersionOk,
|
||||||
|
lockPath,
|
||||||
|
lockIsStale,
|
||||||
|
acquireLock,
|
||||||
|
releaseLock,
|
||||||
|
isCheckoutHookEntry,
|
||||||
|
stripLegacyHooks,
|
||||||
|
hasLegacyHooks,
|
||||||
|
linkCli,
|
||||||
|
npmCommand,
|
||||||
|
installDeps,
|
||||||
|
serverIsLive,
|
||||||
|
liveServers,
|
||||||
|
livePids,
|
||||||
|
stopDashboard,
|
||||||
|
startDashboard,
|
||||||
|
bootstrap,
|
||||||
|
};
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const force = process.argv.includes("--force");
|
const force = process.argv.includes("--force");
|
||||||
if (force || process.argv.includes("--worker")) {
|
if (force || process.argv.includes("--worker")) {
|
||||||
@@ -854,35 +916,3 @@ if (require.main === module) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
MIN_NODE,
|
|
||||||
LOCK_STALE_MS,
|
|
||||||
KEEP_RETIRED_ROOTS,
|
|
||||||
supersededBy,
|
|
||||||
pruneRetiredPluginRoots,
|
|
||||||
installedPluginsPath,
|
|
||||||
pluginVersion,
|
|
||||||
runtimeDir,
|
|
||||||
statePath,
|
|
||||||
readState,
|
|
||||||
writeState,
|
|
||||||
depsHash,
|
|
||||||
fastPathOk,
|
|
||||||
nodeVersionOk,
|
|
||||||
lockPath,
|
|
||||||
lockIsStale,
|
|
||||||
acquireLock,
|
|
||||||
releaseLock,
|
|
||||||
isCheckoutHookEntry,
|
|
||||||
stripLegacyHooks,
|
|
||||||
hasLegacyHooks,
|
|
||||||
linkCli,
|
|
||||||
installDeps,
|
|
||||||
serverIsLive,
|
|
||||||
liveServers,
|
|
||||||
livePids,
|
|
||||||
stopDashboard,
|
|
||||||
startDashboard,
|
|
||||||
bootstrap,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -125,10 +125,9 @@ function runtimeDepsStatus(rt) {
|
|||||||
|
|
||||||
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
||||||
function npmStatus() {
|
function npmStatus() {
|
||||||
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
|
// The same npm the bootstrap and the UI build run (`npmCommand`), not PATH's.
|
||||||
encoding: "utf8",
|
const npm = boot.npmCommand(["-v"]);
|
||||||
shell: process.platform === "win32",
|
const res = spawnSync(npm.file, npm.args, { encoding: "utf8", env: npm.env, shell: npm.shell });
|
||||||
});
|
|
||||||
if (res.status !== 0) {
|
if (res.status !== 0) {
|
||||||
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ const boot = require("./plugin-bootstrap");
|
|||||||
const { resolveDashboardPort } = require("../server/lib/server-info");
|
const { resolveDashboardPort } = require("../server/lib/server-info");
|
||||||
|
|
||||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||||
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
const NPM = "npm";
|
||||||
|
|
||||||
function run(cmd, args, cwd, stdio) {
|
/** `cmd` is always NPM: run through `boot.npmCommand`, never a PATH lookup. */
|
||||||
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
|
function run(_cmd, args, cwd, stdio) {
|
||||||
|
const npm = boot.npmCommand(args);
|
||||||
|
const res = spawnSync(npm.file, npm.args, { cwd, stdio, env: npm.env, shell: npm.shell });
|
||||||
return res.status === 0;
|
return res.status === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { rotateIfLarge } = require("./log-rotate");
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
const [, , pidArg, entry] = process.argv;
|
const [, , pidArg, entry] = process.argv;
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ describe("native-dialog", () => {
|
|||||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives the Windows dialog a TopMost owner, so it does not open behind the browser", () => {
|
||||||
|
for (const kind of ["dir", "file"]) {
|
||||||
|
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
||||||
|
assert.equal(cmd, "powershell.exe");
|
||||||
|
const script = args.at(-1);
|
||||||
|
assert.match(script, /\$o\.TopMost = \$true;.*\$o\.Show\(\);.*\$d\.ShowDialog\(\$o\)/);
|
||||||
|
assert.ok(!script.includes("C:\\x"), "the folder travels in env, never the script");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"answers the chosen path, null on Cancel, and falls through a missing program",
|
"answers the chosen path, null on Cancel, and falls through a missing program",
|
||||||
{
|
{
|
||||||
@@ -95,10 +105,16 @@ describe("native-dialog", () => {
|
|||||||
|
|
||||||
it("tells a Cancel from a failure per program", () => {
|
it("tells a Cancel from a failure per program", () => {
|
||||||
assert.equal(nd.isCancel("zenity", ""), true);
|
assert.equal(nd.isCancel("zenity", ""), true);
|
||||||
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true);
|
assert.equal(
|
||||||
|
nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"),
|
||||||
|
true
|
||||||
|
);
|
||||||
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
|
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
|
||||||
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
|
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
|
||||||
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false);
|
assert.equal(
|
||||||
|
nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"),
|
||||||
|
false
|
||||||
|
);
|
||||||
assert.equal(nd.isCancel("powershell.exe", ""), true);
|
assert.equal(nd.isCancel("powershell.exe", ""), true);
|
||||||
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
|
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -651,3 +651,62 @@ describe("pruneRetiredPluginRoots", () => {
|
|||||||
assert.ok(fs.existsSync(sibling));
|
assert.ok(fs.existsSync(sibling));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("npmCommand", () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-npmcmd-"));
|
||||||
|
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||||
|
const cliAt = (...parts) => {
|
||||||
|
const p = path.join(tmp, ...parts);
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, "");
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("runs the node's own bundled npm (Windows layout), no shell, no PATH lookup", () => {
|
||||||
|
// The "'npm.cmd' is not recognized" failure: the update chain's PATH had no npm.
|
||||||
|
const cli = cliAt("win", "node_modules", "npm", "bin", "npm-cli.js");
|
||||||
|
const execPath = path.join(tmp, "win", "node.exe");
|
||||||
|
const npm = boot.npmCommand(["install"], { execPath, env: { Path: "C:\\other" } });
|
||||||
|
assert.equal(npm.file, execPath);
|
||||||
|
assert.deepEqual(npm.args, [cli, "install"]);
|
||||||
|
assert.equal(npm.shell, false);
|
||||||
|
// Windows env keys keep their own case — a second "PATH" key would be ambiguous.
|
||||||
|
assert.equal(npm.env.Path, [path.dirname(execPath), "C:\\other"].join(path.delimiter));
|
||||||
|
assert.equal(npm.env.PATH, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the Unix layout (bin/node + lib/node_modules/npm)", () => {
|
||||||
|
const cli = cliAt("unix", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
||||||
|
const execPath = path.join(tmp, "unix", "bin", "node");
|
||||||
|
const npm = boot.npmCommand(["-v"], { execPath, env: { PATH: "/usr/bin" } });
|
||||||
|
assert.deepEqual(npm.args, [
|
||||||
|
path.join(tmp, "unix", "bin", "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
||||||
|
"-v",
|
||||||
|
]);
|
||||||
|
assert.ok(fs.existsSync(npm.args[0]) && path.resolve(npm.args[0]) === cli);
|
||||||
|
assert.equal(npm.env.PATH.split(path.delimiter)[0], path.dirname(execPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to npm on PATH when the node has no bundled npm", () => {
|
||||||
|
const npm = boot.npmCommand(["-v"], { execPath: path.join(tmp, "bare", "node"), env: {} });
|
||||||
|
assert.equal(npm.file, process.platform === "win32" ? "npm.cmd" : "npm");
|
||||||
|
assert.deepEqual(npm.args, ["-v"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entry scripts export before they run", () => {
|
||||||
|
// plugin-bootstrap.js run as the entry calls bootstrap() → plugin-open.js,
|
||||||
|
// which requires plugin-bootstrap back and gets whatever module.exports holds
|
||||||
|
// at that moment. Assigned after the entry block it was still {}, and v2.5.1's
|
||||||
|
// update died with "boot.npmCommand is not a function". Requiring the module
|
||||||
|
// (as every other test here does) never takes that path, hence this check.
|
||||||
|
for (const file of ["plugin-bootstrap.js", "plugin-open.js"]) {
|
||||||
|
it(file, () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, "..", "..", "scripts", file), "utf8");
|
||||||
|
const exportsAt = src.indexOf("\nmodule.exports = {");
|
||||||
|
const entryAt = src.indexOf("\nif (require.main === module)");
|
||||||
|
assert.ok(exportsAt > 0 && entryAt > 0, "both markers present");
|
||||||
|
assert.ok(exportsAt < entryAt, "module.exports must be assigned before the entry block");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for server/lib/windows-hide.js: on win32 every child process,
|
||||||
|
* async or sync, is started with `windowsHide: true`; elsewhere nothing changes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { install } = require("../lib/windows-hide");
|
||||||
|
|
||||||
|
function fakeCp() {
|
||||||
|
const seen = [];
|
||||||
|
class ChildProcess {
|
||||||
|
spawn(options) {
|
||||||
|
seen.push(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rec = (...args) => seen.push(args);
|
||||||
|
return { seen, cp: { ChildProcess, spawnSync: rec, execFileSync: rec, execSync: rec } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("win32: async spawns and every sync form get windowsHide", () => {
|
||||||
|
const { seen, cp } = fakeCp();
|
||||||
|
install(cp, "win32");
|
||||||
|
install(cp, "win32"); // idempotent: no double wrap
|
||||||
|
|
||||||
|
new cp.ChildProcess().spawn({ file: "git", windowsHide: false });
|
||||||
|
cp.execFileSync("git", ["status"]);
|
||||||
|
cp.execFileSync("git", ["status"], { cwd: "x" });
|
||||||
|
cp.execFileSync("git", { cwd: "x" });
|
||||||
|
cp.execSync("dir");
|
||||||
|
cp.spawnSync("git", ["log"], undefined);
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
{ file: "git", windowsHide: true },
|
||||||
|
["git", ["status"], { windowsHide: true }],
|
||||||
|
["git", ["status"], { cwd: "x", windowsHide: true }],
|
||||||
|
["git", { cwd: "x", windowsHide: true }],
|
||||||
|
["dir", { windowsHide: true }],
|
||||||
|
["git", ["log"], { windowsHide: true }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other platforms are left alone", () => {
|
||||||
|
const { cp } = fakeCp();
|
||||||
|
const before = { ...cp, spawn: cp.ChildProcess.prototype.spawn };
|
||||||
|
install(cp, "linux");
|
||||||
|
assert.equal(cp.execSync, before.execSync);
|
||||||
|
assert.equal(cp.ChildProcess.prototype.spawn, before.spawn);
|
||||||
|
});
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// First, before any module destructures child_process: no console window per
|
||||||
|
// git/npm call on Windows (server/lib/windows-hide.js).
|
||||||
|
require("./lib/windows-hide").install();
|
||||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
// A server started from inside a Claude Code session (the bootstrap fallback,
|
// A server started from inside a Claude Code session (the bootstrap fallback,
|
||||||
|
|||||||
+12
-9
@@ -9,6 +9,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const aiTools = require("./ai-tools");
|
const aiTools = require("./ai-tools");
|
||||||
const {
|
const {
|
||||||
@@ -182,17 +183,18 @@ function normalizeTitle(value) {
|
|||||||
*/
|
*/
|
||||||
function canonicalCwd(cwd) {
|
function canonicalCwd(cwd) {
|
||||||
// A relative path must stay relative, so createLane still refuses it.
|
// A relative path must stay relative, so createLane still refuses it.
|
||||||
if (!cwd.startsWith("/")) return cwd;
|
// path.isAbsolute, not a leading "/": a Windows cwd is `D:\…`.
|
||||||
const trimmed = cwd.replace(/\/+$/, "");
|
if (!path.isAbsolute(cwd)) return cwd;
|
||||||
|
const trimmed = path.resolve(cwd);
|
||||||
// A path that does not exist yet resolves through its deepest existing
|
// A path that does not exist yet resolves through its deepest existing
|
||||||
// ancestor, so /link/new and /real/new still name the same lane.
|
// ancestor, so /link/new and /real/new still name the same lane.
|
||||||
const path = require("node:path");
|
|
||||||
const tail = [];
|
const tail = [];
|
||||||
for (let dir = trimmed; ; dir = path.dirname(dir)) {
|
for (let dir = trimmed; ; dir = path.dirname(dir)) {
|
||||||
try {
|
try {
|
||||||
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail);
|
return path.join(require("node:fs").realpathSync.native(dir), ...tail);
|
||||||
} catch {
|
} catch {
|
||||||
if (dir === "/" || !dir) return trimmed;
|
// A root (`/`, `D:\` — a drive that does not exist) is its own dirname.
|
||||||
|
if (path.dirname(dir) === dir) return trimmed;
|
||||||
tail.unshift(path.basename(dir));
|
tail.unshift(path.basename(dir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +212,7 @@ function createLane({
|
|||||||
tool = "claude",
|
tool = "claude",
|
||||||
router_profile = null,
|
router_profile = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
|
if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
|
||||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||||
}
|
}
|
||||||
validateKind(kind);
|
validateKind(kind);
|
||||||
@@ -603,12 +605,13 @@ function recoverInterruptedProvisioning() {
|
|||||||
*/
|
*/
|
||||||
function resolveLaneByCwd(cwd, tool) {
|
function resolveLaneByCwd(cwd, tool) {
|
||||||
if (!cwd || typeof cwd !== "string") return null;
|
if (!cwd || typeof cwd !== "string") return null;
|
||||||
const target = cwd.replace(/\/+$/, "");
|
// A Windows cwd ends in and nests under `\`.
|
||||||
|
const target = cwd.replace(/[\\/]+$/, "");
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const lane of listLanes()) {
|
for (const lane of listLanes()) {
|
||||||
if (tool && lane.tool !== tool) continue;
|
if (tool && lane.tool !== tool) continue;
|
||||||
const base = lane.cwd.replace(/\/+$/, "");
|
const base = lane.cwd.replace(/[\\/]+$/, "");
|
||||||
if (target === base || target.startsWith(`${base}/`)) {
|
if (target === base || target.startsWith(`${base}/`) || target.startsWith(base + path.sep)) {
|
||||||
if (!best || base.length > best.cwd.length) best = lane;
|
if (!best || base.length > best.cwd.length) best = lane;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ function dialogCommands(kind, dir, platform = process.platform) {
|
|||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-STA",
|
"-STA",
|
||||||
"-Command",
|
"-Command",
|
||||||
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
// The server is a background process, and Windows will not let one
|
||||||
|
// bring a window to the front: an ownerless dialog opened BEHIND the
|
||||||
|
// browser (or hidden — the first ShowWindow of a process started
|
||||||
|
// with windowsHide). An invisible TopMost owner, shown first, takes
|
||||||
|
// that first ShowWindow and keeps the dialog on top.
|
||||||
|
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o = New-Object System.Windows.Forms.Form; $o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.Opacity = 0; $o.Width = 1; $o.Height = 1; $o.Show(); $o.Activate(); ${dialog}; if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file Makes every child process on Windows start with `windowsHide: true`.
|
||||||
|
* The dashboard runs detached, with no console of its own, so Windows gave
|
||||||
|
* each console program it ran (git, npm, where, …) a NEW console window — a
|
||||||
|
* flash that stole focus on every poll. Node has no global default, so this
|
||||||
|
* patches `ChildProcess.prototype.spawn` (the one path spawn/exec/execFile/fork
|
||||||
|
* and their promisified forms all take) and wraps the sync exports. Require it
|
||||||
|
* FIRST in an entry point: a `const { execSync } = require("child_process")`
|
||||||
|
* evaluated before it keeps the unwrapped function. A no-op off Windows.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DONE = Symbol.for("agenthub.windowsHide");
|
||||||
|
|
||||||
|
function install(cp = require("child_process"), platform = process.platform) {
|
||||||
|
if (platform !== "win32" || cp[DONE]) return;
|
||||||
|
cp[DONE] = true;
|
||||||
|
|
||||||
|
const spawn = cp.ChildProcess.prototype.spawn;
|
||||||
|
cp.ChildProcess.prototype.spawn = function (options) {
|
||||||
|
options.windowsHide = true;
|
||||||
|
return spawn.call(this, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// spawnSync(cmd, args?, opts?), execFileSync(file, args?, opts?), execSync(cmd, opts?)
|
||||||
|
for (const name of ["spawnSync", "execFileSync", "execSync"]) {
|
||||||
|
const fn = cp[name];
|
||||||
|
cp[name] = function (...args) {
|
||||||
|
while (args.length > 1 && args[args.length - 1] == null) args.pop();
|
||||||
|
const last = args[args.length - 1];
|
||||||
|
if (args.length > 1 && typeof last === "object" && !Array.isArray(last)) {
|
||||||
|
args[args.length - 1] = { ...last, windowsHide: true };
|
||||||
|
} else {
|
||||||
|
args.push({ windowsHide: true });
|
||||||
|
}
|
||||||
|
return fn.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { install };
|
||||||
@@ -406,7 +406,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
|||||||
// it, a nested cwd still resolves to its owner, which is what the CLI and
|
// it, a nested cwd still resolves to its owner, which is what the CLI and
|
||||||
// other callers adopting "the lane I am inside" rely on.
|
// other callers adopting "the lane I am inside" rely on.
|
||||||
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||||||
const strip = (p) => p.replace(/\/+$/, "");
|
const strip = (p) => p.replace(/[\\/]+$/, "");
|
||||||
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
|
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
|
||||||
if (owner) {
|
if (owner) {
|
||||||
// First-write wins on the title: an ensure-with-title on an existing lane
|
// First-write wins on the title: an ensure-with-title on an existing lane
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function gitBranch(cwd) {
|
|||||||
cwd,
|
cwd,
|
||||||
timeout: 500,
|
timeout: 500,
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user