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)
212 lines
9.3 KiB
TypeScript
212 lines
9.3 KiB
TypeScript
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
getInstalledVersion,
|
|
getGloballyInstalledVersion,
|
|
detectPackageManager,
|
|
globalInstallCommand,
|
|
formatShellCommand,
|
|
} from "../src/utils.js";
|
|
import { getUpdateTriggerFromEnv, resolveUpdatePackageAction } from "../src/update.js";
|
|
import { PACKAGE_NAME, NPM_REGISTRY } from "../src/constants.js";
|
|
|
|
describe("update — version comparison logic", () => {
|
|
it("getInstalledVersion returns a semver-like string or null", () => {
|
|
const version = getInstalledVersion();
|
|
// In the test environment this reads the package.json from the dist/..
|
|
// directory which may not exist; either way the function should not throw.
|
|
expect(version === null || /^\d+\.\d+\.\d+/.test(version)).toBe(true);
|
|
});
|
|
|
|
it("getGloballyInstalledVersion returns a semver-like string or null", () => {
|
|
// Test envs may or may not have a global argent install; either result
|
|
// is acceptable as long as the call doesn't throw and the shape is
|
|
// valid. The point of this helper is to NOT confuse the running
|
|
// (possibly npx-cached) version with the persisted global one.
|
|
const version = getGloballyInstalledVersion();
|
|
expect(version === null || /^\d+\.\d+\.\d+/.test(version)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("update — install command generation", () => {
|
|
const original = process.env.npm_config_user_agent;
|
|
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.npm_config_user_agent;
|
|
else process.env.npm_config_user_agent = original;
|
|
});
|
|
|
|
it("generates correct npm update command without --registry", () => {
|
|
delete process.env.npm_config_user_agent;
|
|
const pm = detectPackageManager();
|
|
const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@1.0.0`);
|
|
const cmdStr = formatShellCommand(cmd);
|
|
expect(cmdStr).toContain("npm install -g");
|
|
expect(cmdStr).toContain(PACKAGE_NAME);
|
|
expect(cmdStr).not.toContain("--registry");
|
|
});
|
|
|
|
it("generates correct pnpm update command", () => {
|
|
process.env.npm_config_user_agent = "pnpm/9.0.0";
|
|
const pm = detectPackageManager();
|
|
const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@1.0.0`);
|
|
const cmdStr = formatShellCommand(cmd);
|
|
expect(cmdStr).toContain("pnpm add -g");
|
|
expect(cmdStr).toContain(PACKAGE_NAME);
|
|
});
|
|
});
|
|
|
|
describe("update — constants are correct", () => {
|
|
it("PACKAGE_NAME is @swmansion/argent", () => {
|
|
expect(PACKAGE_NAME).toBe("@swmansion/argent");
|
|
});
|
|
|
|
it("NPM_REGISTRY is the npm registry", () => {
|
|
expect(NPM_REGISTRY).toContain("registry.npmjs.org");
|
|
});
|
|
});
|
|
|
|
describe("update — registry safety", () => {
|
|
it("globalInstallCommand never includes --registry (relies on .npmrc scoped registry)", () => {
|
|
for (const pm of ["npm", "yarn", "pnpm", "bun"] as const) {
|
|
const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@1.0.0`);
|
|
const cmdStr = formatShellCommand(cmd);
|
|
expect(cmdStr).not.toContain("--registry");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("update — telemetry package action tagging", () => {
|
|
it("distinguishes standalone update/install from MCP-triggered update", () => {
|
|
expect(resolveUpdatePackageAction("update", "0.8.0")).toBe("standalone_update");
|
|
expect(resolveUpdatePackageAction("update", null)).toBe("standalone_install");
|
|
expect(resolveUpdatePackageAction("mcp_update", "0.8.0")).toBe("mcp_update");
|
|
});
|
|
|
|
it("reads only the supported MCP update trigger env enum", () => {
|
|
expect(
|
|
getUpdateTriggerFromEnv({ ARGENT_UPDATE_TRIGGER: "mcp_update" } as NodeJS.ProcessEnv)
|
|
).toBe("mcp_update");
|
|
expect(
|
|
getUpdateTriggerFromEnv({ ARGENT_UPDATE_TRIGGER: "https://internal" } as NodeJS.ProcessEnv)
|
|
).toBe("update");
|
|
expect(getUpdateTriggerFromEnv({} as NodeJS.ProcessEnv)).toBe("update");
|
|
});
|
|
});
|
|
|
|
// These exercise getGloballyInstalledVersion against a real on-disk install
|
|
// layout (binary symlinked into a node_modules/<pkg>/ directory tree) rather
|
|
// than mocking. That way we actually validate the bug fix end-to-end:
|
|
// which/where -> realpath -> walk up to package.json -> read version.
|
|
// Skipped on Windows because creating fs symlinks there needs admin rights
|
|
// (or developer mode), which isn't reliable in CI.
|
|
describe.skipIf(process.platform === "win32")(
|
|
"update — getGloballyInstalledVersion against a staged install",
|
|
() => {
|
|
let tmpDir: string;
|
|
let originalPath: string | undefined;
|
|
|
|
// Narrow PATH to system dirs so `which` itself stays reachable while
|
|
// hiding any real argent install on the dev machine. /usr/bin and /bin
|
|
// do not contain argent on any standard Unix layout (npm/pnpm/yarn/brew
|
|
// all install elsewhere), so a real binary cannot leak into the result.
|
|
const SYSTEM_PATH = `/usr/bin${path.delimiter}/bin`;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-installer-test-"));
|
|
originalPath = process.env.PATH;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = originalPath;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// Mimics the layout npm/pnpm/yarn produce for a global install — a bin
|
|
// entry that is a symlink into <prefix>/lib/node_modules/<pkg>/dist/.
|
|
// Returns the dir to put on PATH. The package.json must use the real
|
|
// PACKAGE_NAME — getGloballyInstalledPackageRoot validates it so a stray
|
|
// package.json can't scope tool-server teardown to an unrelated install.
|
|
function stageInstall(root: string, version: string): string {
|
|
const pkgRoot = path.join(root, "lib", "node_modules", PACKAGE_NAME);
|
|
const distDir = path.join(pkgRoot, "dist");
|
|
const binDir = path.join(root, "bin");
|
|
fs.mkdirSync(distDir, { recursive: true });
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pkgRoot, "package.json"),
|
|
JSON.stringify({ name: PACKAGE_NAME, version, bin: { argent: "dist/cli.js" } })
|
|
);
|
|
const cliPath = path.join(distDir, "cli.js");
|
|
fs.writeFileSync(cliPath, "#!/usr/bin/env node\n");
|
|
fs.chmodSync(cliPath, 0o755);
|
|
fs.symlinkSync(cliPath, path.join(binDir, "argent"));
|
|
return binDir;
|
|
}
|
|
|
|
it("reads the version from the actual global package.json, not PACKAGE_ROOT", () => {
|
|
// This is the headline regression: even when invoked from npx (where
|
|
// PACKAGE_ROOT is the latest published version), the function must
|
|
// report the version of the binary that's actually on PATH.
|
|
const binDir = stageInstall(tmpDir, "9.9.9");
|
|
process.env.PATH = `${binDir}${path.delimiter}${SYSTEM_PATH}`;
|
|
|
|
expect(getGloballyInstalledVersion()).toBe("9.9.9");
|
|
});
|
|
|
|
it("skips an npx-style transient install ahead of the permanent one on PATH", () => {
|
|
// Simulates: npx caches latest argent, user has an older global
|
|
// install. Without the temp-runner filter `which -a` returns the
|
|
// cache first and we'd report "latest" — masking the outdated global.
|
|
const transientBin = stageInstall(path.join(tmpDir, "cache", "_npx", "abc123"), "0.0.1");
|
|
const persistentBin = stageInstall(path.join(tmpDir, "persistent"), "9.9.9");
|
|
// Transient first so a naive "first match" would pick it.
|
|
process.env.PATH = [transientBin, persistentBin, SYSTEM_PATH].join(path.delimiter);
|
|
|
|
expect(getGloballyInstalledVersion()).toBe("9.9.9");
|
|
});
|
|
|
|
it("returns null when no permanent install is on PATH", () => {
|
|
const emptyBinDir = path.join(tmpDir, "empty-bin");
|
|
fs.mkdirSync(emptyBinDir);
|
|
process.env.PATH = `${emptyBinDir}${path.delimiter}${SYSTEM_PATH}`;
|
|
|
|
expect(getGloballyInstalledVersion()).toBeNull();
|
|
});
|
|
|
|
it("returns null when only a transient runner has argent on PATH", () => {
|
|
// No permanent install — only an npx-style cache. This is the
|
|
// "running via npx with no global ever installed" case; the function
|
|
// must NOT fall back to reporting the transient version.
|
|
const transientBin = stageInstall(path.join(tmpDir, "cache", "_npx", "xyz"), "1.2.3");
|
|
process.env.PATH = `${transientBin}${path.delimiter}${SYSTEM_PATH}`;
|
|
|
|
expect(getGloballyInstalledVersion()).toBeNull();
|
|
});
|
|
|
|
it("returns null when the walked-up package.json is NOT argent's", () => {
|
|
// The walk-up lands on a non-argent package.json (e.g. a stray
|
|
// ~/package.json); the name-validated probe must reject it rather than
|
|
// report a version for an unrelated install.
|
|
const binDir = path.join(tmpDir, "stray", "bin");
|
|
const distDir = path.join(tmpDir, "stray", "lib", "node_modules", "not-argent", "dist");
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
fs.mkdirSync(distDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(distDir, "..", "package.json"),
|
|
JSON.stringify({ name: "not-argent", version: "9.9.9", bin: { argent: "dist/cli.js" } })
|
|
);
|
|
const cliPath = path.join(distDir, "cli.js");
|
|
fs.writeFileSync(cliPath, "#!/usr/bin/env node\n");
|
|
fs.chmodSync(cliPath, 0o755);
|
|
fs.symlinkSync(cliPath, path.join(binDir, "argent"));
|
|
process.env.PATH = `${binDir}${path.delimiter}${SYSTEM_PATH}`;
|
|
|
|
expect(getGloballyInstalledVersion()).toBeNull();
|
|
});
|
|
}
|
|
);
|