diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1f46965c62f..9b56c0a0cda 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 diff --git a/src/plugins/bundled-runtime-deps-materialization.ts b/src/plugins/bundled-runtime-deps-materialization.ts index 3a12046f0c7..1769af95178 100644 --- a/src/plugins/bundled-runtime-deps-materialization.ts +++ b/src/plugins/bundled-runtime-deps-materialization.ts @@ -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) { diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index bcc28cff32f..af5c5ccaef9 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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() }; diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index cf1d0886376..56bcc2c2734 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -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(""); + }); });