0547488f13
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
197 lines
6.3 KiB
JavaScript
197 lines
6.3 KiB
JavaScript
/**
|
|
* @file Which npm-distributed AI tools are npm-MANAGED (their binary resolves
|
|
* under `npm prefix -g`, so an npm update actually replaces what runs) and
|
|
* whether a newer version is published. A tool installed another way — Codex's
|
|
* standalone build, say — is never offered an npm update: it would install a
|
|
* second copy that the PATH order shadows. npm is the LOGIN SHELL's (its PATH
|
|
* first), not the pinned Node's beside the server. Cached for a day.
|
|
* A system-wide Node (`/usr/local`, root's) leaves npm's global prefix
|
|
* unwritable to the dashboard's account, so there a global install goes to the
|
|
* account's own `~/.local` (`--prefix`, bin in `~/.local/bin`) instead, and a
|
|
* tool found under `~/.local/lib/node_modules` counts as npm-managed there.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { execFile } = require("node:child_process");
|
|
const aiTools = require("./ai-tools");
|
|
const { shellPath } = require("./shell-path");
|
|
const { compareVersions } = require("./update-check");
|
|
|
|
const CACHE_MS = 24 * 60 * 60 * 1000;
|
|
const TIMEOUT_MS = 5000;
|
|
|
|
let cache = null; // { at, data }
|
|
|
|
const defaultExec = (file, args, opts) =>
|
|
new Promise((resolve, reject) =>
|
|
execFile(file, args, { ...opts, encoding: "utf8" }, (err, stdout) =>
|
|
err ? reject(err) : resolve({ stdout })
|
|
)
|
|
);
|
|
let exec = defaultExec;
|
|
let userPrefixImpl = () =>
|
|
process.platform === "win32" ? null : path.join(os.homedir(), ".local");
|
|
|
|
/** The PATH a job runs with: the shell's own order first, then the server's. */
|
|
const jobPath = () => [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
|
|
|
|
/** The login shell's npm, else the server's; null when there is none. */
|
|
function npmBin() {
|
|
const sp = shellPath();
|
|
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
|
|
}
|
|
|
|
const run = (bin, args) => {
|
|
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];
|
|
|
|
async function npmPrefix(npm) {
|
|
try {
|
|
return (await run(npm, ["prefix", "-g"])).stdout.trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function under(prefix, file) {
|
|
try {
|
|
const real = fs.realpathSync(file);
|
|
const base = fs.realpathSync(prefix);
|
|
return real.startsWith(base + path.sep);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** The account's own npm prefix (`~/.local`), or null where npm has none (Windows). */
|
|
const userPrefix = () => userPrefixImpl();
|
|
|
|
/** Whether npm can write `dir` — or, when it is not there yet, its nearest existing parent. */
|
|
function writable(dir) {
|
|
let d = dir;
|
|
for (;;) {
|
|
try {
|
|
fs.accessSync(d, fs.constants.W_OK);
|
|
return true;
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT") return false;
|
|
}
|
|
const up = path.dirname(d);
|
|
if (up === d) return false;
|
|
d = up;
|
|
}
|
|
}
|
|
|
|
/** Whether a global install into `prefix` would succeed: its package and bin folders. */
|
|
const prefixWritable = (prefix) =>
|
|
process.platform === "win32"
|
|
? writable(prefix)
|
|
: writable(path.join(prefix, "lib", "node_modules")) && writable(path.join(prefix, "bin"));
|
|
|
|
/**
|
|
* The `--prefix` a global install needs: none while npm's own global prefix is
|
|
* writable, else the account's `~/.local` (a system-wide Node: EACCES otherwise).
|
|
*/
|
|
async function installPrefixArgs() {
|
|
const npm = npmBin();
|
|
const prefix = npm ? await npmPrefix(npm) : null;
|
|
const up = userPrefix();
|
|
if (!prefix || !up || prefixWritable(prefix)) return [];
|
|
return ["--prefix", up];
|
|
}
|
|
|
|
/**
|
|
* Which npm prefix holds tool `id`: `"global"` (npm's own), `"user"` (the
|
|
* account's `~/.local/lib/node_modules`), or null — not installed, or not by npm.
|
|
*/
|
|
async function managedBy(id, prefix) {
|
|
const d = aiTools.detect(id);
|
|
if (!d.installed) return null;
|
|
if (prefix === undefined) {
|
|
const npm = npmBin();
|
|
prefix = npm ? await npmPrefix(npm) : null;
|
|
}
|
|
if (prefix && under(prefix, d.path)) return "global";
|
|
const up = userPrefix();
|
|
if (up && under(path.join(up, "lib", "node_modules"), d.path)) return "user";
|
|
return null;
|
|
}
|
|
|
|
/** Whether tool `id` is installed AND lives under npm's global or the account's prefix. */
|
|
const npmManaged = async (id, prefix) => !!(await managedBy(id, prefix));
|
|
|
|
async function compute() {
|
|
const npm = npmBin();
|
|
const prefix = npm ? await npmPrefix(npm) : null;
|
|
const tools = aiTools
|
|
.list()
|
|
.filter((t) => t.update?.kind === "npm" && aiTools.detect(t.id).installed);
|
|
const rows = await Promise.all(
|
|
tools.map(async (t) => {
|
|
const managed = await npmManaged(t.id, prefix);
|
|
if (!managed) {
|
|
return [t.id, { npmManaged: false, installed: null, latest: null, updateAvailable: false }];
|
|
}
|
|
const [inst, latest] = await Promise.all([
|
|
run(aiTools.detect(t.id).path, ["--version"]).then(
|
|
(r) => parseVersion(r.stdout),
|
|
() => null
|
|
),
|
|
run(npm, ["view", t.install.package, "version"]).then(
|
|
(r) => parseVersion(r.stdout),
|
|
() => null
|
|
),
|
|
]);
|
|
const updateAvailable = !!(inst && latest && compareVersions(latest, inst) > 0);
|
|
return [t.id, { npmManaged: true, installed: inst, latest, updateAvailable }];
|
|
})
|
|
);
|
|
return Object.fromEntries(rows);
|
|
}
|
|
|
|
/** `{[id]: {npmManaged, installed, latest, updateAvailable}}`, cached a day. */
|
|
async function versions({ fresh = false } = {}) {
|
|
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.data;
|
|
const data = await compute();
|
|
cache = { at: Date.now(), data };
|
|
return data;
|
|
}
|
|
|
|
function invalidate() {
|
|
cache = null;
|
|
}
|
|
|
|
/** Test seam: the account's npm prefix (a path, or null for none); undefined restores it. */
|
|
function __setUserPrefix(p) {
|
|
userPrefixImpl =
|
|
p === undefined
|
|
? () => (process.platform === "win32" ? null : path.join(os.homedir(), ".local"))
|
|
: () => p;
|
|
}
|
|
|
|
/** Test seam: `fn(file, args, opts) → Promise<{stdout}>`, or null for execFile. */
|
|
function __setExec(fn) {
|
|
exec = fn || defaultExec;
|
|
cache = null;
|
|
}
|
|
|
|
module.exports = {
|
|
versions,
|
|
invalidate,
|
|
npmManaged,
|
|
managedBy,
|
|
installPrefixArgs,
|
|
userPrefix,
|
|
npmBin,
|
|
jobPath,
|
|
parseVersion,
|
|
__setExec,
|
|
__setUserPrefix,
|
|
};
|