Files
software-mansion__argent/packages/tool-server/test/update-argent-tool.test.ts
filip131311 7bd017a796 feat(installer): committable repo-local (--local) install mode (#428)
## 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)
2026-07-08 12:24:19 +02:00

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();
});
});