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)
189 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|