goose/ui/sdk/scripts/check-binary-compat.mjs
Alex Hancock c938a46417
fix(publish-npm): build binary from current SHA + add compat check (#9212)
Signed-off-by: Alex Hancock <alexhancock@block.xyz>
2026-05-14 16:33:40 +00:00

195 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
// Compatibility smoke test: boot the freshly-built goose binary via `goose acp`
// and call every read-only ACP method through the freshly-built SDK. The
// generated client validates every response with Zod, so any schema drift
// between the binary and the TUI client fails this check and blocks the
// publish.
//
// Run with:
// GOOSE_BINARY=/path/to/goose node ui/sdk/scripts/check-binary-compat.mjs
//
// Or via package script:
// GOOSE_BINARY=/path/to/goose pnpm --filter @aaif/goose-sdk run check:compat
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, existsSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Readable, Writable } from "node:stream";
const SDK_ROOT = resolve(new URL("..", import.meta.url).pathname);
const SDK_DIST = join(SDK_ROOT, "dist");
if (!existsSync(SDK_DIST)) {
console.error(
`[compat] expected built SDK at ${SDK_DIST} — run pnpm build first`,
);
process.exit(1);
}
const GOOSE_BINARY = process.env.GOOSE_BINARY;
if (!GOOSE_BINARY || !existsSync(GOOSE_BINARY)) {
console.error(
`[compat] GOOSE_BINARY must point to a built goose binary (got: ${GOOSE_BINARY ?? "<unset>"})`,
);
process.exit(1);
}
const { GooseClient } = await import(join(SDK_DIST, "goose-client.js"));
const { PROTOCOL_VERSION, ndJsonStream } = await import(
"@agentclientprotocol/sdk"
);
// Each entry is a read-only ACP method we expect to succeed against a fresh,
// unconfigured goose install. Adding a new entry whenever a new read-only
// method ships will widen the safety net for free.
const READ_ONLY_CHECKS = [
{
name: "GooseProvidersList",
call: (c) => c.goose.GooseProvidersList({ providerIds: [] }),
},
{
name: "GooseProvidersCatalogList",
call: (c) => c.goose.GooseProvidersCatalogList({}),
},
{
name: "GooseProvidersSetupCatalogList",
call: (c) => c.goose.GooseProvidersSetupCatalogList({}),
},
{
name: "GooseDefaultsRead",
call: (c) => c.goose.GooseDefaultsRead({}),
},
{
name: "GoosePreferencesRead",
call: (c) => c.goose.GoosePreferencesRead({}),
},
{
name: "GooseSourcesList",
call: (c) => c.goose.GooseSourcesList({}),
},
{
name: "GooseDictationConfig",
call: (c) => c.goose.GooseDictationConfig({}),
},
{
name: "GooseDictationModelsList",
call: (c) => c.goose.GooseDictationModelsList({}),
},
{
name: "GooseConfigExtensions",
call: (c) => c.goose.GooseConfigExtensions({}),
},
];
const sandbox = mkdtempSync(join(tmpdir(), "goose-compat-"));
const env = {
...process.env,
HOME: sandbox,
XDG_CONFIG_HOME: join(sandbox, ".config"),
XDG_DATA_HOME: join(sandbox, ".local/share"),
XDG_STATE_HOME: join(sandbox, ".local/state"),
XDG_CACHE_HOME: join(sandbox, ".cache"),
GOOSE_CONFIG_DIR: join(sandbox, ".config/goose"),
};
console.log(`[compat] using binary: ${GOOSE_BINARY}`);
console.log(`[compat] sandbox HOME: ${sandbox}`);
console.log(`[compat] binary size: ${statSync(GOOSE_BINARY).size} bytes`);
const child = spawn(GOOSE_BINARY, ["acp"], {
stdio: ["pipe", "pipe", "inherit"],
env,
});
let exitedEarly = false;
child.on("exit", (code, signal) => {
if (!exitedEarly) {
console.error(
`[compat] goose acp exited unexpectedly (code=${code} signal=${signal})`,
);
}
});
child.on("error", (err) => {
console.error(`[compat] failed to spawn goose acp: ${err.message}`);
process.exit(1);
});
const stream = ndJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout),
);
const client = new GooseClient(
() => ({
requestPermission: async () => ({
outcome: { outcome: "cancelled" },
}),
sessionUpdate: async () => {},
}),
stream,
);
let failed = 0;
let passed = 0;
const timeout = (ms, label) =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),
);
try {
await Promise.race([
client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "publish-npm-compat", version: "0.0.0" },
clientCapabilities: {},
}),
timeout(15_000, "initialize"),
]);
console.log("[compat] ✅ initialize");
for (const check of READ_ONLY_CHECKS) {
try {
await Promise.race([check.call(client), timeout(15_000, check.name)]);
console.log(`[compat] ✅ ${check.name}`);
passed += 1;
} catch (err) {
failed += 1;
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
console.error(`[compat] ❌ ${check.name}`);
console.error(indent(msg, " "));
}
}
} finally {
exitedEarly = true;
child.kill("SIGTERM");
try {
rmSync(sandbox, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
if (failed > 0) {
console.error(
`\n[compat] ${failed} check(s) failed, ${passed} passed — refusing to publish.`,
);
console.error(
"[compat] This means the TUI's generated client schema doesn't match what",
);
console.error(
"[compat] the goose binary returns. Regenerate the SDK or fix the server DTO.",
);
process.exit(1);
}
console.log(`\n[compat] all ${passed} checks passed.`);
process.exit(0);
function indent(s, prefix) {
return s
.split("\n")
.map((line) => prefix + line)
.join("\n");
}