diff --git a/package.json b/package.json index 20749f7b1cb4..47cfa490fcff 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "!docs/**/*.jpg", "!docs/**/*.png", "src/agents/templates/", + "scripts/crabbox-wrapper-providers.mjs", "scripts/crabbox-wrapper.mjs", "patches/", "skills/", diff --git a/scripts/crabbox-wrapper-providers.mjs b/scripts/crabbox-wrapper-providers.mjs new file mode 100644 index 000000000000..0bafedc0c6ea --- /dev/null +++ b/scripts/crabbox-wrapper-providers.mjs @@ -0,0 +1,93 @@ +const providerAliases = new Map([ + ["blacksmith", "blacksmith-testbox"], + ["cf", "cloudflare"], + ["container", "local-container"], + ["docker", "local-container"], + ["exe", "exe-dev"], + ["exedev", "exe-dev"], + ["google", "gcp"], + ["google-cloud", "gcp"], + ["local-docker", "local-container"], + ["namespace", "namespace-devbox"], + ["namespace-devboxes", "namespace-devbox"], + ["rail", "railway"], + ["railwayapp", "railway"], + ["run-pod", "runpod"], + ["runpodio", "runpod"], + ["sem", "semaphore"], + ["static", "ssh"], + ["static-ssh", "ssh"], + ["tensorlake-sbx", "tensorlake"], + ["tl", "tensorlake"], +]); + +// Crabbox providerHelpAll can omit Tensorlake even when the binary accepts it. +const providerHelpOmissions = new Set(["tensorlake"]); + +export function canonicalProviderName(provider) { + return providerAliases.get(provider) ?? provider; +} + +function addProviderNames(names, text) { + for (const name of text + .replace(/\s+\(default\b.*$/u, "") + .split(/\s*(?:,|\||\bor\b)\s*/u) + .map((s) => s.trim()) + .filter(Boolean)) { + if (/^[a-z0-9][a-z0-9-]*$/u.test(name)) { + names.add(name); + } + } +} + +function providerListContinuation(line, previousText) { + const match = line.match( + /^\s*((?:or\s+)?[a-z0-9][a-z0-9-]*(?:\s*(?:,|\||\bor\b)\s*(?:or\s+)?[a-z0-9][a-z0-9-]*)*\s*(?:,|\|)?)(?:\s+\(default\b.*)?\s*$/u, + ); + if (!match) { + return ""; + } + if (/[,|]\s*$/u.test(previousText) || /[,|]|\bor\b|\(default\b/u.test(line)) { + return match[1]; + } + return ""; +} + +export function parseProvidersFromHelp(text) { + const names = new Set(); + const lines = text.split(/\r?\n/u); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const providerMatch = line.match(/provider:\s*([a-z0-9][a-z0-9, -]*)(?:\s*\(default\b|$)/u); + if (providerMatch) { + let providerText = providerMatch[1]; + while (!/\(default\b/u.test(lines[index]) && index + 1 < lines.length) { + const continuation = providerListContinuation(lines[index + 1], providerText); + if (!continuation) { + break; + } + index += 1; + providerText = `${providerText} ${continuation}`; + } + addProviderNames(names, providerText); + continue; + } + + const flagMatch = line.match( + /^\s+-{1,2}provider(?:[=\s]+)([a-z0-9][a-z0-9|, -]*)(?:\s{2,}|\s+\(|$)/u, + ); + if (flagMatch && /[,|]|\bor\b/u.test(flagMatch[1])) { + addProviderNames(names, flagMatch[1]); + } + } + return [...names]; +} + +export function isProviderAdvertised(provider, advertisedProviders) { + const canonicalProvider = canonicalProviderName(provider); + return ( + advertisedProviders.includes(provider) || + advertisedProviders.includes(canonicalProvider) || + providerHelpOmissions.has(canonicalProvider) + ); +} diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 1084507fcd90..bc6fd5cd0898 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -19,6 +19,11 @@ import { import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + canonicalProviderName, + isProviderAdvertised, + parseProvidersFromHelp, +} from "./crabbox-wrapper-providers.mjs"; import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -660,7 +665,7 @@ function shouldRequireBrokeredAws(commandArgs, providerName) { if (process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS === "1") { return false; } - const canonicalProvider = providerAliases.get(providerName) ?? providerName; + const canonicalProvider = canonicalProviderName(providerName); if (canonicalProvider !== "aws") { return false; } @@ -2057,7 +2062,7 @@ function isAwsMacosRemoteTarget(commandArgs, providerName) { } function isBrokeredWsl2RemoteTarget(commandArgs, providerName) { - const canonicalProvider = providerAliases.get(providerName) ?? providerName; + const canonicalProvider = canonicalProviderName(providerName); return ( commandArgs[0] === "run" && (canonicalProvider === "aws" || canonicalProvider === "azure") && @@ -3152,99 +3157,10 @@ function injectFullCheckoutLeaseReclaim(commandArgs) { const version = checkedOutput(binary, ["--version"]); const help = checkedOutput(binary, ["run", "--help"]); -const providerAliases = new Map([ - ["blacksmith", "blacksmith-testbox"], - ["cf", "cloudflare"], - ["container", "local-container"], - ["docker", "local-container"], - ["exe", "exe-dev"], - ["exedev", "exe-dev"], - ["google", "gcp"], - ["google-cloud", "gcp"], - ["local-docker", "local-container"], - ["namespace", "namespace-devbox"], - ["namespace-devboxes", "namespace-devbox"], - ["rail", "railway"], - ["railwayapp", "railway"], - ["run-pod", "runpod"], - ["runpodio", "runpod"], - ["sem", "semaphore"], - ["static", "ssh"], - ["static-ssh", "ssh"], - ["tensorlake-sbx", "tensorlake"], - ["tl", "tensorlake"], -]); -// Crabbox providerHelpAll can omit Tensorlake even when the binary accepts it. -const providerHelpOmissions = new Set(["tensorlake"]); - -function addProviderNames(names, text) { - for (const name of text - .replace(/\s+\(default\b.*$/u, "") - .split(/\s*(?:,|\||\bor\b)\s*/u) - .map((s) => s.trim()) - .filter(Boolean)) { - if (/^[a-z0-9][a-z0-9-]*$/u.test(name)) { - names.add(name); - } - } -} - -function providerListContinuation(line, previousText) { - const match = line.match( - /^\s*((?:or\s+)?[a-z0-9][a-z0-9-]*(?:\s*(?:,|\||\bor\b)\s*(?:or\s+)?[a-z0-9][a-z0-9-]*)*\s*(?:,|\|)?)(?:\s+\(default\b.*)?\s*$/u, - ); - if (!match) { - return ""; - } - if (/[,|]\s*$/u.test(previousText) || /[,|]|\bor\b|\(default\b/u.test(line)) { - return match[1]; - } - return ""; -} - -function parseProvidersFromHelp(text) { - const names = new Set(); - const lines = text.split(/\r?\n/u); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - const providerMatch = line.match(/provider:\s*([a-z0-9][a-z0-9, -]*)(?:\s*\(default\b|$)/u); - if (providerMatch) { - let providerText = providerMatch[1]; - while (!/\(default\b/u.test(lines[index]) && index + 1 < lines.length) { - const continuation = providerListContinuation(lines[index + 1], providerText); - if (!continuation) { - break; - } - index += 1; - providerText = `${providerText} ${continuation}`; - } - addProviderNames(names, providerText); - continue; - } - - const flagMatch = line.match( - /^\s+-{1,2}provider(?:[=\s]+)([a-z0-9][a-z0-9|, -]*)(?:\s{2,}|\s+\(|$)/u, - ); - if (flagMatch && /[,|]|\bor\b/u.test(flagMatch[1])) { - addProviderNames(names, flagMatch[1]); - } - } - return [...names]; -} - -function isProviderAdvertised(provider, advertisedProviders) { - const canonicalProvider = providerAliases.get(provider) ?? provider; - return ( - advertisedProviders.includes(provider) || - advertisedProviders.includes(canonicalProvider) || - providerHelpOmissions.has(canonicalProvider) - ); -} - const providers = parseProvidersFromHelp(help.text); const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary); const provider = selectedProvider(args, providers); -const canonicalProvider = providerAliases.get(provider) ?? provider; +const canonicalProvider = canonicalProviderName(provider); const commandProviderValue = commandProvider(args); let normalizedArgs = ensureAwsMacOnDemandMarket( ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)), diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 663e0d1b8821..4e85de5f5e87 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -671,6 +671,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/tsconfig.json", ["test/scripts/oxlint-config.test.ts"]], ["scripts/build-all.mjs", ["test/scripts/build-all.test.ts"]], ["scripts/build-stamp.mjs", ["src/infra/build-stamp.test.ts"]], + ["scripts/crabbox-wrapper-providers.mjs", ["test/scripts/crabbox-wrapper.test.ts"]], ["scripts/crabbox-wrapper.mjs", ["test/scripts/crabbox-wrapper.test.ts"]], ["scripts/github/barnacle-auto-response.mjs", ["test/scripts/barnacle-auto-response.test.ts"]], ["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]], diff --git a/test/package-manager-config.test.ts b/test/package-manager-config.test.ts index b2b14ae2b4c0..98b6b200f6f5 100644 --- a/test/package-manager-config.test.ts +++ b/test/package-manager-config.test.ts @@ -72,6 +72,17 @@ describe("package manager build policy", () => { expect(packageJson.files).toContain("THIRD_PARTY_NOTICES.md"); }); + it("includes the Crabbox wrapper runtime modules in the published root package", () => { + const packageJson = readJson("package.json") as RootPackageJson; + + expect(packageJson.files).toEqual( + expect.arrayContaining([ + "scripts/crabbox-wrapper.mjs", + "scripts/crabbox-wrapper-providers.mjs", + ]), + ); + }); + it("keeps npm shrinkwrap aligned with workspace overrides", () => { const workspace = parse( fs.readFileSync("pnpm-workspace.yaml", "utf8"), diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index dd3a85c439b5..5ef4d23b1fe6 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -15,6 +15,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canonicalProviderName, + isProviderAdvertised, + parseProvidersFromHelp, +} from "../../scripts/crabbox-wrapper-providers.mjs"; const tempDirs: string[] = []; const repoRoot = process.cwd(); @@ -588,24 +593,24 @@ describe("scripts/crabbox-wrapper", () => { "", ].join("\n"); const advertisedProviderAliases = [ - "blacksmith", - "cf", - "container", - "docker", - "exe", - "exedev", - "google", - "google-cloud", - "local-docker", - "namespace", - "namespace-devboxes", - "rail", - "railwayapp", - "run-pod", - "runpodio", - "sem", - "static", - "static-ssh", + ["blacksmith", "blacksmith-testbox"], + ["cf", "cloudflare"], + ["container", "local-container"], + ["docker", "local-container"], + ["exe", "exe-dev"], + ["exedev", "exe-dev"], + ["google", "gcp"], + ["google-cloud", "gcp"], + ["local-docker", "local-container"], + ["namespace", "namespace-devbox"], + ["namespace-devboxes", "namespace-devbox"], + ["rail", "railway"], + ["railwayapp", "railway"], + ["run-pod", "runpod"], + ["runpodio", "runpod"], + ["sem", "semaphore"], + ["static", "ssh"], + ["static-ssh", "ssh"], ]; beforeAll(() => { runWrapper("provider: aws\n", ["--version"]); @@ -3077,18 +3082,12 @@ describe("scripts/crabbox-wrapper", () => { }); it.each(advertisedProviderAliases)( - "accepts Crabbox provider alias %s when its canonical provider is advertised", - (alias) => { - const result = runWrapper(advertisedProviderAliasHelp, [ - "run", - "--provider", - alias, - "--", - "echo ok", - ]); + "canonicalizes Crabbox provider alias %s to %s", + (alias, canonical) => { + const advertisedProviders = parseProvidersFromHelp(advertisedProviderAliasHelp); - expect(result.status, alias).toBe(0); - expect(parseFakeCrabboxOutput(result).args).toContain(alias); + expect(canonicalProviderName(alias)).toBe(canonical); + expect(isProviderAdvertised(alias, advertisedProviders)).toBe(true); }, ); @@ -3099,11 +3098,9 @@ describe("scripts/crabbox-wrapper", () => { "", ].join("\n"); + const advertisedProviders = parseProvidersFromHelp(helpText); for (const provider of ["tensorlake", "tl", "tensorlake-sbx"]) { - const result = runWrapper(helpText, ["run", "--provider", provider, "--", "echo ok"]); - - expect(result.status, provider).toBe(0); - expect(parseFakeCrabboxOutput(result).args).toContain(provider); + expect(isProviderAdvertised(provider, advertisedProviders), provider).toBe(true); } }); @@ -3132,15 +3129,16 @@ describe("scripts/crabbox-wrapper", () => { }); it("parses provider choices from the --provider flag help format", () => { - const result = runWrapper( - "Usage: crabbox run [options]\n --provider hetzner|aws|local-container|blacksmith-testbox|cloudflare\n", - ["run", "--provider", "aws", "--", "echo ok"], - ); + const helpText = + "Usage: crabbox run [options]\n --provider hetzner|aws|local-container|blacksmith-testbox|cloudflare\n"; - expect(result.status).toBe(0); - expect(result.stderr).toContain( - "providers=hetzner,aws,local-container,blacksmith-testbox,cloudflare", - ); + expect(parseProvidersFromHelp(helpText)).toEqual([ + "hetzner", + "aws", + "local-container", + "blacksmith-testbox", + "cloudflare", + ]); }); it("uses a temporary full checkout for clean sparse Blacksmith syncs", () => { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 0e43c321f796..069c5f234967 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1189,10 +1189,15 @@ describe("scripts/test-projects changed-target routing", () => { }); it("keeps Crabbox runner script edits on their regression tests", () => { - expect(resolveChangedTestTargetPlan(["scripts/crabbox-wrapper.mjs"])).toEqual({ - mode: "targets", - targets: ["test/scripts/crabbox-wrapper.test.ts"], - }); + for (const scriptPath of [ + "scripts/crabbox-wrapper.mjs", + "scripts/crabbox-wrapper-providers.mjs", + ]) { + expect(resolveChangedTestTargetPlan([scriptPath]), scriptPath).toEqual({ + mode: "targets", + targets: ["test/scripts/crabbox-wrapper.test.ts"], + }); + } }); it("keeps build stamp script edits on the build stamp regression test", () => {