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

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