import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { PI_EXTENSION_FILES, PI_EXTENSION_FILENAMES } from '../../src/config/pi-extension.js'; /** * `mcpctl config pi` installs the *embedded* copy of the extension, not the * files in src/pi-ext/. Editing the sources without re-running the generator * therefore ships stale code to users while the repo looks correct — and the * embedded copy is the one thing no typecheck covers. Same guarantee the * completions check gives. */ const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); const piExtDir = join(repoRoot, 'src', 'pi-ext'); describe('embedded pi extension', () => { it('matches the sources in src/pi-ext (re-run scripts/generate-pi-extension.ts)', () => { for (const name of PI_EXTENSION_FILENAMES) { const onDisk = readFileSync(join(piExtDir, name), 'utf-8'); expect(PI_EXTENSION_FILES[name], `${name} is embedded`).toBeDefined(); expect(PI_EXTENSION_FILES[name], `${name} is stale — regenerate the embed`).toBe(onDisk); } }); it('embeds every file the extension needs to load', () => { // mcpctl-pi.ts imports ./mcp-http.js — installing one without the other // yields an extension pi cannot load. expect(Object.keys(PI_EXTENSION_FILES).sort()).toEqual(['mcp-http.ts', 'mcpctl-pi.ts']); expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js'); }); /** * pi resolves an extension's bare specifiers through a hard-coded alias table * in its own loader, and that table is not the same across pi distributions: * `@earendil-works/*` exists only in the newer packages, `@mariozechner/*` * installs alias only the old names, and neither resolves the other. An * import of a package outside the intersection makes the whole extension fail * to load with `Cannot find module` — every tool gone, on someone else's pi. * * `typebox` is aliased by every published pi, so it is the only safe bare * runtime import. Type-only imports are erased before jiti resolves anything, * so they may name whatever they like. */ it('imports nothing at runtime that some pi build cannot resolve', () => { // `import x from "s"` / `import {..} from "s"` (but not `import type`), // plus the side-effect form `import "s"`. const runtimeImport = /^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm; const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/; for (const name of PI_EXTENSION_FILENAMES) { const src = PI_EXTENSION_FILES[name] ?? ''; for (const match of src.matchAll(runtimeImport)) { const specifier = match[1] ?? match[2] ?? ''; expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed); } } }); it('carries the fixes the pi API requires', () => { const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? ''; // ctx.ui.select takes string[] and returns the chosen string. expect(main).not.toMatch(/select\([^)]*\[\s*\{\s*value:/); // Skills must land in pi's tree, never ~/.claude: the sync passes // --agent pi and no longer tells the user it wrote to Claude's tree. expect(main).toContain('"--agent", "pi"'); expect(main).not.toContain('(into ~/.claude/skills)'); // Spawned without a shell, so the project name is never interpolated into // a command string. Matches the call, not prose mentioning it. expect(main).toContain('execFile("mcpctl", args'); expect(main).not.toMatch(/execSync\s*\(/); }); });