mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
c56cfa47f6
Fixes #701. ## The problem `copyRulesAndAgents` copied the bundled trees onto each managed directory with one recursive `fs.cpSync`. When the destination is a symlink at a directory elsewhere, `fs.cp` refuses to overwrite it: ``` Could not copy agents to /repo/.claude/agents: Error: Cannot overwrite non-directory /repo/.claude/agents with directory .../@swmansion/argent/agents ``` Everything else installs, so it reads as a success apart from one warning line — but `argent-environment-inspector` never lands, while `rules/argent.md` keeps telling the agent to use it. The layout is the point of the report: keep one canonical, harness-neutral copy of the definitions and symlink each vendor path at it, so there's one definition to edit and no drift. The host tools already support that — Claude Code documents symlinks for `.claude/rules/`, and loads argent's skills through exactly such a link today. `rules/` had the same defect; the reporter just hadn't symlinked it. ## It is worse than the report on Node 20 `fs.cp`'s handling of a symlinked destination depends on which runtime you are on — Node 20 and Node 22 ship different implementations, and they disagree exactly here. Measured on both: | destination | Node 20 (JS impl) | Node 22 (C++ impl) | |---|---|---| | top-level symlinked dir | refuses (the reported bug) | refuses (the reported bug) | | nested symlinked dir | refuses | writes through | | symlinked **file** | **replaces the link, canonical left stale** | writes through | | dangling / unwritable dir | catchable throw | **aborts the process, exit 134** | So on Node 20 — which is what this repo's own CI runs — released 0.18.1 silently leaves a symlinked agent file stale on every `update`, with no warning at all. Verified against the released CLI: ``` released 0.18.1, Node 20: symlinked file: canon=stale link=intact (0 warnings) nested symlinked dir: files-in-canonical=0 ``` ## The fix The copy walks the tree itself, resolving each destination as it goes, and copies leaves with `fs.copyFileSync`. Nothing is delegated to `fs.cp`: that is what makes the behaviour the same on every supported runtime, and `copyFileSync` reports each failure — dangling link, loop, unwritable directory, a link pointing at a directory — as a plain catchable errno. The fix lives in `copyDir` rather than in a new helper because that function was already an exact, unused copy of the same buggy shape. Routing `copyRulesAndAgents` through it covers rules and agents, init and update, and removes the latent duplicate. That also settles the case that kills the install outright on Node 22: `fs.cp` does not throw when it cannot create a directory, it aborts through an uncatchable C++ `std::filesystem` exception. Two reachable shapes, both verified against released 0.18.1: - a broken `.claude/agents/references` link (the bundled agents tree really does have that subdirectory) — `argent init` dies at exit 134 mid-run; - **a read-only `.claude/agents`** — same abort, and this one needs no symlink at all, so it hits users of today's release. Both are now ordinary reported warnings. Resolution follows a link chain to its end, resolves each hop against the directory the link really lives in (not the lexical parent — they differ when a parent is itself a link into another subtree), and completes a dangling link only into a directory that already exists, so a link can't conjure a tree at an arbitrary path. Where the bytes land can now differ from the configured path, so the output names both — in the same shortened form the rest of the managed-content output uses: `Copied agents to .claude/agents -> .agents/agents`. A destination outside the workspace stays absolute, so an escape stands out. Nothing changes for a layout with no symlinks. ## Verification Reproduced against released 0.18.1 and a local build, then re-run against the fix. Every row below was executed end-to-end through the real `argent init`: | scenario | before | after | |---|---|---| | `.claude/agents -> ../.agents/agents` (the report) | warning, no files | both files land, link intact | | single symlinked `.md` file inside a real `.claude/agents` | worked | still works (regression test added) | | symlink chain to a real directory | warning, no files | resolves, files land | | link created before its target directory | warning, no files | target completed, files land | | dangling `.claude/agents/references` | **abort, exit 134** | files land, no warning | | **read-only `.claude/agents`, no symlinks** | **abort, exit 134** | `EACCES` warning, install completes | | link at a file / dangling into a missing tree | warning | warning, victim untouched, nothing created | | destination outside the project | writes there | writes there, disclosed absolutely | | `.gemini` / `.opencode` / `.cursor` targets, global `~/.claude` scope | refused | land correctly | | uninstall over a symlinked layout | — | removes only argent's files; user's files, link and directory kept | | re-run over a populated layout (`update`) | — | clean, no warnings | | symlinked file, **Node 20** | **silently stale, no warning** | canonical updated, link intact | | nested symlinked dir, **Node 20** | refused | writes through, link intact | | no symlinks anywhere | works | unchanged, no redirect in output | Every row was run under **both Node 20 and Node 22**, with identical outcomes on each. 15 new unit tests; 552 green on both runtimes. Build, eslint, prettier and the test typecheck all pass. Symlink tests skip on Windows, matching `update.test.ts`. `argent update` is not run here because it acts on the real global npm install, but its call site is byte-identical to init's and the same function was exercised directly in both scopes. ## Deliberately not in this PR - **The summary still prints a green "Rules & agents copied" when a copy failed** (`copiedRules: copyResults.length > 0` in `init.ts`). The issue calls this out as why the failure was easy to overlook. Fixing it honestly means giving `copyRulesAndAgents` a structured return and deciding whether a failed copy should make `argent update` exit non-zero — a separate change, and the general case isn't symlink-specific. Happy to follow up. - **Uninstall leaves both halves of a symlinked layout.** It removes argent's files through the link correctly and never touches the user's link or their other files; it just can't prune the emptied canonical directory (`rmdir` on a symlink fails `ENOTDIR`, swallowed). Not a regression, and arguably right. - **Writing through a link can leave the workspace.** That is pre-existing — `fs.cp` already wrote through symlinked files and nested symlinked directories before this change — and confining it would break the very layout the issue asks for. What this PR does add is bounded: a dangling link is completed only where its parent already exists.
1220 lines
48 KiB
TypeScript
1220 lines
48 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
|
|
// ── Module mocks ─────────────────────────────────────────────────────────────
|
|
// These are hoisted so `vi.mock` can reference them. They let the network-
|
|
// dependent helpers (`isOnline`, `isSkillsCliAvailable`) be tested
|
|
// deterministically without touching DNS or spawning `npx`.
|
|
|
|
const { dnsLookupMock, execSyncMock } = vi.hoisted(() => ({
|
|
dnsLookupMock: vi.fn(),
|
|
execSyncMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:dns", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:dns")>();
|
|
return {
|
|
...actual,
|
|
default: { ...actual, lookup: dnsLookupMock },
|
|
lookup: dnsLookupMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("node:child_process", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
return {
|
|
...actual,
|
|
default: { ...actual, execSync: execSyncMock },
|
|
execSync: execSyncMock,
|
|
};
|
|
});
|
|
|
|
import {
|
|
readJson,
|
|
writeJson,
|
|
copyDir,
|
|
dirExists,
|
|
detectPackageManager,
|
|
globalInstallCommand,
|
|
globalUninstallCommand,
|
|
formatShellCommand,
|
|
getGlobalSkillLockPath,
|
|
getProjectSkillLockPath,
|
|
listArgentSkillsInLock,
|
|
isNewerVersion,
|
|
isOnline,
|
|
isSkillsCliAvailable,
|
|
listBundledSkills,
|
|
resolveProjectRoot,
|
|
SKILLS_DIR,
|
|
RULES_DIR,
|
|
AGENTS_DIR,
|
|
localInstallCommand,
|
|
localUninstallCommand,
|
|
detectProjectPackageManager,
|
|
hasProjectPackageJson,
|
|
isYarnPnp,
|
|
isLocallyInstalled,
|
|
getLocallyInstalledVersion,
|
|
getLocalArgentBinRelPath,
|
|
probeLocalInstall,
|
|
isDeclaredLocally,
|
|
getInstallRecordPath,
|
|
readInstallRecord,
|
|
writeInstallRecord,
|
|
removeInstallRecord,
|
|
resolveInstallMode,
|
|
resolveInstallModeFromFlags,
|
|
InstallModeFlagError,
|
|
} from "../src/utils.js";
|
|
import { NPM_REGISTRY, PACKAGE_NAME } from "../src/constants.js";
|
|
|
|
// Stage a fake project-local @swmansion/argent install under `root`, returning
|
|
// the bin entry's project-relative POSIX path.
|
|
function stageLocalArgent(
|
|
root: string,
|
|
opts: { version?: string; bin?: unknown; withBinFile?: boolean } = {}
|
|
): string {
|
|
const pkgDir = path.join(root, "node_modules", PACKAGE_NAME);
|
|
fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
|
|
const bin = opts.bin ?? { "argent": "dist/cli.js", "argent-simulator-server": "bin/x.cjs" };
|
|
fs.writeFileSync(
|
|
path.join(pkgDir, "package.json"),
|
|
JSON.stringify({ name: PACKAGE_NAME, version: opts.version ?? "1.2.3", bin })
|
|
);
|
|
if (opts.withBinFile !== false) {
|
|
fs.writeFileSync(path.join(pkgDir, "dist", "cli.js"), "#!/usr/bin/env node\n");
|
|
}
|
|
return `node_modules/${PACKAGE_NAME}/dist/cli.js`;
|
|
}
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-utils-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── readJson ──────────────────────────────────────────────────────────────────
|
|
|
|
describe("readJson", () => {
|
|
it("returns empty object for non-existent file", () => {
|
|
expect(readJson(path.join(tmpDir, "nope.json"))).toEqual({});
|
|
});
|
|
|
|
it("returns empty object for invalid JSON", () => {
|
|
const file = path.join(tmpDir, "bad.json");
|
|
fs.writeFileSync(file, "not json");
|
|
expect(readJson(file)).toEqual({});
|
|
});
|
|
|
|
it("reads valid JSON", () => {
|
|
const file = path.join(tmpDir, "ok.json");
|
|
fs.writeFileSync(file, JSON.stringify({ foo: "bar" }));
|
|
expect(readJson(file)).toEqual({ foo: "bar" });
|
|
});
|
|
});
|
|
|
|
// ── writeJson ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("writeJson", () => {
|
|
it("creates parent directories", () => {
|
|
const file = path.join(tmpDir, "a", "b", "c.json");
|
|
writeJson(file, { x: 1 });
|
|
expect(fs.existsSync(file)).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ x: 1 });
|
|
});
|
|
|
|
it("overwrites existing files", () => {
|
|
const file = path.join(tmpDir, "x.json");
|
|
writeJson(file, { v: 1 });
|
|
writeJson(file, { v: 2 });
|
|
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ v: 2 });
|
|
});
|
|
|
|
it("pretty-prints with 2-space indent and trailing newline", () => {
|
|
const file = path.join(tmpDir, "pretty.json");
|
|
writeJson(file, { a: 1 });
|
|
const raw = fs.readFileSync(file, "utf8");
|
|
expect(raw).toBe('{\n "a": 1\n}\n');
|
|
});
|
|
});
|
|
|
|
// ── copyDir ───────────────────────────────────────────────────────────────────
|
|
|
|
describe("copyDir", () => {
|
|
// Two files and a nested directory, matching the shape of the bundled
|
|
// rules/agents trees this copies in practice.
|
|
function stageSource(): string {
|
|
const src = path.join(tmpDir, "src");
|
|
fs.mkdirSync(path.join(src, "sub"), { recursive: true });
|
|
fs.writeFileSync(path.join(src, "a.txt"), "hello");
|
|
fs.writeFileSync(path.join(src, "sub", "b.txt"), "world");
|
|
return src;
|
|
}
|
|
|
|
it("returns null when source does not exist", () => {
|
|
expect(copyDir(path.join(tmpDir, "nope"), path.join(tmpDir, "dest"))).toBeNull();
|
|
});
|
|
|
|
it("copies directory recursively and reports where it wrote", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
|
|
expect(copyDir(src, dest)).toBe(dest);
|
|
expect(fs.readFileSync(path.join(dest, "a.txt"), "utf8")).toBe("hello");
|
|
expect(fs.readFileSync(path.join(dest, "sub", "b.txt"), "utf8")).toBe("world");
|
|
});
|
|
|
|
// Symlinked destinations (issue #701): the canonical copy lives in one
|
|
// harness-neutral directory and each vendor path is a link at it, so the copy
|
|
// has to write *through* the link instead of trying to replace it.
|
|
// Skipped on Windows, where creating symlinks needs admin rights.
|
|
describe.skipIf(process.platform === "win32")("symlinked destinations", () => {
|
|
it("writes through a symlinked destination, leaving the link intact", () => {
|
|
const src = stageSource();
|
|
const real = path.join(tmpDir, "canonical");
|
|
const link = path.join(tmpDir, "dest");
|
|
fs.mkdirSync(real);
|
|
fs.symlinkSync("canonical", link);
|
|
|
|
expect(copyDir(src, link)).toBe(fs.realpathSync(real));
|
|
expect(fs.readFileSync(path.join(real, "a.txt"), "utf8")).toBe("hello");
|
|
expect(fs.readFileSync(path.join(real, "sub", "b.txt"), "utf8")).toBe("world");
|
|
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it("writes through a symlinked subdirectory of the destination", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
const real = path.join(tmpDir, "canonical-sub");
|
|
fs.mkdirSync(dest);
|
|
fs.mkdirSync(real);
|
|
fs.symlinkSync("../canonical-sub", path.join(dest, "sub"));
|
|
|
|
copyDir(src, dest);
|
|
|
|
expect(fs.readFileSync(path.join(real, "b.txt"), "utf8")).toBe("world");
|
|
expect(fs.readFileSync(path.join(dest, "a.txt"), "utf8")).toBe("hello");
|
|
expect(fs.lstatSync(path.join(dest, "sub")).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
// A single symlinked file is the layout a dotfile manager produces when the
|
|
// directory has to stay real because it holds the user's own agents too.
|
|
it("writes through a symlinked file in the destination", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
const canonical = path.join(tmpDir, "canonical", "a.txt");
|
|
fs.mkdirSync(path.join(tmpDir, "canonical"));
|
|
fs.mkdirSync(dest);
|
|
fs.writeFileSync(canonical, "stale");
|
|
fs.symlinkSync(path.join("..", "canonical", "a.txt"), path.join(dest, "a.txt"));
|
|
|
|
copyDir(src, dest);
|
|
|
|
expect(fs.readFileSync(canonical, "utf8")).toBe("hello");
|
|
expect(fs.lstatSync(path.join(dest, "a.txt")).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it("creates the target of a dangling destination link", () => {
|
|
const src = stageSource();
|
|
const link = path.join(tmpDir, "dest");
|
|
fs.symlinkSync("canonical", link);
|
|
|
|
copyDir(src, link);
|
|
|
|
expect(fs.readFileSync(path.join(tmpDir, "canonical", "a.txt"), "utf8")).toBe("hello");
|
|
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
// The case that aborted the whole process before: fs.cp does not throw on a
|
|
// dangling link it must turn into a directory, it terminates the runtime.
|
|
it("creates the target of a dangling link nested in the destination", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
fs.mkdirSync(dest);
|
|
fs.symlinkSync(path.join("..", "canonical-sub"), path.join(dest, "sub"));
|
|
|
|
copyDir(src, dest);
|
|
|
|
expect(fs.readFileSync(path.join(tmpDir, "canonical-sub", "b.txt"), "utf8")).toBe("world");
|
|
});
|
|
|
|
// A relative link target resolves against the directory the link really
|
|
// lives in — here `dotfiles/claude`, not the `home/.claude` path we were
|
|
// handed — so `..` has to be walked from the real parent.
|
|
it("resolves a dangling relative target from behind a symlinked parent", () => {
|
|
const src = stageSource();
|
|
fs.mkdirSync(path.join(tmpDir, "dotfiles", "claude"), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, "dotfiles", "shared"), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, "home"), { recursive: true });
|
|
fs.symlinkSync(path.join("..", "dotfiles", "claude"), path.join(tmpDir, "home", ".claude"));
|
|
fs.symlinkSync(
|
|
path.join("..", "shared", "rules"),
|
|
path.join(tmpDir, "dotfiles", "claude", "rules")
|
|
);
|
|
|
|
const written = copyDir(src, path.join(tmpDir, "home", ".claude", "rules"));
|
|
|
|
expect(written).toBe(path.join(fs.realpathSync(tmpDir), "dotfiles", "shared", "rules"));
|
|
expect(fs.existsSync(path.join(tmpDir, "home", ".claude", "rules", "a.txt"))).toBe(true);
|
|
});
|
|
|
|
it("follows a chain of destination links to its end", () => {
|
|
const src = stageSource();
|
|
const real = path.join(tmpDir, "canonical");
|
|
fs.mkdirSync(real);
|
|
fs.symlinkSync("canonical", path.join(tmpDir, "hop"));
|
|
fs.symlinkSync("hop", path.join(tmpDir, "dest"));
|
|
|
|
expect(copyDir(src, path.join(tmpDir, "dest"))).toBe(fs.realpathSync(real));
|
|
expect(fs.readFileSync(path.join(real, "a.txt"), "utf8")).toBe("hello");
|
|
});
|
|
|
|
// Completing a link is a courtesy for a canonical directory the user has
|
|
// already made — not a licence to build a tree wherever a link points.
|
|
it("refuses a dangling link whose parent does not exist, creating nothing", () => {
|
|
const src = stageSource();
|
|
const link = path.join(tmpDir, "dest");
|
|
fs.symlinkSync(path.join("nowhere", "canonical"), link);
|
|
|
|
expect(() => copyDir(src, link)).toThrow();
|
|
expect(fs.existsSync(path.join(tmpDir, "nowhere"))).toBe(false);
|
|
});
|
|
|
|
// Guards a design that resolved these itself and handed the result to
|
|
// fs.cp, which terminates the runtime on a dangling link rather than
|
|
// throwing. Left to fs.cp, a leaf link is a plain catchable error.
|
|
it("reports a dangling chain on a destination file without crashing", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
fs.mkdirSync(path.join(tmpDir, "neutral"), { recursive: true });
|
|
fs.mkdirSync(dest);
|
|
fs.symlinkSync(path.join("..", "neutral", "a.txt"), path.join(dest, "a.txt"));
|
|
fs.symlinkSync(path.join("..", "gone", "a.txt"), path.join(tmpDir, "neutral", "a.txt"));
|
|
|
|
expect(() => copyDir(src, dest)).toThrow();
|
|
});
|
|
|
|
it("refuses a destination link pointing at a file, without touching it", () => {
|
|
const src = stageSource();
|
|
const file = path.join(tmpDir, "occupied");
|
|
const link = path.join(tmpDir, "dest");
|
|
fs.writeFileSync(file, "mine");
|
|
fs.symlinkSync("occupied", link);
|
|
|
|
expect(() => copyDir(src, link)).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
|
expect(fs.readFileSync(file, "utf8")).toBe("mine");
|
|
});
|
|
});
|
|
|
|
it("overwrites an existing destination on a repeat copy", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
copyDir(src, dest);
|
|
fs.writeFileSync(path.join(src, "a.txt"), "second");
|
|
|
|
copyDir(src, dest);
|
|
|
|
expect(fs.readFileSync(path.join(dest, "a.txt"), "utf8")).toBe("second");
|
|
expect(fs.readFileSync(path.join(dest, "sub", "b.txt"), "utf8")).toBe("world");
|
|
});
|
|
|
|
// Nothing sensible asks for this, but the walk must not chase its own output
|
|
// down an unbounded path, and must not truncate a file by copying it over
|
|
// itself.
|
|
it("does not burrow when the destination is inside the source", () => {
|
|
const src = stageSource();
|
|
|
|
copyDir(src, path.join(src, "sub"));
|
|
|
|
expect(fs.existsSync(path.join(src, "sub", "sub", "sub"))).toBe(false);
|
|
expect(fs.readFileSync(path.join(src, "a.txt"), "utf8")).toBe("hello");
|
|
expect(fs.readFileSync(path.join(src, "sub", "b.txt"), "utf8")).toBe("world");
|
|
});
|
|
|
|
// Creating a directory is what fs.cp cannot survive, so the copy does it.
|
|
it("reports an unwritable destination instead of dying", () => {
|
|
const src = stageSource();
|
|
const dest = path.join(tmpDir, "dest");
|
|
fs.mkdirSync(dest);
|
|
fs.chmodSync(dest, 0o555);
|
|
|
|
try {
|
|
expect(() => copyDir(src, dest)).toThrow(expect.objectContaining({ code: "EACCES" }));
|
|
} finally {
|
|
fs.chmodSync(dest, 0o755);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── dirExists ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("dirExists", () => {
|
|
it("returns true for existing directory", () => {
|
|
expect(dirExists(tmpDir)).toBe(true);
|
|
});
|
|
|
|
it("returns false for non-existent path", () => {
|
|
expect(dirExists(path.join(tmpDir, "nope"))).toBe(false);
|
|
});
|
|
|
|
it("returns false for a file", () => {
|
|
const file = path.join(tmpDir, "file.txt");
|
|
fs.writeFileSync(file, "");
|
|
expect(dirExists(file)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── resolveProjectRoot ────────────────────────────────────────────────────────
|
|
|
|
describe("resolveProjectRoot", () => {
|
|
it("returns the nearest managed project root", () => {
|
|
const projectRoot = path.join(tmpDir, "project");
|
|
const nestedDir = path.join(projectRoot, "src", "deep");
|
|
fs.mkdirSync(path.join(projectRoot, ".claude"), { recursive: true });
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
|
|
expect(resolveProjectRoot(nestedDir)).toBe(projectRoot);
|
|
});
|
|
|
|
it("prefers a nearer managed root over an ancestor git root", () => {
|
|
const repoRoot = path.join(tmpDir, "repo");
|
|
const nestedProjectRoot = path.join(repoRoot, "packages", "app");
|
|
const nestedDir = path.join(nestedProjectRoot, "src");
|
|
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(nestedProjectRoot, ".cursor"), { recursive: true });
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
|
|
expect(resolveProjectRoot(nestedDir)).toBe(nestedProjectRoot);
|
|
});
|
|
|
|
it("falls back to git root when no managed markers exist", () => {
|
|
const repoRoot = path.join(tmpDir, "repo");
|
|
const nestedDir = path.join(repoRoot, "src");
|
|
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
|
|
expect(resolveProjectRoot(nestedDir)).toBe(repoRoot);
|
|
});
|
|
|
|
it("falls back to the starting directory when no markers exist", () => {
|
|
const nestedDir = path.join(tmpDir, "plain", "src");
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
|
|
expect(resolveProjectRoot(nestedDir)).toBe(nestedDir);
|
|
});
|
|
|
|
it("recognizes opencode.jsonc as a project root marker", () => {
|
|
const projectRoot = path.join(tmpDir, "project");
|
|
const nestedDir = path.join(projectRoot, "src");
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, "opencode.jsonc"), "{}");
|
|
|
|
expect(resolveProjectRoot(nestedDir)).toBe(projectRoot);
|
|
});
|
|
});
|
|
|
|
// ── detectPackageManager ──────────────────────────────────────────────────────
|
|
|
|
describe("detectPackageManager", () => {
|
|
const original = process.env.npm_config_user_agent;
|
|
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.npm_config_user_agent;
|
|
else process.env.npm_config_user_agent = original;
|
|
});
|
|
|
|
it("returns npm when agent is unset", () => {
|
|
delete process.env.npm_config_user_agent;
|
|
expect(detectPackageManager()).toBe("npm");
|
|
});
|
|
|
|
it("returns yarn for yarn agent", () => {
|
|
process.env.npm_config_user_agent = "yarn/4.0.0";
|
|
expect(detectPackageManager()).toBe("yarn");
|
|
});
|
|
|
|
it("returns pnpm for pnpm agent", () => {
|
|
process.env.npm_config_user_agent = "pnpm/9.0.0";
|
|
expect(detectPackageManager()).toBe("pnpm");
|
|
});
|
|
|
|
it("returns bun for bun agent", () => {
|
|
process.env.npm_config_user_agent = "bun/1.0.0";
|
|
expect(detectPackageManager()).toBe("bun");
|
|
});
|
|
});
|
|
|
|
// ── globalInstallCommand / globalUninstallCommand ─────────────────────────────
|
|
|
|
describe("globalInstallCommand", () => {
|
|
it("npm", () => {
|
|
expect(globalInstallCommand("npm", "pkg")).toEqual({
|
|
bin: "npm",
|
|
args: ["install", "-g", "pkg"],
|
|
});
|
|
});
|
|
it("yarn", () => {
|
|
expect(globalInstallCommand("yarn", "pkg")).toEqual({
|
|
bin: "yarn",
|
|
args: ["global", "add", "pkg"],
|
|
});
|
|
});
|
|
it("pnpm", () => {
|
|
expect(globalInstallCommand("pnpm", "pkg")).toEqual({
|
|
bin: "pnpm",
|
|
args: ["add", "-g", "pkg"],
|
|
});
|
|
});
|
|
it("bun", () => {
|
|
expect(globalInstallCommand("bun", "pkg")).toEqual({ bin: "bun", args: ["add", "-g", "pkg"] });
|
|
});
|
|
it("preserves paths with spaces", () => {
|
|
const cmd = globalInstallCommand("npm", "/path/with spaces/pkg.tgz");
|
|
expect(cmd.args[2]).toBe("/path/with spaces/pkg.tgz");
|
|
});
|
|
});
|
|
|
|
describe("globalUninstallCommand", () => {
|
|
it("npm", () => {
|
|
expect(globalUninstallCommand("npm", "pkg")).toEqual({
|
|
bin: "npm",
|
|
args: ["uninstall", "-g", "pkg"],
|
|
});
|
|
});
|
|
it("yarn", () => {
|
|
expect(globalUninstallCommand("yarn", "pkg")).toEqual({
|
|
bin: "yarn",
|
|
args: ["global", "remove", "pkg"],
|
|
});
|
|
});
|
|
it("pnpm", () => {
|
|
expect(globalUninstallCommand("pnpm", "pkg")).toEqual({
|
|
bin: "pnpm",
|
|
args: ["remove", "-g", "pkg"],
|
|
});
|
|
});
|
|
it("bun", () => {
|
|
expect(globalUninstallCommand("bun", "pkg")).toEqual({
|
|
bin: "bun",
|
|
args: ["remove", "-g", "pkg"],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── formatShellCommand ───────────────────────────────────────────────────────
|
|
|
|
describe("formatShellCommand", () => {
|
|
it("joins bin and args", () => {
|
|
expect(formatShellCommand({ bin: "npm", args: ["install", "-g", "pkg"] })).toBe(
|
|
"npm install -g pkg"
|
|
);
|
|
});
|
|
|
|
it("quotes args that contain spaces", () => {
|
|
expect(
|
|
formatShellCommand({ bin: "npm", args: ["install", "-g", "/path/with spaces/pkg.tgz"] })
|
|
).toBe('npm install -g "/path/with spaces/pkg.tgz"');
|
|
});
|
|
});
|
|
|
|
// ── Bundled paths ─────────────────────────────────────────────────────────────
|
|
|
|
describe("bundled paths", () => {
|
|
it("SKILLS_DIR is a string ending with skills", () => {
|
|
expect(SKILLS_DIR).toMatch(/skills$/);
|
|
});
|
|
|
|
it("RULES_DIR is a string ending with rules", () => {
|
|
expect(RULES_DIR).toMatch(/rules$/);
|
|
});
|
|
|
|
it("AGENTS_DIR is a string ending with agents", () => {
|
|
expect(AGENTS_DIR).toMatch(/agents$/);
|
|
});
|
|
});
|
|
|
|
// ── isNewerVersion ───────────────────────────────────────────────────────────
|
|
|
|
describe("isNewerVersion", () => {
|
|
it("returns true when candidate is a higher patch", () => {
|
|
expect(isNewerVersion("0.5.3", "0.5.2")).toBe(true);
|
|
});
|
|
|
|
it("returns true when candidate is a higher minor", () => {
|
|
expect(isNewerVersion("0.6.0", "0.5.9")).toBe(true);
|
|
});
|
|
|
|
it("returns true when candidate is a higher major", () => {
|
|
expect(isNewerVersion("1.0.0", "0.9.9")).toBe(true);
|
|
});
|
|
|
|
it("returns false when versions are equal", () => {
|
|
expect(isNewerVersion("0.5.3", "0.5.3")).toBe(false);
|
|
});
|
|
|
|
it("returns false when candidate is older — the bug fix", () => {
|
|
// Before the fix init.ts used `latest !== version`, which prompted a
|
|
// "downgrade" when running a local prerelease newer than npm's latest.
|
|
expect(isNewerVersion("0.5.2", "0.5.3")).toBe(false);
|
|
});
|
|
|
|
it("treats a prerelease as older than the matching release", () => {
|
|
expect(isNewerVersion("0.5.3-alpha.1", "0.5.3")).toBe(false);
|
|
expect(isNewerVersion("0.5.3", "0.5.3-alpha.1")).toBe(true);
|
|
});
|
|
|
|
it("still allows upgrades from a prerelease to a newer release", () => {
|
|
expect(isNewerVersion("0.5.4", "0.5.4-beta.0")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── listBundledSkills ────────────────────────────────────────────────────────
|
|
|
|
describe("listBundledSkills", () => {
|
|
it("returns an empty list for a non-existent directory", () => {
|
|
expect(listBundledSkills(path.join(tmpDir, "does-not-exist"))).toEqual([]);
|
|
});
|
|
|
|
it("returns only subdirectories that contain a SKILL.md", () => {
|
|
const skillsDir = path.join(tmpDir, "skills");
|
|
fs.mkdirSync(path.join(skillsDir, "argent-alpha"), { recursive: true });
|
|
fs.writeFileSync(path.join(skillsDir, "argent-alpha", "SKILL.md"), "# alpha");
|
|
fs.mkdirSync(path.join(skillsDir, "argent-beta"), { recursive: true });
|
|
fs.writeFileSync(path.join(skillsDir, "argent-beta", "SKILL.md"), "# beta");
|
|
// An orphan directory without SKILL.md must be excluded — it is not a skill.
|
|
fs.mkdirSync(path.join(skillsDir, "not-a-skill"), { recursive: true });
|
|
// Stray files at the top level must also be excluded.
|
|
fs.writeFileSync(path.join(skillsDir, "README.md"), "");
|
|
|
|
expect(listBundledSkills(skillsDir)).toEqual(["argent-alpha", "argent-beta"]);
|
|
});
|
|
|
|
it("returns results in a stable sorted order", () => {
|
|
const skillsDir = path.join(tmpDir, "skills");
|
|
for (const name of ["zulu", "alpha", "mike"]) {
|
|
fs.mkdirSync(path.join(skillsDir, name), { recursive: true });
|
|
fs.writeFileSync(path.join(skillsDir, name, "SKILL.md"), "");
|
|
}
|
|
expect(listBundledSkills(skillsDir)).toEqual(["alpha", "mike", "zulu"]);
|
|
});
|
|
});
|
|
|
|
// ── skills lock helpers ──────────────────────────────────────────────────────
|
|
|
|
describe("getProjectSkillLockPath", () => {
|
|
it("resolves to skills-lock.json under the provided cwd", () => {
|
|
expect(getProjectSkillLockPath("/some/project")).toBe("/some/project/skills-lock.json");
|
|
});
|
|
});
|
|
|
|
describe("getGlobalSkillLockPath", () => {
|
|
const originalXdg = process.env.XDG_STATE_HOME;
|
|
|
|
afterEach(() => {
|
|
if (originalXdg === undefined) delete process.env.XDG_STATE_HOME;
|
|
else process.env.XDG_STATE_HOME = originalXdg;
|
|
});
|
|
|
|
it("falls back to ~/.agents/.skill-lock.json when XDG_STATE_HOME is unset", () => {
|
|
delete process.env.XDG_STATE_HOME;
|
|
expect(getGlobalSkillLockPath()).toBe(path.join(os.homedir(), ".agents", ".skill-lock.json"));
|
|
});
|
|
|
|
it("uses $XDG_STATE_HOME/skills/.skill-lock.json when set", () => {
|
|
process.env.XDG_STATE_HOME = "/tmp/xdg";
|
|
expect(getGlobalSkillLockPath()).toBe("/tmp/xdg/skills/.skill-lock.json");
|
|
});
|
|
});
|
|
|
|
describe("listArgentSkillsInLock", () => {
|
|
it("returns an empty list when the lock file does not exist", () => {
|
|
expect(listArgentSkillsInLock(path.join(tmpDir, "missing.json"))).toEqual([]);
|
|
});
|
|
|
|
it("returns an empty list for a malformed JSON lock", () => {
|
|
const lockPath = path.join(tmpDir, "bad.json");
|
|
fs.writeFileSync(lockPath, "not json");
|
|
expect(listArgentSkillsInLock(lockPath)).toEqual([]);
|
|
});
|
|
|
|
it("returns only skills whose name starts with argent-", () => {
|
|
const lockPath = path.join(tmpDir, "lock.json");
|
|
fs.writeFileSync(
|
|
lockPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
skills: {
|
|
"argent-create-flow": {},
|
|
"argent-old-workflow": {}, // still in lock even if no longer bundled
|
|
"some-other-skill": {},
|
|
"vercel-labs/agent-skills": {},
|
|
},
|
|
})
|
|
);
|
|
// Result is sorted so callers can rely on a stable order.
|
|
expect(listArgentSkillsInLock(lockPath)).toEqual(["argent-create-flow", "argent-old-workflow"]);
|
|
});
|
|
|
|
it("returns an empty list when the lock has no skills object", () => {
|
|
const lockPath = path.join(tmpDir, "empty.json");
|
|
fs.writeFileSync(lockPath, JSON.stringify({ version: 1 }));
|
|
expect(listArgentSkillsInLock(lockPath)).toEqual([]);
|
|
});
|
|
|
|
it("returns an empty list when no argent-prefixed entry is tracked", () => {
|
|
const lockPath = path.join(tmpDir, "lock.json");
|
|
fs.writeFileSync(lockPath, JSON.stringify({ version: 1, skills: { "other-skill": {} } }));
|
|
expect(listArgentSkillsInLock(lockPath)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── isOnline ──────────────────────────────────────────────────────────────────
|
|
// `isOnline` wraps `dns.lookup` with a timeout. All tests below run against
|
|
// the mocked `dns.lookup` set up at the top of this file — they never touch
|
|
// real DNS, which keeps them deterministic on offline runners and CI machines
|
|
// that deny outbound network access.
|
|
|
|
describe("isOnline", () => {
|
|
beforeEach(() => {
|
|
dnsLookupMock.mockReset();
|
|
});
|
|
|
|
it("returns true when DNS resolution succeeds", async () => {
|
|
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
|
|
setImmediate(() => callback(null));
|
|
});
|
|
|
|
await expect(isOnline()).resolves.toBe(true);
|
|
expect(dnsLookupMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns false when DNS resolution errors", async () => {
|
|
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
|
|
setImmediate(() => callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })));
|
|
});
|
|
|
|
await expect(isOnline()).resolves.toBe(false);
|
|
});
|
|
|
|
it("returns false when DNS never responds before the timeout", async () => {
|
|
dnsLookupMock.mockImplementation(() => {
|
|
// Never invoke the callback — simulate a hanging DNS query.
|
|
});
|
|
|
|
const start = Date.now();
|
|
const result = await isOnline(30);
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(result).toBe(false);
|
|
expect(elapsed).toBeGreaterThanOrEqual(25);
|
|
expect(elapsed).toBeLessThan(500);
|
|
});
|
|
|
|
it("looks up the hostname from NPM_REGISTRY", async () => {
|
|
const expectedHost = new URL(NPM_REGISTRY).hostname;
|
|
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
|
|
setImmediate(() => callback(null));
|
|
});
|
|
|
|
await isOnline();
|
|
|
|
expect(dnsLookupMock).toHaveBeenCalledWith(expectedHost, expect.any(Function));
|
|
});
|
|
|
|
it("settles once even if DNS responds after the timeout has already fired", async () => {
|
|
let dnsCallback: ((err: Error | null) => void) | null = null;
|
|
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
|
|
dnsCallback = callback;
|
|
});
|
|
|
|
const result = await isOnline(10);
|
|
expect(result).toBe(false);
|
|
|
|
// Late DNS callback must not throw, log, or re-resolve the already-
|
|
// settled promise. This mirrors what happens when DNS responds after
|
|
// we have already given up waiting.
|
|
expect(() => dnsCallback?.(null)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── isSkillsCliAvailable ─────────────────────────────────────────────────────
|
|
|
|
describe("isSkillsCliAvailable", () => {
|
|
beforeEach(() => {
|
|
execSyncMock.mockReset();
|
|
});
|
|
|
|
it("probes `npx --force --no-install skills --version` and returns true on success", () => {
|
|
execSyncMock.mockReturnValue(Buffer.from("0.1.0\n"));
|
|
|
|
expect(isSkillsCliAvailable()).toBe(true);
|
|
expect(execSyncMock).toHaveBeenCalledTimes(1);
|
|
const [cmd] = execSyncMock.mock.calls[0]!;
|
|
// `--force` softens the host project's npm engine gate so the probe can't
|
|
// hard-fail with EBADDEVENGINES in a devEngines-pinned repo (#298).
|
|
expect(cmd).toBe("npx --force --no-install skills --version");
|
|
});
|
|
|
|
it("returns false when the probe throws (skills CLI not in npx cache)", () => {
|
|
execSyncMock.mockImplementation(() => {
|
|
throw new Error("command failed");
|
|
});
|
|
|
|
expect(isSkillsCliAvailable()).toBe(false);
|
|
});
|
|
|
|
it("fully silences stdio so nothing leaks to the terminal", () => {
|
|
execSyncMock.mockReturnValue(Buffer.from(""));
|
|
|
|
isSkillsCliAvailable();
|
|
|
|
const opts = execSyncMock.mock.calls[0]![1] as
|
|
| { stdio?: [unknown, unknown, unknown] }
|
|
| undefined;
|
|
expect(opts?.stdio).toEqual(["ignore", "ignore", "ignore"]);
|
|
});
|
|
|
|
it("passes a timeout so a wedged npx cannot hang init forever", () => {
|
|
execSyncMock.mockReturnValue(Buffer.from(""));
|
|
|
|
isSkillsCliAvailable();
|
|
|
|
const opts = execSyncMock.mock.calls[0]![1] as { timeout?: number } | undefined;
|
|
expect(typeof opts?.timeout).toBe("number");
|
|
expect(opts!.timeout!).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ── localInstallCommand / localUninstallCommand ──────────────────────────────
|
|
|
|
describe("localInstallCommand", () => {
|
|
it("uses --save-dev for npm", () => {
|
|
expect(localInstallCommand("npm", "pkg")).toEqual({
|
|
bin: "npm",
|
|
args: ["install", "--save-dev", "pkg"],
|
|
});
|
|
});
|
|
it("uses add --dev for yarn", () => {
|
|
expect(localInstallCommand("yarn", "pkg")).toEqual({
|
|
bin: "yarn",
|
|
args: ["add", "--dev", "pkg"],
|
|
});
|
|
});
|
|
it("uses add -D for pnpm", () => {
|
|
expect(localInstallCommand("pnpm", "pkg")).toEqual({ bin: "pnpm", args: ["add", "-D", "pkg"] });
|
|
});
|
|
it("uses add -d for bun", () => {
|
|
expect(localInstallCommand("bun", "pkg")).toEqual({ bin: "bun", args: ["add", "-d", "pkg"] });
|
|
});
|
|
});
|
|
|
|
describe("localUninstallCommand", () => {
|
|
it("uses uninstall (project) for npm — not -g", () => {
|
|
expect(localUninstallCommand("npm", "pkg")).toEqual({ bin: "npm", args: ["uninstall", "pkg"] });
|
|
});
|
|
it("uses remove for yarn/pnpm/bun without -g", () => {
|
|
expect(localUninstallCommand("yarn", "pkg")).toEqual({ bin: "yarn", args: ["remove", "pkg"] });
|
|
expect(localUninstallCommand("pnpm", "pkg")).toEqual({ bin: "pnpm", args: ["remove", "pkg"] });
|
|
expect(localUninstallCommand("bun", "pkg")).toEqual({ bin: "bun", args: ["remove", "pkg"] });
|
|
});
|
|
});
|
|
|
|
// ── detectProjectPackageManager ──────────────────────────────────────────────
|
|
|
|
describe("detectProjectPackageManager", () => {
|
|
it("detects pnpm from pnpm-lock.yaml", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("detects yarn from yarn.lock", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
|
|
});
|
|
it("detects bun from bun.lockb", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "bun.lockb"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("bun");
|
|
});
|
|
it("detects npm from package-lock.json", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "package-lock.json"), "{}");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("npm");
|
|
});
|
|
it("pnpm wins over yarn when both lockfiles exist", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("falls back to a valid PM when no lockfile is present", () => {
|
|
expect(["npm", "yarn", "pnpm", "bun"]).toContain(detectProjectPackageManager(tmpDir));
|
|
});
|
|
it("honors the corepack packageManager field over lockfiles", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ packageManager: "pnpm@9.1.0" })
|
|
);
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("honors devEngines.packageManager (pnpm init default, pre-first-install)", () => {
|
|
// `pnpm init` (pnpm 10+) writes ONLY devEngines — no packageManager
|
|
// field, and no lockfile exists before the first install.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({
|
|
devEngines: { packageManager: { name: "pnpm", version: "^11.0.0", onFail: "download" } },
|
|
})
|
|
);
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("honors devEngines.packageManager in array form", () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devEngines: { packageManager: [{ name: "bun" }] } })
|
|
);
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("bun");
|
|
});
|
|
it("ignores a devEngines packageManager with an unknown name", () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devEngines: { packageManager: { name: "vlt" } } })
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
|
|
});
|
|
it("a real lockfile outranks devEngines (an OR-set of acceptable managers, not the project's choice)", () => {
|
|
// The common EBADDEVENGINES workaround declares npm AND pnpm acceptable;
|
|
// taking devEngines[0] would run `npm install` in a pnpm repo and stale
|
|
// pnpm-lock.yaml. The lockfile records what the project actually uses.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devEngines: { packageManager: [{ name: "npm" }, { name: "pnpm" }] } })
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("a multi-entry devEngines array alone identifies nothing (falls through)", () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devEngines: { packageManager: [{ name: "npm" }, { name: "pnpm" }] } })
|
|
);
|
|
// No lockfile: nothing identifies a single manager, so detection falls
|
|
// back to the user-agent default rather than guessing from entry order.
|
|
expect(["npm", "yarn", "pnpm", "bun"]).toContain(detectProjectPackageManager(tmpDir));
|
|
});
|
|
it("a stale devEngines declaration does not outrank the lockfile (pnpm→yarn/bun migration)", () => {
|
|
// yarn and bun neither read nor update devEngines, so a `pnpm init`-era
|
|
// declaration survives a migration forever while the lockfile moves on.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devEngines: { packageManager: { name: "pnpm", version: "^11.0.0" } } })
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
|
|
});
|
|
it("duplicate devEngines entries naming one manager still identify it", () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({
|
|
devEngines: { packageManager: [{ name: "pnpm", version: "^10" }, { name: "pnpm" }] },
|
|
})
|
|
);
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("the corepack packageManager field still outranks everything", () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({
|
|
packageManager: "bun@1.2.0",
|
|
devEngines: { packageManager: { name: "pnpm" } },
|
|
})
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("bun");
|
|
});
|
|
it("detects pnpm from pnpm-workspace.yaml when no lockfile exists yet", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
|
|
});
|
|
it("a real lockfile outranks a stray pnpm-workspace.yaml (pnpm→yarn migration)", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
|
|
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
|
|
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
|
|
});
|
|
it("walks up to a workspace-root lockfile (monorepo sub-package)", () => {
|
|
// pnpm/yarn workspaces keep the single lockfile at the monorepo root.
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
|
|
const sub = path.join(tmpDir, "packages", "app");
|
|
fs.mkdirSync(sub, { recursive: true });
|
|
fs.writeFileSync(path.join(sub, "package.json"), "{}");
|
|
expect(detectProjectPackageManager(sub)).toBe("pnpm");
|
|
});
|
|
it("stops the upward walk at a repo boundary (.git)", () => {
|
|
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
|
|
const repo = path.join(tmpDir, "other-repo");
|
|
fs.mkdirSync(path.join(repo, ".git"), { recursive: true });
|
|
// The sibling repo has no lockfile of its own; the outer pnpm lockfile
|
|
// must NOT bleed through the .git boundary.
|
|
expect(["npm", "yarn", "bun"]).toContain(detectProjectPackageManager(repo));
|
|
});
|
|
});
|
|
|
|
// ── Local install detection ──────────────────────────────────────────────────
|
|
|
|
describe("hasProjectPackageJson", () => {
|
|
it("is false without and true with a package.json", () => {
|
|
expect(hasProjectPackageJson(tmpDir)).toBe(false);
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), "{}");
|
|
expect(hasProjectPackageJson(tmpDir)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("isYarnPnp", () => {
|
|
it("detects .pnp.cjs and .pnp.loader.mjs", () => {
|
|
expect(isYarnPnp(tmpDir)).toBe(false);
|
|
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
|
|
expect(isYarnPnp(tmpDir)).toBe(true);
|
|
fs.rmSync(path.join(tmpDir, ".pnp.cjs"));
|
|
fs.writeFileSync(path.join(tmpDir, ".pnp.loader.mjs"), "");
|
|
expect(isYarnPnp(tmpDir)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("isLocallyInstalled / getLocallyInstalledVersion", () => {
|
|
it("is false / null when not installed", () => {
|
|
expect(isLocallyInstalled(tmpDir)).toBe(false);
|
|
expect(getLocallyInstalledVersion(tmpDir)).toBeNull();
|
|
});
|
|
it("is true and reads version from node_modules package.json", () => {
|
|
stageLocalArgent(tmpDir, { version: "9.9.9" });
|
|
expect(isLocallyInstalled(tmpDir)).toBe(true);
|
|
expect(getLocallyInstalledVersion(tmpDir)).toBe("9.9.9");
|
|
});
|
|
});
|
|
|
|
describe("getLocalArgentBinRelPath", () => {
|
|
it("returns null when not installed", () => {
|
|
expect(getLocalArgentBinRelPath(tmpDir)).toBeNull();
|
|
});
|
|
it("derives the bin path from the package.json bin map", () => {
|
|
const rel = stageLocalArgent(tmpDir);
|
|
expect(getLocalArgentBinRelPath(tmpDir)).toBe(rel);
|
|
expect(getLocalArgentBinRelPath(tmpDir)).toBe(`node_modules/${PACKAGE_NAME}/dist/cli.js`);
|
|
});
|
|
it("supports a string bin field", () => {
|
|
stageLocalArgent(tmpDir, { bin: "dist/cli.js" });
|
|
expect(getLocalArgentBinRelPath(tmpDir)).toBe(`node_modules/${PACKAGE_NAME}/dist/cli.js`);
|
|
});
|
|
it("returns null when the resolved bin file is missing on disk", () => {
|
|
stageLocalArgent(tmpDir, { withBinFile: false });
|
|
expect(getLocalArgentBinRelPath(tmpDir)).toBeNull();
|
|
});
|
|
it("uses forward slashes (committable / cross-platform)", () => {
|
|
stageLocalArgent(tmpDir);
|
|
expect(getLocalArgentBinRelPath(tmpDir)).not.toContain("\\");
|
|
});
|
|
it.skipIf(process.platform === "win32")(
|
|
"commits the stable node_modules path, not the pnpm version-pinned store dir",
|
|
() => {
|
|
// pnpm layout: node_modules/<pkg> symlinks into a version-suffixed .pnpm
|
|
// store dir, and module resolution returns that realpath. Committing it
|
|
// would bake the version into the MCP command; the stable symlink path wins.
|
|
const storeDir = path.join(
|
|
tmpDir,
|
|
"node_modules",
|
|
".pnpm",
|
|
"@swmansion+argent@0.13.0",
|
|
"node_modules",
|
|
PACKAGE_NAME
|
|
);
|
|
fs.mkdirSync(path.join(storeDir, "dist"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(storeDir, "package.json"),
|
|
JSON.stringify({ name: PACKAGE_NAME, version: "0.13.0", bin: { argent: "dist/cli.js" } })
|
|
);
|
|
fs.writeFileSync(path.join(storeDir, "dist", "cli.js"), "#!/usr/bin/env node\n");
|
|
const linkPath = path.join(tmpDir, "node_modules", PACKAGE_NAME);
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
fs.symlinkSync(storeDir, linkPath);
|
|
|
|
const rel = getLocalArgentBinRelPath(tmpDir);
|
|
expect(rel).toBe(`node_modules/${PACKAGE_NAME}/dist/cli.js`);
|
|
expect(rel).not.toContain(".pnpm");
|
|
expect(rel).not.toContain("0.13.0");
|
|
}
|
|
);
|
|
});
|
|
|
|
describe("probeLocalInstall / isDeclaredLocally", () => {
|
|
it("resolves a hoisted install from a sub-package (workspace layout)", () => {
|
|
// The package lives in the workspace-root node_modules; the project root
|
|
// is a sub-package. A hardcoded <root>/node_modules probe misses this.
|
|
stageLocalArgent(tmpDir, { version: "3.2.1" });
|
|
const sub = path.join(tmpDir, "packages", "app");
|
|
fs.mkdirSync(sub, { recursive: true });
|
|
fs.writeFileSync(path.join(sub, "package.json"), "{}");
|
|
const probe = probeLocalInstall(sub);
|
|
expect(probe.installed).toBe(true);
|
|
expect(probe.version).toBe("3.2.1");
|
|
expect(probe.packageDir).toBeTruthy();
|
|
});
|
|
it("treats a declared dep under Yarn PnP as installed (no node_modules)", () => {
|
|
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devDependencies: { [PACKAGE_NAME]: "1.4.0" } })
|
|
);
|
|
const probe = probeLocalInstall(tmpDir);
|
|
expect(probe.installed).toBe(true);
|
|
expect(probe.version).toBe("1.4.0"); // exact specifier doubles as the version
|
|
expect(probe.packageDir).toBeNull();
|
|
});
|
|
it("PnP with a range specifier is installed but version-unknown", () => {
|
|
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ devDependencies: { [PACKAGE_NAME]: "^1.4.0" } })
|
|
);
|
|
const probe = probeLocalInstall(tmpDir);
|
|
expect(probe.installed).toBe(true);
|
|
expect(probe.version).toBeNull();
|
|
});
|
|
it("PnP without a declaration is not installed", () => {
|
|
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), "{}");
|
|
expect(probeLocalInstall(tmpDir).installed).toBe(false);
|
|
});
|
|
it("isDeclaredLocally reads dependencies and devDependencies", () => {
|
|
expect(isDeclaredLocally(tmpDir)).toBe(false);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "package.json"),
|
|
JSON.stringify({ dependencies: { [PACKAGE_NAME]: "*" } })
|
|
);
|
|
expect(isDeclaredLocally(tmpDir)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Install-mode record (.argent/install.json) ───────────────────────────────
|
|
|
|
describe("install record", () => {
|
|
it("getInstallRecordPath points at <root>/.argent/install.json", () => {
|
|
expect(getInstallRecordPath(tmpDir)).toBe(path.join(tmpDir, ".argent", "install.json"));
|
|
});
|
|
it("write then read round-trips", () => {
|
|
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME, writtenBy: "1.0.0" });
|
|
expect(readInstallRecord(tmpDir)).toEqual({
|
|
mode: "local",
|
|
package: PACKAGE_NAME,
|
|
writtenBy: "1.0.0",
|
|
});
|
|
});
|
|
it("read returns null on a missing or malformed file", () => {
|
|
expect(readInstallRecord(tmpDir)).toBeNull();
|
|
fs.mkdirSync(path.join(tmpDir, ".argent"), { recursive: true });
|
|
fs.writeFileSync(getInstallRecordPath(tmpDir), "{ not json");
|
|
expect(readInstallRecord(tmpDir)).toBeNull();
|
|
});
|
|
it("read rejects an unknown mode value", () => {
|
|
fs.mkdirSync(path.join(tmpDir, ".argent"), { recursive: true });
|
|
fs.writeFileSync(getInstallRecordPath(tmpDir), JSON.stringify({ mode: "bogus" }));
|
|
expect(readInstallRecord(tmpDir)).toBeNull();
|
|
});
|
|
it("removeInstallRecord deletes the file and prunes an emptied .argent dir", () => {
|
|
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME });
|
|
expect(removeInstallRecord(tmpDir)).toBe(true);
|
|
expect(fs.existsSync(getInstallRecordPath(tmpDir))).toBe(false);
|
|
expect(fs.existsSync(path.join(tmpDir, ".argent"))).toBe(false);
|
|
expect(removeInstallRecord(tmpDir)).toBe(false);
|
|
});
|
|
it("removeInstallRecord keeps .argent when other files remain (e.g. flags.json)", () => {
|
|
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME });
|
|
fs.writeFileSync(path.join(tmpDir, ".argent", "flags.json"), "{}");
|
|
expect(removeInstallRecord(tmpDir)).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, ".argent"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("resolveInstallMode", () => {
|
|
const declareLocalArgent = (root: string) =>
|
|
fs.writeFileSync(
|
|
path.join(root, "package.json"),
|
|
JSON.stringify({ name: "host", devDependencies: { [PACKAGE_NAME]: "^1.2.3" } })
|
|
);
|
|
it("defaults to global with no record and no local install", () => {
|
|
expect(resolveInstallMode(tmpDir)).toBe("global");
|
|
});
|
|
it("does NOT infer local from mere node_modules presence (hoisted / transitive copy)", () => {
|
|
// A copy that merely exists in node_modules is not intent — acting on it
|
|
// would rewrite a manifest the user never opted into.
|
|
stageLocalArgent(tmpDir);
|
|
expect(resolveInstallMode(tmpDir)).toBe("global");
|
|
});
|
|
it("infers local from a dependency the project manifest declares (no record)", () => {
|
|
stageLocalArgent(tmpDir);
|
|
declareLocalArgent(tmpDir);
|
|
expect(resolveInstallMode(tmpDir)).toBe("local");
|
|
});
|
|
it("the committed record wins over inference", () => {
|
|
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME });
|
|
expect(resolveInstallMode(tmpDir)).toBe("local");
|
|
writeInstallRecord(tmpDir, { mode: "global", package: PACKAGE_NAME });
|
|
stageLocalArgent(tmpDir);
|
|
expect(resolveInstallMode(tmpDir)).toBe("global");
|
|
});
|
|
});
|
|
|
|
// ── resolveInstallModeFromFlags ──────────────────────────────────────────────
|
|
|
|
describe("resolveInstallModeFromFlags", () => {
|
|
it("returns local for --local (even with --yes)", () => {
|
|
expect(resolveInstallModeFromFlags({ local: true, global: false, nonInteractive: true })).toBe(
|
|
"local"
|
|
);
|
|
});
|
|
it("returns global for --global", () => {
|
|
expect(resolveInstallModeFromFlags({ local: false, global: true, nonInteractive: false })).toBe(
|
|
"global"
|
|
);
|
|
});
|
|
it("non-interactive with no flag defaults to global", () => {
|
|
expect(resolveInstallModeFromFlags({ local: false, global: false, nonInteractive: true })).toBe(
|
|
"global"
|
|
);
|
|
});
|
|
it("non-interactive honors a committed local record (no silent revert to global)", () => {
|
|
expect(
|
|
resolveInstallModeFromFlags({
|
|
local: false,
|
|
global: false,
|
|
nonInteractive: true,
|
|
recordedMode: "local",
|
|
})
|
|
).toBe("local");
|
|
});
|
|
it("an explicit --global still overrides a committed local record", () => {
|
|
expect(
|
|
resolveInstallModeFromFlags({
|
|
local: false,
|
|
global: true,
|
|
nonInteractive: true,
|
|
recordedMode: "local",
|
|
})
|
|
).toBe("global");
|
|
});
|
|
it("returns null (prompt) when interactive with no flag", () => {
|
|
expect(
|
|
resolveInstallModeFromFlags({ local: false, global: false, nonInteractive: false })
|
|
).toBeNull();
|
|
});
|
|
it("throws on conflicting --local and --global", () => {
|
|
expect(() =>
|
|
resolveInstallModeFromFlags({ local: true, global: true, nonInteractive: false })
|
|
).toThrow(InstallModeFlagError);
|
|
});
|
|
});
|