Files
software-mansion__argent/packages/argent/test/bundled-paths.test.ts
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

84 lines
3.4 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { findStablePackageDir } from "../src/bundled-paths.js";
// findStablePackageDir resolves the version-STABLE node_modules symlink for the
// running package — the one that survives a pnpm store prune. The bug it fixes:
// in a pnpm workspace the symlink lives in the DECLARING member's node_modules,
// a level below the workspace root where the .pnpm store (the realpath target)
// lives, so a probe that only checks the root misses it and falls back to the
// version-pinned store path.
const PKG = "@swmansion/argent";
let tmpDir: string;
// Creates a real "store" dir standing in for the resolved package, and a
// node_modules/<pkg> SYMLINK to it under `linkParent`.
function linkTo(linkParent: string, realPkgDir: string): string {
const nm = path.join(linkParent, "node_modules", "@swmansion");
fs.mkdirSync(nm, { recursive: true });
const link = path.join(nm, "argent");
fs.symlinkSync(realPkgDir, link);
return path.join(linkParent, "node_modules", PKG);
}
beforeEach(() => {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-stable-path-")));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("findStablePackageDir", () => {
it("finds the symlink at cwd for a single-package layout", () => {
const store = path.join(tmpDir, "store", "argent");
fs.mkdirSync(store, { recursive: true });
const proj = path.join(tmpDir, "proj");
const link = linkTo(proj, store);
expect(findStablePackageDir(proj, store)).toBe(link);
});
it("scans up to find the DECLARING member's symlink in a pnpm workspace", () => {
// The version-pinned store lives at the workspace root; the stable symlink
// is only in the member's node_modules, a level below where the store sits.
const store = path.join(tmpDir, "root", "node_modules", ".pnpm", "argent");
fs.mkdirSync(store, { recursive: true });
const member = path.join(tmpDir, "root", "packages", "app");
const memberLink = linkTo(member, store);
// Root has a .pnpm store but NO top-level @swmansion/argent symlink.
// cwd is the member root.
expect(findStablePackageDir(member, store)).toBe(memberLink);
// ...and the returned path is NOT the version-pinned store dir.
expect(findStablePackageDir(member, store)).not.toContain(".pnpm");
});
it("prefers the deepest (closest-to-cwd) alias when several resolve to the package", () => {
const store = path.join(tmpDir, "store", "argent");
fs.mkdirSync(store, { recursive: true });
const root = path.join(tmpDir, "root");
const member = path.join(root, "packages", "app");
linkTo(root, store);
const memberLink = linkTo(member, store);
expect(findStablePackageDir(member, store)).toBe(memberLink);
});
it("returns undefined when no symlink resolves to the running package", () => {
const store = path.join(tmpDir, "store", "argent");
fs.mkdirSync(store, { recursive: true });
const proj = path.join(tmpDir, "proj");
// A node_modules/<pkg> that points somewhere ELSE must not be accepted.
const other = path.join(tmpDir, "other", "argent");
fs.mkdirSync(other, { recursive: true });
linkTo(proj, other);
expect(findStablePackageDir(proj, store)).toBeUndefined();
});
});