diff --git a/scripts/runtime-provider-env-patches.ts b/scripts/runtime-provider-env-patches.ts index a2acd80..4cb65cb 100644 --- a/scripts/runtime-provider-env-patches.ts +++ b/scripts/runtime-provider-env-patches.ts @@ -40,6 +40,7 @@ function inspect(runtime: string) { const match = new RegExp(`^${escapePattern(options)}\\.env\\[(${identifier})\\]\\?\\.trim\\(\\)(\\|\\|void 0)?$`, "u").exec(source); if (!match) incompatible(); return { name: binding.id.name, key: match[1]!, init: binding.init, source, + original: `${options}.env[${match[1]}]?.trim()`, replacement: `${options}.env[${match[1]}]?.trim()||void 0` }; }); const builtin = bindings[0]!; @@ -74,7 +75,7 @@ function inspect(runtime: string) { const inserted = runtime.slice(initial.end, initial.end + recovery.length) === recovery; const patched = inserted && bindings.every(binding => binding.source === binding.replacement) && bundledSource === replacement; if (runtime.slice(prepare.start, prepare.end).includes(fallbackName) && !patched) incompatible(); - return { patched, bindings, initial, bundled: bundled.init, replacement, recovery }; + return { patched, bindings, initial, bundled: bundled.init, original, replacement, recovery }; } export function hasRuntimeBuiltinProviderEnvRecovery(runtime: string): boolean { @@ -94,3 +95,18 @@ export function patchRuntimeBuiltinProviderEnvRecovery(runtime: string): string } return runtime; } + +/** Restore the upstream bootstrap in test fixtures, preserving all other runtime patches. */ +export function unpatchRuntimeBuiltinProviderEnvRecovery(runtime: string): string { + const inspected = inspect(runtime); + if (!inspected.patched) return runtime; + const edits = [ + ...inspected.bindings.map(binding => ({ start: binding.init.start, end: binding.init.end, text: binding.original })), + { start: inspected.initial.end, end: inspected.initial.end + inspected.recovery.length, text: "" }, + { start: inspected.bundled.start, end: inspected.bundled.end, text: inspected.original } + ]; + for (const edit of edits.sort((first, second) => second.start - first.start)) { + runtime = runtime.slice(0, edit.start) + edit.text + runtime.slice(edit.end); + } + return runtime; +} diff --git a/src/launcher.ts b/src/launcher.ts index 614c443..660977e 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -533,6 +533,7 @@ export async function main(args: string[]): Promise { process.once("SIGINT", onPluginSigint); process.once("SIGTERM", onPluginSigterm); if (process.platform !== "win32") process.once("SIGHUP", onPluginSighup); + const builtinProviderWarnings = new Set(); let pluginCommand: number | undefined; try { pluginCommand = await runPluginCommand(args, { @@ -541,9 +542,10 @@ export async function main(args: string[]): Promise { params, onStderr: stderr => { for (const line of stderr.split(/\r?\n/u)) { - if (line.startsWith("ZCode Built-in: ignoring unavailable ZCODE_BUILTIN_PROVIDER_CONFIG_FILE ")) { - process.stderr.write(`${line}\n`); - } + if (!line.startsWith("ZCode Built-in: ignoring unavailable ZCODE_BUILTIN_PROVIDER_CONFIG_FILE ") + || builtinProviderWarnings.has(line)) continue; + builtinProviderWarnings.add(line); + process.stderr.write(`${line}\n`); } }, signal: signal ?? pluginAbortController.signal, diff --git a/test/runtime-provider-env-patches.test.ts b/test/runtime-provider-env-patches.test.ts index c5cc078..575f566 100644 --- a/test/runtime-provider-env-patches.test.ts +++ b/test/runtime-provider-env-patches.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { hasRuntimeBuiltinProviderEnvRecovery, patchRuntimeBuiltinProviderEnvRecovery } from "../scripts/runtime-provider-env-patches.ts"; +import { hasRuntimeBuiltinProviderEnvRecovery, patchRuntimeBuiltinProviderEnvRecovery, unpatchRuntimeBuiltinProviderEnvRecovery } from "../scripts/runtime-provider-env-patches.ts"; const source = [ 'var active="ZCODE_BUILTIN_PROVIDER_CONFIG_FILE",bundled="ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE",personal="ZCODE_PERSONAL_PROVIDER_CONFIG_FILE";', @@ -40,6 +40,16 @@ describe("built-in provider environment recovery", () => { expect(patchRuntimeBuiltinProviderEnvRecovery(patched)).toBe(patched); }); + test("restores the unpatched bootstrap without removing unrelated runtime changes", () => { + const baseline = `${source};const unrelatedPatch=true;`; + const patched = patchRuntimeBuiltinProviderEnvRecovery(baseline); + const restored = unpatchRuntimeBuiltinProviderEnvRecovery(patched); + expect(restored).toBe(baseline); + expect(hasRuntimeBuiltinProviderEnvRecovery(restored)).toBe(false); + expect(unpatchRuntimeBuiltinProviderEnvRecovery(restored)).toBe(restored); + expect(patchRuntimeBuiltinProviderEnvRecovery(restored)).toBe(patched); + }); + test.each([undefined, "", " \t "])("discovers bundled config for an unset or blank override (%j)", async value => { const host = fixture(); const env: Record = value === undefined ? {} : { ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: value }; @@ -122,5 +132,6 @@ describe("built-in provider environment recovery", () => { const partial = patchRuntimeBuiltinProviderEnvRecovery(source).replace("using bundled catalog", "partial recovery"); expect(hasRuntimeBuiltinProviderEnvRecovery(partial)).toBe(false); expect(() => patchRuntimeBuiltinProviderEnvRecovery(partial)).toThrow("incompatible"); + expect(() => unpatchRuntimeBuiltinProviderEnvRecovery(partial)).toThrow("incompatible"); }); }); diff --git a/test/runtime/builtin-provider-env.test.ts b/test/runtime/builtin-provider-env.test.ts index 25b526e..09f050e 100644 --- a/test/runtime/builtin-provider-env.test.ts +++ b/test/runtime/builtin-provider-env.test.ts @@ -5,14 +5,13 @@ import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node: import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { hasRuntimeBuiltinProviderEnvRecovery, patchRuntimeBuiltinProviderEnvRecovery } from "../../scripts/runtime-provider-env-patches.ts"; +import { hasRuntimeBuiltinProviderEnvRecovery, patchRuntimeBuiltinProviderEnvRecovery, unpatchRuntimeBuiltinProviderEnvRecovery } from "../../scripts/runtime-provider-env-patches.ts"; import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; const root = fileURLToPath(new URL("../..", import.meta.url)); const node = Bun.which("node"); const endpoint = "http://127.0.0.1:9"; let directory = "", packageRoot = "", builtinContent = ""; -let baselineUnpatched = false; beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), "zcode-builtin-env-")); @@ -25,10 +24,15 @@ beforeAll(async () => { } } const original = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); - baselineUnpatched = !hasRuntimeBuiltinProviderEnvRecovery(original); + const baseline = unpatchRuntimeBuiltinProviderEnvRecovery(original); + const patched = patchRuntimeBuiltinProviderEnvRecovery(original); + expect(hasRuntimeBuiltinProviderEnvRecovery(baseline)).toBe(false); + if (patchRuntimeBuiltinProviderEnvRecovery(baseline) !== patched) { + throw new Error("Unpatched runtime fixture did not preserve the other runtime patches."); + } builtinContent = await readFile(join(root, "vendor/provider/zcode-builtin.json"), "utf8"); - await writeFile(join(vendor, "zcode.cjs"), patchRuntimeBuiltinProviderEnvRecovery(original)); - await writeFile(join(vendor, "unpatched.cjs"), original); + await writeFile(join(vendor, "zcode.cjs"), patched); + await writeFile(join(vendor, "unpatched.cjs"), baseline); await writeFile(join(vendor, "provider/zcode-builtin.json"), builtinContent); await writeFile(join(packageRoot, "package.json"), JSON.stringify({ type: "module", version: "0.0.0-test" })); const build = await Bun.build({ entrypoints: [join(root, "bin/zcode.ts")], outdir: join(packageRoot, "bin"), target: "node", format: "esm" }); @@ -145,18 +149,52 @@ test("fails with a diagnostic when neither override nor bundled fallback is usab expect(result.stderr).toContain("no valid bundled fallback"); }, 10000); -test("recovers the real launcher plugin-validation path, reproducing failure when an unpatched runtime is available", async () => { +test("reproduces an unavailable builtin override failure with the unpatched runtime", async () => { + const { home, env } = await homeEnv(false); + env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); + const control = await run(home, env, [join(packageRoot, "vendor/unpatched.cjs"), "app-server"]); + expect(control.code).toBe(1); + expect(control.stderr).toContain("均不可用"); +}, 10000); + +test("recovers the real launcher plugin-validation source path with one warning", async () => { const { home, env } = await homeEnv(false); env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); - if (baselineUnpatched) { - const control = await run(home, env, [join(packageRoot, "vendor/unpatched.cjs"), "app-server"]); - expect(control.code).toBe(1); - expect(control.stderr).toContain("均不可用"); - } const marketplace = join(home, "marketplace.json"); await writeFile(marketplace, JSON.stringify({ name: "builtin-env-fixture", plugins: [] })); const fixed = await run(home, env, [join(packageRoot, "bin/zcode.js"), "plugins", "validate", "--source", marketplace], false); expect(fixed.code).toBe(0); expect(fixed.stdout).toContain("Plugin validation completed."); - expect(fixed.stderr).toContain("using bundled catalog"); + expect(fixed.stderr.match(/using bundled catalog/gu)).toHaveLength(1); }, 15000); + +test("warns once per command when plugin-coordinate validation starts multiple app-servers", async () => { + const { home, env } = await homeEnv(false); + const marketplace = join(home, "marketplace"); + const plugin = join(marketplace, "plugin"); + await mkdir(join(plugin, ".zcode-plugin"), { recursive: true }); + await mkdir(join(plugin, "skills", "probe"), { recursive: true }); + await writeFile(join(marketplace, "marketplace.json"), JSON.stringify({ + name: "builtin-env-fixture", pluginRoot: ".", + plugins: [{ name: "probe", source: "./plugin", version: "1.0.0", description: "Validation fixture" }] + })); + await writeFile(join(plugin, ".zcode-plugin", "plugin.json"), JSON.stringify({ + name: "probe", version: "1.0.0", description: "Validation fixture", skills: "skills" + })); + await writeFile(join(plugin, "skills", "probe", "SKILL.md"), + "---\nname: probe\ndescription: Validation fixture\n---\nValidation fixture.\n"); + const launcher = join(packageRoot, "bin/zcode.js"); + const added = await run(home, env, [launcher, "plugins", "marketplace", "add", marketplace, "--yes"], false); + expect(added.code).toBe(0); + expect(added.stderr).not.toContain("using bundled catalog"); + + env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = join(home, "stale.json"); + for (const json of [false, true]) { + const result = await run(home, env, + [launcher, "plugins", "validate", "probe@builtin-env-fixture", ...json ? ["--json"] : []], false); + expect(result.code).toBe(0); + if (json) expect(JSON.parse(result.stdout)).toMatchObject({ diagnostics: [] }); + else expect(result.stdout).toContain("Plugin validation completed."); + expect(result.stderr.match(/using bundled catalog/gu)).toHaveLength(1); + } +}, 30000);