diff --git a/.changeset/tidy-lexicon-links.md b/.changeset/tidy-lexicon-links.md new file mode 100644 index 0000000..741ec48 --- /dev/null +++ b/.changeset/tidy-lexicon-links.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": patch +--- + +Run Atcute's Lexicon CLI with an explicit module redirect for its byte-array helper so `contrail dev` works with pnpm's strictly isolated global package links. diff --git a/packages/contrail/src/cli/atcute.ts b/packages/contrail/src/cli/atcute.ts index a02539e..93fcbf5 100644 --- a/packages/contrail/src/cli/atcute.ts +++ b/packages/contrail/src/cli/atcute.ts @@ -1,9 +1,35 @@ import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; const require = createRequire(import.meta.url); +function dataModule(source: string): string { + return `data:text/javascript,${encodeURIComponent(source)}`; +} + +/** Create a preload module that redirects one bare import before starting a + * child CLI. This keeps undeclared transitive imports working in package + * managers whose content-addressed links fully isolate dependency trees. */ +export function createModuleRedirectRegistration( + specifier: string, + targetUrl: string, +): string { + const loader = dataModule( + `export async function resolve(specifier, context, nextResolve) { + if (specifier === ${JSON.stringify(specifier)}) { + return { url: ${JSON.stringify(targetUrl)}, shortCircuit: true }; + } + return nextResolve(specifier, context); +}`, + ); + return dataModule( + `import { register } from "node:module"; +register(${JSON.stringify(loader)}, import.meta.url);`, + ); +} + function runAtcute( action: "pull" | "generate", root: string, @@ -11,9 +37,22 @@ function runAtcute( ): void { const entry = require.resolve("@atcute/lex-cli"); const cli = join(dirname(entry), "..", "cli.mjs"); + // @atcute/lex-cli 3.3.0 imports this helper but declares it only as a dev + // dependency. Resolve Contrail's declared copy explicitly until upstream's + // package metadata includes it as a runtime dependency. + const registration = createModuleRedirectRegistration( + "@atcute/uint8array", + pathToFileURL(require.resolve("@atcute/uint8array")).href, + ); const result = spawnSync( process.execPath, - [cli, action, ...(configPath ? ["--config", configPath] : [])], + [ + "--import", + registration, + cli, + action, + ...(configPath ? ["--config", configPath] : []), + ], { cwd: root, stdio: "inherit", diff --git a/packages/contrail/tests/atcute-cli.test.ts b/packages/contrail/tests/atcute-cli.test.ts new file mode 100644 index 0000000..0d7cc08 --- /dev/null +++ b/packages/contrail/tests/atcute-cli.test.ts @@ -0,0 +1,44 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { createModuleRedirectRegistration } from "../src/cli/atcute"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("Atcute CLI module resolution", () => { + it("redirects an undeclared bare import in an isolated child process", () => { + const root = mkdtempSync(join(tmpdir(), "contrail-atcute-loader-")); + roots.push(root); + const target = join(root, "target.mjs"); + const entry = join(root, "entry.mjs"); + writeFileSync(target, `export const value = "resolved";\n`); + writeFileSync( + entry, + `import { value } from "@missing/runtime-helper"; +process.stdout.write(value); +`, + ); + + const registration = createModuleRedirectRegistration( + "@missing/runtime-helper", + pathToFileURL(target).href, + ); + const result = spawnSync( + process.execPath, + ["--import", registration, entry], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("resolved"); + }); +});