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)
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
// The version gate decides reuse-vs-respawn from the CURRENT on-disk bundle
|
|
// version (the package.json above the bundle), not the caller's import-time
|
|
// version. A real package layout is staged so the disk read resolves; tests
|
|
// cover downgrades, prerelease bumps, and a stale caller vs. a current server.
|
|
let launcher: typeof import("../src/launcher.js");
|
|
let TEST_HOME: string;
|
|
|
|
const FIXTURE_BUNDLE = resolve(__dirname, "fixtures/fake-tool-server.cjs");
|
|
|
|
let pkgDir: string;
|
|
let bundlePath: string;
|
|
|
|
function stageDiskVersion(version: string): void {
|
|
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "fake-argent", version }));
|
|
}
|
|
|
|
const paths = (callerVersion: string): import("../src/launcher.js").ToolsServerPaths => ({
|
|
bundlePath,
|
|
simulatorServerDir: "/unused/sim",
|
|
nativeDevtoolsDir: "/unused/dylibs",
|
|
version: callerVersion,
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
TEST_HOME = mkdtempSync(join(tmpdir(), "argent-version-gate-test-"));
|
|
process.env.HOME = TEST_HOME;
|
|
vi.resetModules();
|
|
launcher = await import("../src/launcher.js");
|
|
pkgDir = join(TEST_HOME, "pkg");
|
|
mkdirSync(join(pkgDir, "dist"), { recursive: true });
|
|
bundlePath = join(pkgDir, "dist", "tool-server.cjs");
|
|
copyFileSync(FIXTURE_BUNDLE, bundlePath);
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(TEST_HOME, { recursive: true, force: true });
|
|
});
|
|
|
|
const spawnedPids: number[] = [];
|
|
afterEach(() => {
|
|
for (const pid of spawnedPids.splice(0)) {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
}
|
|
rmSync(launcher.STATE_PATHS.STATE_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
async function waitForDeath(pid: number, timeoutMs = 12_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline && launcher.isToolsServerProcessAlive(pid)) {
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
}
|
|
|
|
async function spawnTracked(recordedVersion: string): Promise<{ pid: number; port: number }> {
|
|
const server = await launcher.spawnToolsServer(
|
|
paths(recordedVersion),
|
|
await launcher.findFreePort(),
|
|
{ token: "gate-token" }
|
|
);
|
|
spawnedPids.push(server.pid);
|
|
await launcher.writeToolsServerState({
|
|
port: server.port,
|
|
pid: server.pid,
|
|
startedAt: new Date().toISOString(),
|
|
bundlePath,
|
|
version: recordedVersion,
|
|
host: "127.0.0.1",
|
|
token: "gate-token",
|
|
managed: "autospawn",
|
|
});
|
|
return server;
|
|
}
|
|
|
|
describe("ensureToolsServer — disk-version gate", () => {
|
|
it(
|
|
"retires the tracked server after an on-disk DOWNGRADE, even though the caller is not newer",
|
|
{ timeout: 30_000 },
|
|
async () => {
|
|
// Downgrade away from a bad release with --ignore-scripts (no postinstall
|
|
// kill): the tracked 2.0.0 server's code no longer exists on disk, so it
|
|
// must be retired even though the caller (also 2.0.0) is not newer.
|
|
stageDiskVersion("1.0.0");
|
|
const old = await spawnTracked("2.0.0");
|
|
|
|
const handle = await launcher.ensureToolsServer(paths("2.0.0"));
|
|
|
|
await waitForDeath(old.pid);
|
|
expect(launcher.isToolsServerProcessAlive(old.pid)).toBe(false);
|
|
const state = await launcher.readToolsServerState(bundlePath);
|
|
expect(state).not.toBeNull();
|
|
expect(state!.pid).not.toBe(old.pid);
|
|
// The record carries what the new server actually runs: the disk version.
|
|
expect(state!.version).toBe("1.0.0");
|
|
spawnedPids.push(state!.pid);
|
|
expect(handle.url).toBe(launcher.formatToolsServerUrl("127.0.0.1", state!.port));
|
|
}
|
|
);
|
|
|
|
it(
|
|
"retires the tracked server after a PRERELEASE bump (rc.1 -> rc.2)",
|
|
{ timeout: 30_000 },
|
|
async () => {
|
|
stageDiskVersion("1.0.0-rc.2");
|
|
const old = await spawnTracked("1.0.0-rc.1");
|
|
|
|
await launcher.ensureToolsServer(paths("1.0.0-rc.2"));
|
|
|
|
await waitForDeath(old.pid);
|
|
expect(launcher.isToolsServerProcessAlive(old.pid)).toBe(false);
|
|
const state = await launcher.readToolsServerState(bundlePath);
|
|
expect(state!.version).toBe("1.0.0-rc.2");
|
|
spawnedPids.push(state!.pid);
|
|
}
|
|
);
|
|
|
|
it(
|
|
"reuses a current server even when THIS caller is frozen at an older version",
|
|
{ timeout: 30_000 },
|
|
async () => {
|
|
// A long-lived MCP session frozen at 1.0.0 while disk and tracked server
|
|
// agree on 2.0.0: reuse — both sessions read the same disk, so neither
|
|
// tears the other down (no ping-pong, without going version-blind).
|
|
stageDiskVersion("2.0.0");
|
|
const current = await spawnTracked("2.0.0");
|
|
|
|
const handle = await launcher.ensureToolsServer(paths("1.0.0"));
|
|
|
|
expect(launcher.isToolsServerProcessAlive(current.pid)).toBe(true);
|
|
const state = await launcher.readToolsServerState(bundlePath);
|
|
expect(state!.pid).toBe(current.pid);
|
|
expect(handle.token).toBe("gate-token");
|
|
}
|
|
);
|
|
});
|
|
|
|
describe("isVersionNewer — prerelease-aware precedence", () => {
|
|
it("orders numeric cores", () => {
|
|
expect(launcher.isVersionNewer("1.2.3", "1.2.2")).toBe(true);
|
|
expect(launcher.isVersionNewer("1.2.2", "1.2.3")).toBe(false);
|
|
expect(launcher.isVersionNewer("1.2.3", "1.2.3")).toBe(false);
|
|
});
|
|
|
|
it("a release outranks its own prereleases", () => {
|
|
expect(launcher.isVersionNewer("1.0.0", "1.0.0-rc.1")).toBe(true);
|
|
expect(launcher.isVersionNewer("1.0.0-rc.1", "1.0.0")).toBe(false);
|
|
});
|
|
|
|
it("orders prerelease identifiers per semver", () => {
|
|
expect(launcher.isVersionNewer("1.0.0-rc.2", "1.0.0-rc.1")).toBe(true);
|
|
expect(launcher.isVersionNewer("1.0.0-rc.1", "1.0.0-rc.2")).toBe(false);
|
|
expect(launcher.isVersionNewer("1.0.0-rc.10", "1.0.0-rc.9")).toBe(true);
|
|
expect(launcher.isVersionNewer("1.0.0-rc.1.1", "1.0.0-rc.1")).toBe(true);
|
|
expect(launcher.isVersionNewer("1.0.0-rc", "1.0.0-alpha")).toBe(true);
|
|
// Numeric identifiers rank below alphanumeric ones.
|
|
expect(launcher.isVersionNewer("1.0.0-alpha", "1.0.0-1")).toBe(true);
|
|
expect(launcher.isVersionNewer("1.0.0-1", "1.0.0-alpha")).toBe(false);
|
|
});
|
|
|
|
it("ignores build metadata and treats unparseable input as not newer", () => {
|
|
expect(launcher.isVersionNewer("1.0.1+build5", "1.0.0")).toBe(true);
|
|
expect(launcher.isVersionNewer("garbage", "1.0.0")).toBe(false);
|
|
expect(launcher.isVersionNewer("1.0.0", "garbage")).toBe(false);
|
|
});
|
|
});
|