mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
6355620c4d
## Summary - Splits the monolithic `packages/mcp` package into five focused packages: - `argent` — the user-facing CLI entry (`argent` binary, init/uninstall/update flows, bundled-paths, scripts). - `argent-cli` — CLI runner internals (flag-parser, run, server, tools). - `argent-installer` — `init`/`update`/`uninstall` flows, MCP config adapters, skills installer. - `argent-mcp` — MCP server itself (mcp-server, tool-mapping, auto-screenshot, content adapters). - `argent-tools-client` — HTTP client + launcher for the tool-server. - Adds Gemini agent definitions, VSCode MCP config, and per-package `tsconfig.json` / `vitest.config.ts`. - Updates root `tsconfig.json`, `scripts/dev.cjs`, CI workflows, and `package.json` to wire the new packages together. ## Test plan - [ ] `npm install` resolves the new workspaces cleanly - [ ] `npm run build` succeeds across all packages - [ ] `npm test` passes (renamed test files keep their assertions) - [ ] `npx argent init` still produces the expected MCP config in a sample project - [ ] `npx argent update` / `argent uninstall` exercise the installer package - [ ] CI workflows (`publish.yml`, `unit-tests.yml`) run on this branch
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { resolvePackageRoot } from "../src/utils.js";
|
|
import { PACKAGE_NAME } from "../src/constants.js";
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
function mkdirp(p: string): void {
|
|
fs.mkdirSync(p, { recursive: true });
|
|
}
|
|
|
|
function writeFile(p: string, content: string): void {
|
|
mkdirp(path.dirname(p));
|
|
fs.writeFileSync(p, content);
|
|
}
|
|
|
|
function writePkg(dir: string, name: string, version = "1.0.0"): void {
|
|
writeFile(path.join(dir, "package.json"), JSON.stringify({ name, version }));
|
|
}
|
|
|
|
function writeSkills(pkgRoot: string): void {
|
|
mkdirp(path.join(pkgRoot, "skills", "simulator-interact"));
|
|
writeFile(
|
|
path.join(pkgRoot, "skills", "simulator-interact", "SKILL.md"),
|
|
"---\nname: simulator-interact\ndescription: test\n---\n"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Given a simulated package root, return what resolvePackageRoot produces
|
|
* when called from the dist/cli/ directory inside that package.
|
|
*/
|
|
function resolveFrom(pkgRoot: string): string {
|
|
const distCli = path.join(pkgRoot, "dist", "cli");
|
|
mkdirp(distCli);
|
|
return resolvePackageRoot(distCli);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-pkgroot-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Global install layouts ────────────────────────────────────────────────────
|
|
|
|
describe("global install — npm", () => {
|
|
// npm i -g: <prefix>/lib/node_modules/@swmansion/argent/
|
|
it("resolves from npm global layout", () => {
|
|
const prefix = path.join(tmpDir, "usr", "lib", "node_modules");
|
|
const pkgRoot = path.join(prefix, "@swmansion", "argent");
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
expect(fs.existsSync(path.join(resolved, "package.json"))).toBe(true);
|
|
expect(fs.existsSync(path.join(resolved, "skills"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("global install — nvm", () => {
|
|
// nvm: ~/.nvm/versions/node/v22.0.0/lib/node_modules/@swmansion/argent/
|
|
it("resolves from nvm global layout", () => {
|
|
const pkgRoot = path.join(
|
|
tmpDir,
|
|
".nvm",
|
|
"versions",
|
|
"node",
|
|
"v22.0.0",
|
|
"lib",
|
|
"node_modules",
|
|
"@swmansion",
|
|
"argent"
|
|
);
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
expect(fs.existsSync(path.join(resolved, "skills"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("global install — pnpm", () => {
|
|
// pnpm add -g: ~/.local/share/pnpm/global/5/node_modules/@swmansion/argent/
|
|
it("resolves from pnpm global layout", () => {
|
|
const pkgRoot = path.join(
|
|
tmpDir,
|
|
".local",
|
|
"share",
|
|
"pnpm",
|
|
"global",
|
|
"5",
|
|
"node_modules",
|
|
"@swmansion",
|
|
"argent"
|
|
);
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
});
|
|
});
|
|
|
|
describe("global install — yarn classic", () => {
|
|
// yarn global add: ~/.config/yarn/global/node_modules/@swmansion/argent/
|
|
it("resolves from yarn global layout", () => {
|
|
const pkgRoot = path.join(
|
|
tmpDir,
|
|
".config",
|
|
"yarn",
|
|
"global",
|
|
"node_modules",
|
|
"@swmansion",
|
|
"argent"
|
|
);
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
});
|
|
});
|
|
|
|
// ── Local install in monorepos ────────────────────────────────────────────────
|
|
|
|
describe("local install — npm workspaces (hoisted)", () => {
|
|
// Hoisted: <monorepo>/node_modules/@swmansion/argent/
|
|
// Monorepo root has its own package.json with a different name
|
|
it("resolves to the argent package, not the monorepo root", () => {
|
|
const monorepo = path.join(tmpDir, "my-monorepo");
|
|
writePkg(monorepo, "my-monorepo");
|
|
writeFile(
|
|
path.join(monorepo, "package.json"),
|
|
JSON.stringify({
|
|
name: "my-monorepo",
|
|
private: true,
|
|
workspaces: ["packages/*"],
|
|
})
|
|
);
|
|
|
|
const pkgRoot = path.join(monorepo, "node_modules", "@swmansion", "argent");
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
// Should NOT be the monorepo root
|
|
expect(resolved).not.toBe(monorepo);
|
|
});
|
|
});
|
|
|
|
describe("local install — pnpm (symlinked from store)", () => {
|
|
// pnpm stores in a content-addressable store, then symlinks:
|
|
// <project>/node_modules/.pnpm/@swmansion+argent@0.3.1/node_modules/@swmansion/argent/
|
|
// The symlink at <project>/node_modules/@swmansion/argent -> above
|
|
// import.meta.dirname follows the real path.
|
|
it("resolves from pnpm .pnpm store layout", () => {
|
|
const project = path.join(tmpDir, "my-project");
|
|
writePkg(project, "my-project");
|
|
|
|
const storePkg = path.join(
|
|
project,
|
|
"node_modules",
|
|
".pnpm",
|
|
"@swmansion+argent@0.3.1",
|
|
"node_modules",
|
|
"@swmansion",
|
|
"argent"
|
|
);
|
|
writePkg(storePkg, PACKAGE_NAME, "0.3.1");
|
|
writeSkills(storePkg);
|
|
|
|
const resolved = resolveFrom(storePkg);
|
|
expect(resolved).toBe(storePkg);
|
|
expect(fs.existsSync(path.join(resolved, "skills"))).toBe(true);
|
|
});
|
|
|
|
it("resolves correctly via symlink (realpath)", () => {
|
|
const project = path.join(tmpDir, "symlink-project");
|
|
writePkg(project, "my-project");
|
|
|
|
// Real location in the .pnpm store
|
|
const storePkg = path.join(
|
|
project,
|
|
"node_modules",
|
|
".pnpm",
|
|
"@swmansion+argent@0.3.1",
|
|
"node_modules",
|
|
"@swmansion",
|
|
"argent"
|
|
);
|
|
writePkg(storePkg, PACKAGE_NAME, "0.3.1");
|
|
writeSkills(storePkg);
|
|
mkdirp(path.join(storePkg, "dist", "cli"));
|
|
|
|
// Create the symlink at the standard location
|
|
const symlinkDir = path.join(project, "node_modules", "@swmansion");
|
|
mkdirp(symlinkDir);
|
|
fs.symlinkSync(storePkg, path.join(symlinkDir, "argent"), "dir");
|
|
|
|
// When Node resolves import.meta.dirname, it uses the real path
|
|
const realDistCli = fs.realpathSync(path.join(symlinkDir, "argent", "dist", "cli"));
|
|
|
|
const resolved = resolvePackageRoot(realDistCli);
|
|
expect(fs.existsSync(path.join(resolved, "skills"))).toBe(true);
|
|
expect(fs.existsSync(path.join(resolved, "package.json"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("local install — yarn PnP (unplugged)", () => {
|
|
// Yarn PnP unplugged packages:
|
|
// <project>/.yarn/unplugged/@swmansion-argent-npm-0.3.1-<hash>/node_modules/@swmansion/argent/
|
|
it("resolves from yarn PnP unplugged layout", () => {
|
|
const project = path.join(tmpDir, "pnp-project");
|
|
writePkg(project, "pnp-project");
|
|
|
|
const pkgRoot = path.join(
|
|
project,
|
|
".yarn",
|
|
"unplugged",
|
|
"@swmansion-argent-npm-0.3.1-abc123",
|
|
"node_modules",
|
|
"@swmansion",
|
|
"argent"
|
|
);
|
|
writePkg(pkgRoot, PACKAGE_NAME, "0.3.1");
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
expect(fs.existsSync(path.join(resolved, "skills"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Development (monorepo source) ─────────────────────────────────────────────
|
|
|
|
describe("development — running from monorepo source", () => {
|
|
// When developing, the bundled installer runs from packages/argent/dist/.
|
|
// The published package root is packages/argent/.
|
|
it("resolves from packages/argent/dist/ during development", () => {
|
|
const monorepo = path.join(tmpDir, "argent-monorepo");
|
|
writePkg(monorepo, "argent-workspace");
|
|
|
|
const pkgRoot = path.join(monorepo, "packages", "argent");
|
|
writePkg(pkgRoot, PACKAGE_NAME, "0.3.1");
|
|
writeSkills(pkgRoot);
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(resolved).toBe(pkgRoot);
|
|
expect(fs.existsSync(path.join(resolved, "skills"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Edge cases ────────────────────────────────────────────────────────────────
|
|
|
|
describe("edge cases", () => {
|
|
it("skills/rules/agents dirs are siblings of package.json", () => {
|
|
const pkgRoot = path.join(tmpDir, "edge-pkg");
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
writeSkills(pkgRoot);
|
|
mkdirp(path.join(pkgRoot, "rules"));
|
|
mkdirp(path.join(pkgRoot, "agents"));
|
|
|
|
const resolved = resolveFrom(pkgRoot);
|
|
expect(path.join(resolved, "skills")).toBe(path.join(pkgRoot, "skills"));
|
|
expect(path.join(resolved, "rules")).toBe(path.join(pkgRoot, "rules"));
|
|
expect(path.join(resolved, "agents")).toBe(path.join(pkgRoot, "agents"));
|
|
});
|
|
|
|
it("resolution is deterministic — same input always same output", () => {
|
|
const pkgRoot = path.join(tmpDir, "deterministic");
|
|
writePkg(pkgRoot, PACKAGE_NAME);
|
|
mkdirp(path.join(pkgRoot, "dist", "cli"));
|
|
|
|
const distCli = path.join(pkgRoot, "dist", "cli");
|
|
const r1 = resolvePackageRoot(distCli);
|
|
const r2 = resolvePackageRoot(distCli);
|
|
const r3 = resolvePackageRoot(distCli);
|
|
expect(r1).toBe(r2);
|
|
expect(r2).toBe(r3);
|
|
expect(r1).toBe(pkgRoot);
|
|
});
|
|
});
|
|
|
|
// ── The actual running environment ────────────────────────────────────────────
|
|
|
|
describe("actual environment — this monorepo", () => {
|
|
it("PACKAGE_ROOT from the live module points to its package directory", () => {
|
|
// After bundling, the installer ships as a single dist/installer.cjs inside
|
|
// the published package, so resolvePackageRoot(dist/) -> the published
|
|
// package root. During monorepo development the function still walks two
|
|
// dirs up; given the test file lives at packages/argent-installer/test/,
|
|
// we exercise the function from a known location inside the package.
|
|
const fakeDistCli = path.resolve(import.meta.dirname, "..", "dist", "cli");
|
|
const resolved = resolvePackageRoot(fakeDistCli);
|
|
expect(resolved).toBe(path.resolve(import.meta.dirname, ".."));
|
|
const resolvedPkg = path.join(resolved, "package.json");
|
|
expect(fs.existsSync(resolvedPkg)).toBe(true);
|
|
});
|
|
});
|