Files
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

189 lines
6.3 KiB
TypeScript

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
// Same HOME-redirection pattern as launcher-state.test.ts so killToolServer
// reads/writes the per-file isolated state directory and never touches the
// developer's real ~/.argent.
let launcher: typeof import("../src/launcher.js");
let TEST_HOME: string;
const FAKE_BUNDLE = resolve(__dirname, "fixtures/fake-tool-server.cjs");
const fakePaths = (): import("../src/launcher.js").ToolsServerPaths => ({
bundlePath: FAKE_BUNDLE,
simulatorServerDir: "/unused/sim",
nativeDevtoolsDir: "/unused/dylibs",
});
beforeAll(async () => {
TEST_HOME = mkdtempSync(join(tmpdir(), "argent-spawn-test-"));
process.env.HOME = TEST_HOME;
vi.resetModules();
launcher = await import("../src/launcher.js");
expect(existsSync(FAKE_BUNDLE)).toBe(true);
});
afterAll(() => {
rmSync(TEST_HOME, { recursive: true, force: true });
});
// Ensure no stray children survive a failing test.
const spawnedPids: number[] = [];
afterEach(async () => {
for (const pid of spawnedPids.splice(0)) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* already dead */
}
}
await launcher.clearToolsServerState(FAKE_BUNDLE);
});
async function trackedSpawn(
port = 0,
options: import("../src/launcher.js").SpawnToolsServerOptions = {}
) {
const free = port === 0 ? await launcher.findFreePort() : port;
const result = await launcher.spawnToolsServer(fakePaths(), free, options);
spawnedPids.push(result.pid);
return result;
}
describe("spawnToolsServer", () => {
it("resolves with the bound port and pid once the ready banner appears", async () => {
const requested = await launcher.findFreePort();
const { port, pid } = await trackedSpawn(requested);
expect(port).toBe(requested);
expect(pid).toBeGreaterThan(0);
expect(launcher.isToolsServerProcessAlive(pid)).toBe(true);
});
it("propagates host into the child via ARGENT_HOST and serves /tools there", async () => {
const requested = await launcher.findFreePort();
const { port } = await trackedSpawn(requested, { host: "127.0.0.1" });
const healthy = await launcher.isToolsServerHealthy(port, "127.0.0.1", 2000);
expect(healthy).toBe(true);
});
it("rejects if the child exits before printing the ready banner", async () => {
process.env.FAKE_MODE = "exit-immediate";
try {
await expect(trackedSpawn()).rejects.toThrow(/exited with code 7/);
} finally {
delete process.env.FAKE_MODE;
}
});
});
describe("killToolServer — full lifecycle", () => {
it("graceful shutdown: SIGTERM stops the child and clears the state file", async () => {
const { port, pid } = await trackedSpawn();
await launcher.writeToolsServerState({
port,
pid,
startedAt: new Date().toISOString(),
bundlePath: FAKE_BUNDLE,
host: "127.0.0.1",
});
expect(launcher.isToolsServerProcessAlive(pid)).toBe(true);
await launcher.killToolServer(FAKE_BUNDLE);
expect(launcher.isToolsServerProcessAlive(pid)).toBe(false);
expect(await launcher.readToolsServerState(FAKE_BUNDLE)).toBeNull();
});
it(
"escalates to SIGKILL when the child swallows SIGTERM (the EADDRINUSE-restart fix)",
{ timeout: 20_000 },
async () => {
process.env.FAKE_IGNORE_SIGTERM = "1";
let pid: number;
try {
const spawned = await trackedSpawn();
pid = spawned.pid;
await launcher.writeToolsServerState({
port: spawned.port,
pid,
startedAt: new Date().toISOString(),
bundlePath: FAKE_BUNDLE,
host: "127.0.0.1",
});
} finally {
delete process.env.FAKE_IGNORE_SIGTERM;
}
const start = Date.now();
await launcher.killToolServer(FAKE_BUNDLE);
const elapsed = Date.now() - start;
expect(launcher.isToolsServerProcessAlive(pid)).toBe(false);
expect(await launcher.readToolsServerState(FAKE_BUNDLE)).toBeNull();
// SIGTERM grace is 6s; SIGKILL must arrive after that. Loose upper
// bound prevents flaky failures on slow CI hosts.
expect(elapsed).toBeGreaterThanOrEqual(5_500);
expect(elapsed).toBeLessThan(15_000);
}
);
it("clears state when the recorded pid is already dead before killToolServer is called", async () => {
const { pid } = await trackedSpawn();
process.kill(pid, "SIGKILL");
// Wait for the OS to reap the process so isProcessAlive flips to false.
for (let i = 0; i < 50 && launcher.isToolsServerProcessAlive(pid); i++) {
await new Promise((r) => setTimeout(r, 20));
}
await launcher.writeToolsServerState({
port: 1,
pid,
startedAt: new Date().toISOString(),
bundlePath: FAKE_BUNDLE,
host: "127.0.0.1",
});
await launcher.killToolServer(FAKE_BUNDLE);
expect(await launcher.readToolsServerState(FAKE_BUNDLE)).toBeNull();
});
});
describe("ensureToolsServer", () => {
it("reuses the already-running server reported by the state file", async () => {
const first = await launcher.ensureToolsServer(fakePaths());
spawnedPids.push((await launcher.readToolsServerState(FAKE_BUNDLE))!.pid);
const second = await launcher.ensureToolsServer(fakePaths());
// ensureToolsServer returns a fresh handle each call; on reuse the url +
// token are derived from the same state file, so compare by value.
expect(second).toEqual(first);
const state = await launcher.readToolsServerState(FAKE_BUNDLE);
expect(state?.pid).toBe(spawnedPids[0]);
});
it("treats a stale state file (dead pid) as 'no server' and respawns", async () => {
await launcher.writeToolsServerState({
port: 1,
pid: 2_147_483_646,
startedAt: "2025-01-01T00:00:00.000Z",
bundlePath: FAKE_BUNDLE,
host: "127.0.0.1",
});
const handle = await launcher.ensureToolsServer(fakePaths());
const fresh = await launcher.readToolsServerState(FAKE_BUNDLE);
expect(fresh).not.toBeNull();
expect(fresh!.pid).not.toBe(2_147_483_646);
expect(launcher.isToolsServerProcessAlive(fresh!.pid)).toBe(true);
spawnedPids.push(fresh!.pid);
expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(await launcher.isToolsServerHealthy(fresh!.port, "127.0.0.1")).toBe(true);
});
});