mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
7bd017a796
## Summary Adds an opt-in **committable / repo-local** install mode so a team can version Argent _with the repo_. `argent init --local` installs `@swmansion/argent` as a project **devDependency** and writes MCP configs that launch the project-local copy — so after `git clone && npm install`, every teammate has the same Argent version and MCP wiring with **no global install and no per-developer `argent init`**. **Global mode stays the default** and is unchanged; everything here is additive and backward-compatible. Motivation: the workflow some users already do by hand (#207). Supersedes the earlier, since-closed #232 — this version is Windows-safe (`node`, not the `.bin` shim), handles Yarn PnP, and closes the cross-install tool-server hazards rather than leaving them. ## What changed **`init`** - `--local` / `--global` flags + an interactive "How should argent be installed?" prompt. Mutually exclusive; non-interactive (`--yes`) defaults to **global**. - Local mode installs the devDependency with the package manager detected from the project's **lockfile** (run with `cwd = projectRoot`). - MCP entry points at the local copy: `node node_modules/@swmansion/argent/dist/cli.js mcp` (the bin path is derived from the installed `package.json` `bin` and existence-checked — forward slashes, committable, Windows-safe). Yarn PnP → `yarn argent mcp`; an `npx --no-install argent mcp` fallback is used only when the bin can't be resolved. - Records the mode in a committed `<root>/.argent/install.json`. - Drops global-only adapters (Windsurf/Hermes) in local mode; prints team-setup / commit guidance. **`update`** — detects the project's mode; in local mode reads the installed version from `node_modules` (not the global binary / npx cache) and bumps the **devDependency + lockfile**, then re-emits the local MCP command. **`uninstall`** — scoped to the project's mode: a local-mode uninstall removes the **local devDependency only and never the shared global install**, and prunes `.argent/install.json`. Global-mode behavior is unchanged. **Cross-install hardening (the gate that makes per-project pinning safe)** - `launcher`: a tool-server is reused only when its `bundlePath` matches the caller's — a repo-local pin never silently runs against (or kills) a different version's server. - `postinstall`: only stops a tool-server spawned from *this package's own* bundle, so a devDependency install can't tear down an unrelated editor session. **Telemetry** — adds an `install_mode` dimension to init/update/uninstall events. ## Testing - New unit tests: install-mode helpers + record (`utils`), mode-aware `getMcpEntry`/`resolveLocalCommandMode` incl. the opencode array form (`mcp-configs`), local-only uninstall guard (`uninstall`), and bundlePath-gated tool-server reuse (`launcher`). - All existing suites green (installer 346, tools-client 115, telemetry 191, registry 83, argent 13, tool-server 1621). Build + lint clean. - **Live end-to-end** in an isolated sandbox: `init --local` → real MCP stdio handshake serves **69 tools from `node_modules`**; `update --local` reads the local version; `uninstall --local` removes the local install and **leaves the global install intact**; `init --global` unchanged; flag conflict rejected. ## Notes / known limitations - Recommended on mainstream platforms (macOS, Linux x64, Windows x64) with prebuilt native binaries; Linux arm64 / Windows arm need a C/C++ toolchain to build `tree-sitter` on `npm install` (documented in the README). - Concurrent sessions of two *different* Argent versions on one machine each spawn their own tool-server; an orphaned one idle-times-out (no session is ever broken). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
// Mock child_process and update-checker before importing the module under test.
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../src/utils/update-checker", () => ({
|
|
getUpdateState: vi.fn(),
|
|
}));
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { getUpdateState } from "../src/utils/update-checker";
|
|
|
|
const mockSpawn = vi.mocked(spawn);
|
|
const mockGetUpdateState = vi.mocked(getUpdateState);
|
|
|
|
function makeChild() {
|
|
// `on` is required: the tool attaches 'error' and 'exit' listeners (spawn
|
|
// ENOENT must not crash the tool-server; a no-op updater exit unblocks later calls).
|
|
return { unref: vi.fn(), on: vi.fn() } as unknown as ReturnType<typeof spawn>;
|
|
}
|
|
|
|
function stateWithUpdate(latestVersion = "99.0.0") {
|
|
return {
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
installableVersion: latestVersion,
|
|
currentVersion: "1.0.0",
|
|
latestVersion,
|
|
latestPublishedAt: null,
|
|
minReleaseAgeMs: 0,
|
|
};
|
|
}
|
|
|
|
function stateHeldByPolicy(latestVersion = "99.0.0") {
|
|
return {
|
|
updateAvailable: true,
|
|
updateInstallable: false,
|
|
installableVersion: null,
|
|
currentVersion: "1.0.0",
|
|
latestVersion,
|
|
latestPublishedAt: null,
|
|
minReleaseAgeMs: 7 * 24 * 60 * 60 * 1000,
|
|
};
|
|
}
|
|
|
|
function stateUpToDate() {
|
|
return {
|
|
updateAvailable: false,
|
|
updateInstallable: false,
|
|
installableVersion: null,
|
|
currentVersion: "1.0.0",
|
|
latestVersion: "1.0.0",
|
|
latestPublishedAt: null,
|
|
minReleaseAgeMs: 0,
|
|
};
|
|
}
|
|
|
|
describe("update-argent tool", () => {
|
|
// Re-import per test so the module-level `updateScheduled` flag resets.
|
|
let updateArgentTool: typeof import("../src/tools/system/update-argent").updateArgentTool;
|
|
let savedInstallKind: string | undefined;
|
|
let savedProjectRoot: string | undefined;
|
|
let originalCwd: string;
|
|
let tmpDir: string;
|
|
|
|
// A directory that provably hosts a local argent install, for tests that
|
|
// exercise the project-root resolution of a local-targeted update.
|
|
function stageDeclaringProject(): string {
|
|
const projDir = path.join(tmpDir, "proj");
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projDir, "package.json"),
|
|
JSON.stringify({ name: "proj", devDependencies: { "@swmansion/argent": "^1.0.0" } })
|
|
);
|
|
return projDir;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.useFakeTimers();
|
|
vi.resetModules();
|
|
mockSpawn.mockReturnValue(makeChild());
|
|
mockGetUpdateState.mockReturnValue(stateWithUpdate());
|
|
// Pin the running-install classification: the fallback cwd-walk would
|
|
// otherwise pick up whatever repository the test process runs in.
|
|
savedInstallKind = process.env.ARGENT_INSTALL_KIND;
|
|
savedProjectRoot = process.env.ARGENT_PROJECT_ROOT;
|
|
process.env.ARGENT_INSTALL_KIND = "global";
|
|
delete process.env.ARGENT_PROJECT_ROOT;
|
|
originalCwd = process.cwd();
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-update-tool-"));
|
|
// A cwd with no ancestor declaring argent, so findDeclaringProjectRoot is
|
|
// deterministic (os.tmpdir() ancestors never declare the package).
|
|
process.chdir(tmpDir);
|
|
const mod = await import("../src/tools/system/update-argent");
|
|
updateArgentTool = mod.updateArgentTool;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
if (savedInstallKind === undefined) delete process.env.ARGENT_INSTALL_KIND;
|
|
else process.env.ARGENT_INSTALL_KIND = savedInstallKind;
|
|
if (savedProjectRoot === undefined) delete process.env.ARGENT_PROJECT_ROOT;
|
|
else process.env.ARGENT_PROJECT_ROOT = savedProjectRoot;
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns a message with current and latest version on success", async () => {
|
|
const result = await updateArgentTool.execute({}, {}, undefined);
|
|
expect((result as { message: string }).message).toContain("1.0.0 -> v99.0.0");
|
|
expect((result as { message: string }).message).toContain("restart");
|
|
});
|
|
|
|
it("spawns `argent update` with a pinned target flag (not npx) after 2 seconds", async () => {
|
|
await updateArgentTool.execute({}, { target: "global" }, undefined);
|
|
|
|
vi.advanceTimersByTime(1999);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
["update", "--yes", "--global", "--version", "99.0.0"],
|
|
{
|
|
detached: true,
|
|
stdio: "ignore",
|
|
env: { ...process.env, ARGENT_UPDATE_TRIGGER: "mcp_update" },
|
|
}
|
|
);
|
|
});
|
|
|
|
it("a global-serving server refuses a local target instead of guessing the project", async () => {
|
|
// A global server is shared across every project of that install and its
|
|
// cwd is frozen at whatever session spawned it FIRST — walking it could
|
|
// pin a different project than the caller's and rewrite that project's
|
|
// manifest. Refuse with CLI guidance, even when cwd looks plausible.
|
|
const projDir = stageDeclaringProject();
|
|
process.chdir(projDir);
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "local" }, undefined);
|
|
|
|
expect((result as { message: string }).message).toContain("Could not determine which project");
|
|
vi.advanceTimersByTime(5000);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("pins --global (no --version) and --project-root when a local-serving server targets the global install", async () => {
|
|
// Cross-install direction that IS provable: the running local install
|
|
// knows its own project, and the --version pin describes the RUNNING
|
|
// install, so the installer resolves the global target's version itself.
|
|
const projDir = stageDeclaringProject();
|
|
process.env.ARGENT_INSTALL_KIND = "local";
|
|
process.env.ARGENT_PROJECT_ROOT = projDir;
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "global" }, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
["update", "--yes", "--global", "--project-root", projDir],
|
|
expect.not.objectContaining({ cwd: expect.anything() })
|
|
);
|
|
// Honest restart semantics: the running (local) server is not the target.
|
|
expect((result as { message: string }).message).toContain("not affected");
|
|
expect((result as { message: string }).message).not.toContain("will stop and restart");
|
|
});
|
|
|
|
it("pins --version and the recorded project root when the running install IS the local target", async () => {
|
|
const projDir = stageDeclaringProject();
|
|
process.env.ARGENT_INSTALL_KIND = "local";
|
|
process.env.ARGENT_PROJECT_ROOT = projDir;
|
|
|
|
await updateArgentTool.execute({}, { target: "local" }, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
["update", "--yes", "--local", "--version", "99.0.0", "--project-root", projDir],
|
|
expect.not.objectContaining({ cwd: expect.anything() })
|
|
);
|
|
});
|
|
|
|
it("re-proves a recorded project root that no longer exists instead of pinning it", async () => {
|
|
// ARGENT_PROJECT_ROOT points at a since-deleted project; the tool must not
|
|
// schedule an update bound to a dead path.
|
|
process.env.ARGENT_INSTALL_KIND = "local";
|
|
process.env.ARGENT_PROJECT_ROOT = path.join(tmpDir, "deleted-project");
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "local" }, undefined);
|
|
|
|
expect((result as { message: string }).message).toContain("Could not determine which project");
|
|
vi.advanceTimersByTime(5000);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a local target when no project can be located, instead of guessing", async () => {
|
|
const result = await updateArgentTool.execute({}, { target: "local" }, undefined);
|
|
|
|
expect((result as { message: string }).message).toContain("Could not determine which project");
|
|
vi.advanceTimersByTime(5000);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("pins both --global and --local when a local-serving server targets 'both'", async () => {
|
|
const projDir = stageDeclaringProject();
|
|
process.env.ARGENT_INSTALL_KIND = "local";
|
|
process.env.ARGENT_PROJECT_ROOT = projDir;
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "both" }, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
["update", "--yes", "--global", "--local", "--project-root", projDir],
|
|
expect.not.objectContaining({ cwd: expect.anything() })
|
|
);
|
|
// 'both' covers the running install — the restart promise holds.
|
|
expect((result as { message: string }).message).toContain("will stop and restart");
|
|
});
|
|
|
|
it("degrades 'both' to --global (and says so) when no project is locatable", async () => {
|
|
const result = await updateArgentTool.execute({}, { target: "both" }, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
// The degraded target IS the running install, so its gates and version pin
|
|
// apply like a plain global update.
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
["update", "--yes", "--global", "--version", "99.0.0"],
|
|
expect.not.objectContaining({ cwd: expect.anything() })
|
|
);
|
|
expect((result as { message: string }).message).toContain("project-local install was skipped");
|
|
});
|
|
|
|
it("a degraded 'both' honors the running install's up-to-date gate", async () => {
|
|
// With no locatable project, 'both' degrades to the running install — so an
|
|
// up-to-date state must answer instead of spawning a pointless updater.
|
|
mockGetUpdateState.mockReturnValue(stateUpToDate());
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "both" }, undefined);
|
|
|
|
expect((result as { message: string }).message).toContain("already up to date");
|
|
vi.advanceTimersByTime(5000);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("auto (default) pins exactly one target flag and hints at the other install", async () => {
|
|
const result = await updateArgentTool.execute({}, {}, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
const args = mockSpawn.mock.calls[0]![1] as string[];
|
|
expect(args[0]).toBe("update");
|
|
expect(args).toContain("--version");
|
|
expect(args).toContain("99.0.0");
|
|
// In 'auto' mode we resolve to the single install serving this session.
|
|
const flags = args.filter((a) => a === "--global" || a === "--local");
|
|
expect(flags).toHaveLength(1);
|
|
// ...and hint at the OTHER install so the agent can offer to update it too.
|
|
// The global->local direction goes through the CLI, which can bind the
|
|
// right project — the hint must say so.
|
|
expect((result as { message: string }).message).toMatch(/argent update --local/);
|
|
});
|
|
|
|
it("does NOT spawn before 2 seconds have elapsed", async () => {
|
|
await updateArgentTool.execute({}, {}, undefined);
|
|
vi.advanceTimersByTime(1999);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls unref() on the child process", async () => {
|
|
const child = makeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
await updateArgentTool.execute({}, {}, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(child.unref).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("swallows a spawn error and allows a retry (no tool-server crash in local mode)", async () => {
|
|
const handlers: Record<string, (err: Error) => void> = {};
|
|
const child = {
|
|
unref: vi.fn(),
|
|
on: vi.fn((event: string, cb: (err: Error) => void) => {
|
|
handlers[event] = cb;
|
|
return child;
|
|
}),
|
|
} as unknown as ReturnType<typeof spawn>;
|
|
mockSpawn.mockReturnValue(child);
|
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
await updateArgentTool.execute({}, {}, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
// ENOENT (`argent` not on PATH): the 'error' handler must exist and not throw.
|
|
expect(handlers.error).toBeTypeOf("function");
|
|
expect(() => handlers.error!(new Error("spawn argent ENOENT"))).not.toThrow();
|
|
|
|
// The scheduled flag reset, so a later call re-attempts rather than reporting
|
|
// "already in progress" forever.
|
|
const second = await updateArgentTool.execute({}, {}, undefined);
|
|
expect((second as { message: string }).message).not.toContain("already in progress");
|
|
vi.advanceTimersByTime(2000);
|
|
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it("a no-op updater exit unblocks later calls (the server was not restarted)", async () => {
|
|
const handlers: Record<string, (...args: unknown[]) => void> = {};
|
|
const child = {
|
|
unref: vi.fn(),
|
|
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
handlers[event] = cb;
|
|
return child;
|
|
}),
|
|
} as unknown as ReturnType<typeof spawn>;
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
await updateArgentTool.execute({}, {}, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
// The updater exited without restarting this server (a no-op, e.g. a
|
|
// cross-install target already current); the next call must not be stuck
|
|
// behind "already in progress".
|
|
expect(handlers.exit).toBeTypeOf("function");
|
|
handlers.exit!(0);
|
|
|
|
const second = await updateArgentTool.execute({}, {}, undefined);
|
|
expect((second as { message: string }).message).not.toContain("already in progress");
|
|
});
|
|
|
|
it("returns 'already up to date' when no update is available for the running install", async () => {
|
|
mockGetUpdateState.mockReturnValue(stateUpToDate());
|
|
|
|
const result = await updateArgentTool.execute({}, {}, undefined);
|
|
expect((result as { message: string }).message).toContain("already up to date");
|
|
vi.advanceTimersByTime(5000);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a cross-install target bypasses the running install's up-to-date gate", async () => {
|
|
// The update state describes only the RUNNING (local) install — it says
|
|
// nothing about the global install; the installer gets the final word.
|
|
mockGetUpdateState.mockReturnValue(stateUpToDate());
|
|
const projDir = stageDeclaringProject();
|
|
process.env.ARGENT_INSTALL_KIND = "local";
|
|
process.env.ARGENT_PROJECT_ROOT = projDir;
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "global" }, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect((result as { message: string }).message).toContain("update initiated");
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
["update", "--yes", "--global", "--project-root", projDir],
|
|
expect.not.objectContaining({ cwd: expect.anything() })
|
|
);
|
|
});
|
|
|
|
it("accepts an optionalDependencies declaration when re-proving a local server's project", async () => {
|
|
// A local-serving server spawned by an older argent (no ARGENT_PROJECT_ROOT
|
|
// recorded): the cwd-walk fallback applies — its cwd IS its project.
|
|
const projDir = path.join(tmpDir, "opt-proj");
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projDir, "package.json"),
|
|
JSON.stringify({ name: "opt", optionalDependencies: { "@swmansion/argent": "^1.0.0" } })
|
|
);
|
|
process.env.ARGENT_INSTALL_KIND = "local";
|
|
process.chdir(projDir);
|
|
|
|
const result = await updateArgentTool.execute({}, { target: "local" }, undefined);
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect((result as { message: string }).message).toContain("update initiated");
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"argent",
|
|
[
|
|
"update",
|
|
"--yes",
|
|
"--local",
|
|
"--version",
|
|
"99.0.0",
|
|
"--project-root",
|
|
fs.realpathSync(projDir),
|
|
],
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("returns a held-by-policy message and does not spawn when the latest update is not installable yet", async () => {
|
|
mockGetUpdateState.mockReturnValue(stateHeldByPolicy());
|
|
|
|
const result = await updateArgentTool.execute({}, {}, undefined);
|
|
expect((result as { message: string }).message).toContain("not installable yet");
|
|
vi.advanceTimersByTime(5000);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 'already in progress' and does not double-spawn on second call", async () => {
|
|
await updateArgentTool.execute({}, {}, undefined);
|
|
const second = await updateArgentTool.execute({}, {}, undefined);
|
|
|
|
expect((second as { message: string }).message).toContain("already in progress");
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
// Only one spawn despite two calls.
|
|
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
});
|
|
});
|