fix: handle bin-only runtime deps

This commit is contained in:
Peter Steinberger
2026-05-01 09:07:25 +01:00
parent 0ac1a07f7c
commit 7d7b610a24
4 changed files with 119 additions and 10 deletions

View File

@@ -485,5 +485,6 @@ function Main {
return $true
}
$installSucceeded = Main
$mainResults = @(Main)
$installSucceeded = $mainResults.Count -gt 0 -and $mainResults[-1] -eq $true
Complete-Install -Succeeded:$installSucceeded

View File

@@ -213,6 +213,9 @@ function hasInstalledRuntimeDepExportFiles(packageDir: string, rawExports: unkno
}
function hasInstalledRuntimeDepEntryFiles(packageDir: string, packageJson: JsonObject): boolean {
if (hasInstalledRuntimeDepBinFiles(packageDir, packageJson.bin)) {
return true;
}
if (packageJson.exports !== undefined) {
return hasInstalledRuntimeDepExportFiles(packageDir, packageJson.exports);
}
@@ -223,6 +226,23 @@ function hasInstalledRuntimeDepEntryFiles(packageDir: string, packageJson: JsonO
return hasRuntimeDepEntryFile(packageDir, "index");
}
function collectRuntimeDepBinTargets(rawBin: unknown): string[] {
if (typeof rawBin === "string" && rawBin.trim() !== "") {
return [rawBin];
}
if (!isJsonObject(rawBin)) {
return [];
}
return Object.values(rawBin).filter(
(value): value is string => typeof value === "string" && value.trim() !== "",
);
}
function hasInstalledRuntimeDepBinFiles(packageDir: string, rawBin: unknown): boolean {
const targets = collectRuntimeDepBinTargets(rawBin);
return targets.some((target) => hasRuntimeDepEntryFile(packageDir, target));
}
function isRuntimeDepSatisfied(rootDir: string, dep: { name: string; version: string }): boolean {
const installed = readInstalledRuntimeDepPackage(rootDir, dep.name);
if (!installed) {

View File

@@ -1458,6 +1458,32 @@ describe("createBundledRuntimeDepsPackagePlan config policy", () => {
).not.toThrow();
});
it("accepts staged runtime deps that expose a package bin entry", () => {
const installRoot = makeTempDir();
const packageDir = path.join(installRoot, "node_modules", "@zed-industries", "codex-acp");
fs.mkdirSync(path.join(packageDir, "bin"), { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({
name: "@zed-industries/codex-acp",
version: "0.12.0",
bin: {
"codex-acp": "bin/codex-acp.js",
},
}),
"utf8",
);
fs.writeFileSync(path.join(packageDir, "bin", "codex-acp.js"), "#!/usr/bin/env node\n");
writeGeneratedRuntimeDepsManifest(installRoot, ["@zed-industries/codex-acp@0.12.0"]);
expect(isRuntimeDepsPlanMaterialized(installRoot, ["@zed-industries/codex-acp@0.12.0"])).toBe(
true,
);
expect(() =>
assertBundledRuntimeDepsInstalled(installRoot, ["@zed-industries/codex-acp@0.12.0"]),
).not.toThrow();
});
it("accepts staged runtime deps with exported package entry files", () => {
const installRoot = makeTempDir();
const packageDir = path.join(installRoot, "node_modules", "alpha-runtime");
@@ -1645,6 +1671,29 @@ describe("createBundledRuntimeDepsPackagePlan config policy", () => {
);
});
it("reports staged runtime deps as missing when a package bin entry is absent", () => {
const installRoot = makeTempDir();
const packageDir = path.join(installRoot, "node_modules", "alpha-runtime");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({
name: "alpha-runtime",
version: "1.0.0",
bin: {
"alpha-runtime": "bin/alpha-runtime.js",
},
}),
"utf8",
);
writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]);
expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(false);
expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow(
/alpha-runtime@1\.0\.0/,
);
});
it("reports staged runtime deps as missing when a declared entry file is absent", () => {
const packageRoot = setupPolicyPackageRoot();
const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() };

View File

@@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers";
const SCRIPT_PATH = "scripts/install.ps1";
const ENTRYPOINT_RE =
/\r?\n\$mainResults = @\(Main\)\r?\n\$installSucceeded = \$mainResults\.Count -gt 0 -and \$mainResults\[-1\] -eq \$true\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m;
function extractFunctionBody(source: string, name: string): string {
const match = source.match(
@@ -35,10 +37,7 @@ function toPowerShellSingleQuotedLiteral(value: string): string {
}
function createFailingNodeFixture(source: string): string {
const scriptWithoutEntryPoint = source.replace(
/\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m,
"",
);
const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, "");
expect(scriptWithoutEntryPoint).not.toBe(source);
return [
@@ -48,7 +47,8 @@ function createFailingNodeFixture(source: string): string {
"function Ensure-ExecutionPolicy { return $true }",
"function Ensure-Node { return $false }",
"",
"$installSucceeded = Main",
"$mainResults = @(Main)",
"$installSucceeded = $mainResults.Count -gt 0 -and $mainResults[-1] -eq $true",
"Complete-Install -Succeeded:$installSucceeded",
"",
].join("\n");
@@ -114,10 +114,7 @@ describe("install.ps1 failure handling", () => {
runIfPowerShell("keeps npm chatter out of Main's success return value", () => {
const tempDir = harness.createTempDir("openclaw-install-ps1-");
const scriptPath = join(tempDir, "install.ps1");
const scriptWithoutEntryPoint = source.replace(
/\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m,
"",
);
const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, "");
writeFileSync(
scriptPath,
[
@@ -149,4 +146,46 @@ describe("install.ps1 failure handling", () => {
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
runIfPowerShell("uses Main's final boolean result when helper output precedes success", () => {
const tempDir = harness.createTempDir("openclaw-install-ps1-");
const scriptPath = join(tempDir, "install.ps1");
const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, "");
writeFileSync(
scriptPath,
[
scriptWithoutEntryPoint,
"",
"function Write-Banner { }",
"function Ensure-ExecutionPolicy { return $true }",
"function Ensure-Node { return $true }",
"function Ensure-Git { return $true }",
"function Add-ToPath { param([string]$Path) }",
"function Install-OpenClawNpm {",
" param([string]$Target = 'latest')",
" Write-Output 'native chatter'",
" return $true",
"}",
"function Invoke-NativeCommandCapture {",
" param([string]$FilePath, [string[]]$Arguments)",
" return @{ ExitCode = 0; Stdout = 'npm prefix'; Stderr = '' }",
"}",
"$NoOnboard = $true",
"$mainResults = @(Main)",
"$installSucceeded = $mainResults.Count -gt 0 -and $mainResults[-1] -eq $true",
"Complete-Install -Succeeded:$installSucceeded",
"",
].join("\n"),
);
chmodSync(scriptPath, 0o755);
const result = spawnSync(
powershell!,
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath],
{ encoding: "utf8" },
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
});