fix(core): keep multi-line commands intact for Store PowerShell 7 (#62522)

This commit is contained in:
Luke Parker
2026-11-06 23:38:57 -06:00
committed by GitHub
Unverified
parent 2b3a0d4410
commit 145e948159
3 changed files with 35 additions and 5 deletions
@@ -8,10 +8,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Teardown } from "@opencode/util/teardown"
import { appExecutionAlias } from "../fixture/app-execution-alias"
import { testEffect } from "../lib/effect"
const live = LayerNode.compile(CrossSpawnSpawner.node)
const fx = testEffect(live)
const pwsh = appExecutionAlias("pwsh.exe")
function js(code: string, opts?: ChildProcess.CommandOptions) {
return ChildProcess.make("node", ["-e", code], opts)
@@ -579,5 +581,19 @@ describe("cross-spawn spawner", () => {
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
// Skipped where PowerShell 7 is not installed from the Microsoft Store for the user; see the fixture.
const storePwsh = pwsh ? fx.live : fx.live.skip
storePwsh(
"keeps every line of a command run through an app execution alias",
Effect.gen(function* () {
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make(pwsh!.file, ["-NoProfile", "-NonInteractive", "-Command", "echo one\necho two"]),
),
)
expect(out.trim().split(/\r?\n/)).toEqual(["one", "two"])
}),
)
})
})
@@ -2,14 +2,16 @@ import { existsSync, lstatSync, readdirSync } from "fs"
import path from "path"
// Tests cannot create an app execution alias (an IO_REPARSE_TAG_APPEXECLINK reparse point), so they borrow one that
// a Microsoft Store app installed for the current user. Returns undefined when the machine has none.
export function appExecutionAlias() {
// a Microsoft Store app installed for the current user. Returns undefined when the machine has none, or none with
// the requested file name.
export function appExecutionAlias(file?: string) {
if (process.platform !== "win32" || !process.env.LOCALAPPDATA) return
const dir = path.join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps")
if (!existsSync(dir)) return
const name = readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.name.toLowerCase().endsWith(".exe"))
.map((entry) => entry.name)
.filter((entry) => file === undefined || entry.toLowerCase() === file)
.find((entry) => lstatSync(path.join(dir, entry), { throwIfNoEntry: false })?.isSymbolicLink())
return name ? { dir, name, file: path.join(dir, name) } : undefined
}
+15 -3
View File
@@ -370,11 +370,23 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
}
const launchProcess = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
// A Microsoft Store app execution alias, such as Store PowerShell 7 in %LOCALAPPDATA%\Microsoft\WindowsApps, is a
// reparse point that stat rejects with EACCES. cross-spawn then cannot find it and runs it through
// `cmd.exe /d /s /c`, which drops everything after the first newline of the command line. Launch the target of
// the link instead, read on every spawn because a Store update moves it.
const executable = (file: string) => {
if (globalThis.process.platform !== "win32" || !path.isAbsolute(file)) return Effect.succeed(file)
return fs.readLink(file).pipe(
Effect.map((target) => path.resolve(path.dirname(file), target)),
Effect.orElseSucceed(() => file),
)
}
const launchProcess = (command: ChildProcess.StandardCommand, file: string, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<Spawned, PlatformError.PlatformError>((resume) => {
const closed = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const exited = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args.map(unwrap), opts)
const proc = launch(file, command.args.map(unwrap), opts)
let settle = () => {}
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
@@ -420,7 +432,7 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
opts: NodeChildProcess.SpawnOptions,
) {
yield* Effect.logInfo("spawning process", { command: command.command, args: command.args, cwd: opts.cwd })
const [proc, closed, exited, settle] = yield* launchProcess(command, opts)
const [proc, closed, exited, settle] = yield* launchProcess(command, yield* executable(command.command), opts)
const stopOutput = yield* Deferred.make<void>()
// Register before process release so the deadline remains active during termination.
yield* Effect.forkScoped(