Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m10s
CI/CD / smoke (pull_request) Failing after 10m42s
CI/CD / build (pull_request) Failing after 13m32s
CI/CD / publish (pull_request) Has been skipped
The extension failed to load outright on older pi installs: Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai' pi doesn't resolve an extension's bare specifiers the ordinary way — it hands jiti a hard-coded alias table built from its own dependencies, and that table differs between pi distributions. `@earendil-works/pi-coding- agent` (0.84.1) aliases both the `@earendil-works/*` and legacy `@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases only the old ones. Neither resolves the other's namespace, so a single import outside the intersection takes the whole extension down: every tool, the /mcpctl command, and the status line, all gone. The only thing we used from pi-ai was `StringEnum`, a six-line wrapper over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical output, so `typebox` — aliased by every published pi — is now the sole bare runtime import. The call site also passes `description` through, which the pi-ai version was silently dropping. Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in the embedded sources that isn't `node:`, relative, or typebox now fails. Verified against both installs with the same active project: 0.73.1 reproduced the error verbatim before the change and loads cleanly after, and 0.84.1 keeps registering the gate tool exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
74 lines
3.5 KiB
TypeScript
74 lines
3.5 KiB
TypeScript
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*\(/);
|
|
});
|
|
});
|