|
| 1 | +/** |
| 2 | + * The admin extension's pi canary (issue #96). Runs the admin's pinned-pi assumptions against an |
| 3 | + * ARBITRARY pi install -- in CI, @latest -- so a breaking pi release fails a build here instead of |
| 4 | + * an operator's /dispatch there. |
| 5 | + * |
| 6 | + * Why this exists at all: admin/package.json declares `"@earendil-works/pi-coding-agent": "*"` as |
| 7 | + * its peer range. That is deliberate, and this header is where the reason is recorded (JSON takes |
| 8 | + * no comments): pi's own packages doc prescribes `"*"` peers for host-provided packages -- pi never |
| 9 | + * installs a peer, the host session IS the pi -- and an exact peer pin makes every plain-npm |
| 10 | + * consumer ERESOLVE the moment their pi differs by a patch. The exact TESTED version still lives in |
| 11 | + * two places locked to each other: `SUPPORTED_PI_VERSION` in admin/src/index.ts and the admin |
| 12 | + * devDependency pin (admin/test/load.test.mjs asserts they cannot drift). A wildcard peer without a |
| 13 | + * canary would be a hope; this script is what keeps it honest. |
| 14 | + * |
| 15 | + * Usage: node .github/scripts/admin-pi-canary.mjs <scratch-dir> |
| 16 | + * where <scratch-dir> holds an `npm install @earendil-works/pi-coding-agent` (any version, in CI |
| 17 | + * @latest) under <scratch-dir>/node_modules. Never point it at the repo root: the repo's hoisted |
| 18 | + * copy is the PIN the admin test suite anchors on, and canarying it would assert nothing. |
| 19 | + * |
| 20 | + * Asserts, against THAT install: |
| 21 | + * (a) every needle from the admin's pinned-api needle list appears in its |
| 22 | + * dist/core/extensions/types.d.ts |
| 23 | + * (b) every USED_API member is declared as a method on its `interface ExtensionAPI` |
| 24 | + * (c) `VERSION` is a runtime string export of the package root |
| 25 | + * (d) the BUILT admin bundle actually loads with that pi resolvable and registers exactly one |
| 26 | + * `dispatch` command with no stderr refusal line |
| 27 | + * |
| 28 | + * Resolution mechanics for (d): the bundle (admin/dist/index.mjs, pi kept external by |
| 29 | + * admin/build.mjs) is COPIED into the scratch dir. ESM resolves a bare specifier against the |
| 30 | + * IMPORTING FILE's own location, so the copy's `@earendil-works/pi-coding-agent` import walks up |
| 31 | + * from <scratch-dir> and finds <scratch-dir>/node_modules -- the install under test -- never the |
| 32 | + * repo's hoisted pin. bullmq/ioredis are external in the bundle too but are NOT under test; they |
| 33 | + * are satisfied by symlinking the repo's own pinned copies into the scratch node_modules (offline, |
| 34 | + * deterministic, no second install). |
| 35 | + */ |
| 36 | + |
| 37 | +import { copyFileSync, existsSync, mkdtempSync, readFileSync, symlinkSync } from "node:fs"; |
| 38 | +import { dirname, join, resolve } from "node:path"; |
| 39 | +import { fileURLToPath, pathToFileURL } from "node:url"; |
| 40 | + |
| 41 | +/** |
| 42 | + * Source of truth: admin/test/pinned-extension-api.test.mjs. That test asserts these against the |
| 43 | + * PINNED pi and may not import from (or be edited for) a CI script; this is a deliberate second |
| 44 | + * copy, and admin/test/wiring.test.mjs is the anti-drift bolt: it imports these exports and fails |
| 45 | + * the suite when they diverge from the pinned test's literals or from the real USED_API export. |
| 46 | + */ |
| 47 | +export const NEEDLES = [ |
| 48 | + "registerCommand(name", |
| 49 | + "registerTool<TParams", |
| 50 | + "sendMessage<T", |
| 51 | + "getArgumentCompletions", |
| 52 | + "custom<T>(", |
| 53 | + "notify(message", |
| 54 | + "confirm(title", |
| 55 | + "select(title", |
| 56 | + "input(title", |
| 57 | + "session_start", |
| 58 | + "executionMode", |
| 59 | +]; |
| 60 | + |
| 61 | +/** Mirrors USED_API in admin/src/index.ts -- deepEqual-checked by wiring.test.mjs (same bolt). */ |
| 62 | +export const USED_API_MEMBERS = ["registerCommand", "registerTool", "sendMessage", "on"]; |
| 63 | + |
| 64 | +const here = dirname(fileURLToPath(import.meta.url)); |
| 65 | +const repoRoot = resolve(here, "..", ".."); |
| 66 | + |
| 67 | +/** |
| 68 | + * Extract the body text of a named `interface X { ... }` by brace balancing. A copy of the helper |
| 69 | + * in admin/test/pinned-extension-api.test.mjs (see the NEEDLES note on why a copy). |
| 70 | + */ |
| 71 | +function extractInterface(src, name) { |
| 72 | + const start = src.indexOf(`interface ${name} {`); |
| 73 | + if (start === -1) return null; |
| 74 | + let depth = 0; |
| 75 | + for (let i = src.indexOf("{", start); i < src.length; i++) { |
| 76 | + if (src[i] === "{") depth++; |
| 77 | + else if (src[i] === "}" && --depth === 0) return src.slice(start, i + 1); |
| 78 | + } |
| 79 | + return null; |
| 80 | +} |
| 81 | + |
| 82 | +let failures = 0; |
| 83 | +function fail(msg) { |
| 84 | + failures++; |
| 85 | + console.error(`canary FAIL: ${msg}`); |
| 86 | +} |
| 87 | +function ok(msg) { |
| 88 | + console.log(`canary ok: ${msg}`); |
| 89 | +} |
| 90 | + |
| 91 | +async function main(scratchArg) { |
| 92 | + const scratch = resolve(scratchArg); |
| 93 | + const piDir = join(scratch, "node_modules", "@earendil-works", "pi-coding-agent"); |
| 94 | + if (!existsSync(join(piDir, "package.json"))) { |
| 95 | + console.error(`canary: no pi install at ${piDir} -- npm install @earendil-works/pi-coding-agent into the scratch dir first`); |
| 96 | + process.exit(2); |
| 97 | + } |
| 98 | + const piPkg = JSON.parse(readFileSync(join(piDir, "package.json"), "utf8")); |
| 99 | + console.log(`canary: probing @earendil-works/pi-coding-agent ${piPkg.version} at ${piDir}`); |
| 100 | + |
| 101 | + // ---- (a) the needle list still appears in the install's extension types ---- |
| 102 | + const typesPath = join(piDir, "dist", "core", "extensions", "types.d.ts"); |
| 103 | + let typesSrc; |
| 104 | + try { |
| 105 | + typesSrc = readFileSync(typesPath, "utf8"); |
| 106 | + } catch (err) { |
| 107 | + fail(`cannot read ${typesPath}: ${err.message} -- pi moved its extension type declarations`); |
| 108 | + } |
| 109 | + if (typesSrc !== undefined) { |
| 110 | + const missing = NEEDLES.filter((needle) => !typesSrc.includes(needle)); |
| 111 | + if (missing.length > 0) { |
| 112 | + fail(`extensions/types.d.ts no longer contains: ${missing.map((n) => JSON.stringify(n)).join(", ")}`); |
| 113 | + } else { |
| 114 | + ok(`(a) all ${NEEDLES.length} pinned-api needles present`); |
| 115 | + } |
| 116 | + |
| 117 | + // ---- (b) every USED_API member is a method on this install's ExtensionAPI ---- |
| 118 | + const block = extractInterface(typesSrc, "ExtensionAPI"); |
| 119 | + if (!block) { |
| 120 | + fail("could not find `interface ExtensionAPI` in the install's types.d.ts"); |
| 121 | + } else { |
| 122 | + let allMembers = true; |
| 123 | + for (const member of USED_API_MEMBERS) { |
| 124 | + if (!new RegExp(`\\b${member}\\s*[<(]`).test(block)) { |
| 125 | + allMembers = false; |
| 126 | + fail(`ExtensionAPI no longer declares "${member}" as a method`); |
| 127 | + } |
| 128 | + } |
| 129 | + if (allMembers) ok(`(b) all ${USED_API_MEMBERS.length} USED_API members declared on ExtensionAPI`); |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + // ---- (c) VERSION is a runtime string export of the package root ---- |
| 134 | + const dot = piPkg.exports?.["."]; |
| 135 | + const entryRel = typeof dot === "string" ? dot : (dot?.import ?? dot?.default ?? piPkg.main ?? "./dist/index.js"); |
| 136 | + try { |
| 137 | + const piMod = await import(pathToFileURL(join(piDir, entryRel)).href); |
| 138 | + if (typeof piMod.VERSION === "string") { |
| 139 | + ok(`(c) VERSION is exported and a string ("${piMod.VERSION}")`); |
| 140 | + } else { |
| 141 | + fail(`the package root exports VERSION as ${typeof piMod.VERSION}, want string -- the admin's runtime advisory depends on it`); |
| 142 | + } |
| 143 | + } catch (err) { |
| 144 | + fail(`importing the package root (${entryRel}) threw: ${err.message}`); |
| 145 | + } |
| 146 | + |
| 147 | + // ---- (d) the built admin bundle loads with THIS pi and registers cleanly ---- |
| 148 | + const builtBundle = join(repoRoot, "admin", "dist", "index.mjs"); |
| 149 | + if (!existsSync(builtBundle)) { |
| 150 | + console.error("canary: admin/dist/index.mjs is missing -- run `node admin/build.mjs` first"); |
| 151 | + process.exit(2); |
| 152 | + } |
| 153 | + // Always copy fresh so the probe can never run against a stale bundle left in the scratch dir. |
| 154 | + const bundleCopy = join(scratch, "admin-bundle.mjs"); |
| 155 | + copyFileSync(builtBundle, bundleCopy); |
| 156 | + // The bundle's other externals (heavy runtime deps, not under test) resolve to the repo's own |
| 157 | + // pinned install via symlink -- nothing is ever network-installed here, and the scratch pi stays |
| 158 | + // the only pi in the resolution path. |
| 159 | + for (const dep of ["bullmq", "ioredis"]) { |
| 160 | + const target = join(scratch, "node_modules", dep); |
| 161 | + if (!existsSync(target)) symlinkSync(join(repoRoot, "node_modules", dep), target, "dir"); |
| 162 | + } |
| 163 | + // Hermeticity: the factory layers the deployment pointer from pi's agent dir at load; pin that to |
| 164 | + // an empty dir under the scratch so the probe can never read (or be steered by) a real ~/.pi/agent. |
| 165 | + process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(scratch, "canary-agent-")); |
| 166 | + delete process.env.PI_DISPATCH_DEPLOYMENT_FILE; |
| 167 | + |
| 168 | + try { |
| 169 | + const mod = await import(pathToFileURL(bundleCopy).href); |
| 170 | + if (typeof mod.default !== "function") throw new Error("the bundle's default export is not a factory function"); |
| 171 | + if (JSON.stringify([...mod.USED_API].sort()) !== JSON.stringify([...USED_API_MEMBERS].sort())) { |
| 172 | + fail(`the bundle's USED_API (${mod.USED_API}) differs from the canary's list (${USED_API_MEMBERS}) -- update USED_API_MEMBERS here`); |
| 173 | + } |
| 174 | + |
| 175 | + // A recording proxy exposing exactly the USED_API members; anything else the factory reaches |
| 176 | + // for throws, mirroring admin/test/wiring.test.mjs's discipline. |
| 177 | + const registered = []; |
| 178 | + const pi = new Proxy( |
| 179 | + {}, |
| 180 | + { |
| 181 | + get(_target, key) { |
| 182 | + if (typeof key !== "string") return undefined; |
| 183 | + if (key === "registerCommand") return (name, def) => registered.push([name, def]); |
| 184 | + if (USED_API_MEMBERS.includes(key)) return () => {}; |
| 185 | + throw new Error(`admin extension reached a non-USED_API pi member: ${key}`); |
| 186 | + }, |
| 187 | + }, |
| 188 | + ); |
| 189 | + const stderrLines = []; |
| 190 | + const origError = console.error; |
| 191 | + console.error = (...a) => stderrLines.push(a.join(" ")); |
| 192 | + try { |
| 193 | + mod.default(pi); |
| 194 | + } finally { |
| 195 | + console.error = origError; |
| 196 | + } |
| 197 | + if (stderrLines.length > 0) { |
| 198 | + fail(`the factory printed to stderr (the refusal path fired?): ${stderrLines[0]}`); |
| 199 | + } else if (registered.length !== 1 || registered[0][0] !== "dispatch") { |
| 200 | + fail(`expected exactly one registerCommand("dispatch"), got: ${JSON.stringify(registered.map(([n]) => n))}`); |
| 201 | + } else if (typeof registered[0][1]?.handler !== "function") { |
| 202 | + fail("the registered dispatch command has no handler function"); |
| 203 | + } else { |
| 204 | + ok(`(d) the built bundle loads against pi ${piPkg.version} and registers /dispatch cleanly`); |
| 205 | + } |
| 206 | + } catch (err) { |
| 207 | + fail(`loading the admin bundle against pi ${piPkg.version} threw: ${err.message}`); |
| 208 | + } |
| 209 | + |
| 210 | + if (failures > 0) { |
| 211 | + console.error( |
| 212 | + `canary: ${failures} failure(s) against pi ${piPkg.version}. pi moved underneath the admin: ` + |
| 213 | + "retest locally, bump SUPPORTED_PI_VERSION and the admin devDependency pin together " + |
| 214 | + "(load.test.mjs locks them to each other), and republish @edgehero/pi-dispatch-admin.", |
| 215 | + ); |
| 216 | + process.exit(1); |
| 217 | + } |
| 218 | + console.log(`canary: PASS against pi ${piPkg.version}`); |
| 219 | +} |
| 220 | + |
| 221 | +// Main-module guard: wiring.test.mjs imports this file for NEEDLES/USED_API_MEMBERS, and an import |
| 222 | +// must never run the probe. |
| 223 | +const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url; |
| 224 | +if (isMain) { |
| 225 | + const scratchArg = process.argv[2]; |
| 226 | + if (!scratchArg) { |
| 227 | + console.error("usage: node .github/scripts/admin-pi-canary.mjs <scratch-dir>"); |
| 228 | + process.exit(2); |
| 229 | + } |
| 230 | + await main(scratchArg); |
| 231 | +} |
0 commit comments