diff --git a/.github/actions/prepare-windows-cli-launcher/action.yml b/.github/actions/prepare-windows-cli-launcher/action.yml new file mode 100644 index 000000000000..8e7ae74f48cf --- /dev/null +++ b/.github/actions/prepare-windows-cli-launcher/action.yml @@ -0,0 +1,39 @@ +name: Prepare the orcad Windows CLI launcher +description: Compiles native/windows-cli-launcher for each requested arch into .build/windows-cli-launcher//orca.exe. + +inputs: + arches: + description: Space-separated Windows architectures (x64, arm64). + required: true + +runs: + using: composite + steps: + - name: Cache the Windows CLI launcher builds + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + .build/windows-cli-launcher + native/windows-cli-launcher/target + ~/.cargo/registry + key: orcad-windows-cli-launcher-${{ runner.arch }}-${{ inputs.arches }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }} + + # Why explicit: a runner image without cargo would otherwise fail inside the orcad build. + - name: Build the launcher for each architecture + if: runner.os == 'Windows' + shell: pwsh + env: + ARCHES: ${{ inputs.arches }} + run: | + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + rustup toolchain install stable --profile minimal + rustup default stable + } + $triples = @{ x64 = 'x86_64-pc-windows-msvc'; arm64 = 'aarch64-pc-windows-msvc' } + foreach ($arch in ($env:ARCHES -split ' ' | Where-Object { $_ })) { + rustup target add $triples[$arch] + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node config/scripts/build-windows-cli-launcher.mjs --arch $arch --output ".build/windows-cli-launcher/$arch/orca.exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index de4592af128d..a21fb68408c6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -665,6 +665,9 @@ jobs: with: resolve-windows-cache: 'true' restore-windows-cache: 'true' + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: x64 - name: Build the e2e app, CLI, orcad slot and template shell: bash run: | diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 90c3fc68473d..a72e7fbc6fd0 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -192,6 +192,11 @@ jobs: (github.event_name == 'pull_request' || (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }} restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + # The template lane cross-compiles arm64 too, as it does for the process-table addon. + - uses: ./.github/actions/prepare-windows-cli-launcher + if: runner.os == 'Windows' + with: + arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }} - run: pnpm build:orcad - wait: bun-orcad - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} @@ -216,6 +221,15 @@ jobs: if-no-files-found: error retention-days: 7 overwrite: true + - name: Keep the Windows CLI launchers for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + uses: actions/upload-artifact@v7 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher/ + if-no-files-found: error + retention-days: 7 + overwrite: true - uses: actions/setup-node@v6 if: runner.arch == 'X64' with: @@ -458,6 +472,11 @@ jobs: with: name: orcad-windows-process-tree path: .build/windows-process-tree + - name: Collect the Windows CLI launchers + uses: actions/download-artifact@v8 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher - name: Merge the lanes and gate the full slot matrix shell: bash run: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3751f5f2b289..c98f55d34d09 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1220,6 +1220,7 @@ jobs: config/scripts/ensure-native-runtime-job-ownership.test.mjs config/scripts/verify-packaged-node-pty-job-ownership.test.mjs config/scripts/windows-pe-machine.test.mjs + config/scripts/build-windows-cli-launcher.test.mjs config/scripts/script-module-dependencies.test.mjs src/main/windows-registry-addon.test.ts config/scripts/windows-process-tree-gyp-path.test.mjs diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 752f3ff9a2a0..2b6792b94af3 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -26,6 +26,11 @@ on: - 'config/scripts/build-orcad-*.mjs' - 'config/scripts/orcad-prebuild-*.mjs' - 'config/scripts/orcad-windows-process-tree.mjs' + - 'config/scripts/orcad-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'native/windows-cli-launcher/**' + - 'src/main/orcad/orcad-cli-launcher.ts' + - '.github/actions/prepare-windows-cli-launcher/**' - 'config/scripts/build-relay.mjs' - 'config/patches/node-pty*' - 'config/patches/@vscode__windows-process-tree*' @@ -150,6 +155,9 @@ jobs: with: resolve-windows-cache: ${{ github.event_name == 'pull_request' }} restore-windows-cache: ${{ github.event_name == 'pull_request' }} + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: ${{ matrix.arch }} - name: Build the win32 template and the relay shell: bash env: diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml index 776b0db72ed8..6e105876008a 100644 --- a/.github/workflows/win-orcad-serve-switch-e2e.yml +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -78,6 +78,9 @@ jobs: with: resolve-windows-cache: 'true' restore-windows-cache: 'true' + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: x64 - name: Build the win32-x64 orcad template shell: bash run: | diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 5099a95994d7..d548e5c65d16 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -31,6 +31,7 @@ import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-s import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' +import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, ORCAD_CLI_ENTRY_FILENAME, @@ -157,6 +158,7 @@ writeFileSync( stageNodePty(BUILD_TARGET) await stageParcelWatcher(BUILD_TARGET) stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) +stageOrcadWindowsCliLauncher(ROOT, OUT_DIR, BUILD_TARGET) const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) mkdirSync(dirname(emojiDatasetOutput), { recursive: true }) copyFileSync( diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 3d9d93aaed20..a0950170d4ec 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -40,6 +40,20 @@ function defaultOutputPath(projectRoot) { return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe') } +// Why explicit triples: the orcad template ships both, cross-compiled from one x64 runner. +const RUST_TARGETS = { x64: 'x86_64-pc-windows-msvc', arm64: 'aarch64-pc-windows-msvc' } + +export function windowsCliLauncherRustTarget(arch) { + if (arch === undefined) { + return null + } + const target = RUST_TARGETS[arch] + if (!target) { + throw new Error(`Unsupported Windows CLI launcher architecture: ${arch}`) + } + return target +} + function readArg(name) { const index = process.argv.indexOf(name) return index !== -1 ? process.argv[index + 1] : undefined @@ -61,6 +75,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const { version } = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) // Throws on a version the PE cannot represent, before anything is compiled. windowsCliLauncherFileVersion(version) + const rustTarget = windowsCliLauncherRustTarget(readArg('--arch')) const fingerprint = windowsCliLauncherFingerprint( [ join(crateRoot, 'src', 'main.rs'), @@ -71,7 +86,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], - version + rustTarget ? `${version}\0${rustTarget}` : version ) const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) @@ -92,7 +107,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) '--manifest-path', manifestPath, '--target-dir', - targetDirectory + targetDirectory, + ...(rustTarget ? ['--target', rustTarget] : []) ], { cwd: crateRoot, @@ -119,7 +135,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - const builtPath = join(targetDirectory, 'release', 'orca.exe') + const builtPath = join( + targetDirectory, + ...(rustTarget ? [rustTarget] : []), + 'release', + 'orca.exe' + ) const vcRuntimeImports = findDynamicVcRuntimeImports( readPeImportedDllNames(readFileSync(builtPath)) ) diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 0e06cafd22eb..236bafe7443a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest' import { shouldReuseCompiledWindowsCliLauncher, windowsCliLauncherFileVersion, + windowsCliLauncherRustTarget, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' @@ -245,6 +246,81 @@ describe('Windows CLI launcher', () => { } }) + it('cross-compiles only the architectures the orcad template ships', () => { + expect(windowsCliLauncherRustTarget(undefined)).toBeNull() + expect(windowsCliLauncherRustTarget('x64')).toBe('x86_64-pc-windows-msvc') + expect(windowsCliLauncherRustTarget('arm64')).toBe('aarch64-pc-windows-msvc') + expect(() => windowsCliLauncherRustTarget('ia32')).toThrow('Unsupported') + }) + + itWindows('runs a server profile CLI on pinned Node with argv and profile intact', () => { + const root = mkdtempSync(join(tmpdir(), 'orca server launcher ')) + try { + const profile = join(root, 'server profile') + const launcherPath = join(profile, 'cli', 'bin', 'orca.exe') + const cliPath = join(root, 'server slot', 'out', 'cli', 'index.js') + mkdirSync(dirname(cliPath), { recursive: true }) + writeFileSync( + cliPath, + `process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + profile: process.env.ORCA_USER_DATA_PATH, + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, + nodeOptions: process.env.NODE_OPTIONS ?? null +}))\n`, + 'utf8' + ) + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + writeFileSync( + join(dirname(launcherPath), 'orca-launcher.cfg'), + `node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n` + ) + + const inherited = { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: '--require C:\\missing\\preload.js', + ORCA_USER_DATA_PATH: 'C:\\another\\owner', + ORCA_TEST_LAUNCHER: launcherPath + } + const expected = (body) => ({ + argv: ['orchestration', 'send', '--body', body, '--json'], + profile, + electronRunAsNode: null, + nodeOptions: null + }) + // Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none. + const shellBody = 'line one & %PATH% ^caret\n\nline three' + const powershell = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json' + ], + { encoding: 'utf8', env: { ...inherited, ORCA_TEST_BODY: shellBody } } + ) + expect(powershell.status, powershell.stderr).toBe(0) + expect(JSON.parse(powershell.stdout)).toEqual(expected(shellBody)) + + const body = 'line one "quoted" \\"escaped\\" & %PATH% ^caret\\\n\nline three\\' + const direct = spawnSync(launcherPath, expected(body).argv, { + encoding: 'utf8', + env: inherited + }) + expect(direct.status, direct.stderr).toBe(0) + expect(JSON.parse(direct.stdout)).toEqual(expected(body)) + } finally { + removeFixtureTree(root) + } + }) + itWindows('survives an inherited environment block containing PATH and Path', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher ')) try { diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index ffaf8f1dce66..17be2b64a089 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -14,6 +14,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', + 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/run-node-server-tests.mjs', 'config/vitest.config.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', diff --git a/config/scripts/orcad-windows-cli-launcher.mjs b/config/scripts/orcad-windows-cli-launcher.mjs new file mode 100644 index 000000000000..14339045317e --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.mjs @@ -0,0 +1,63 @@ +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import { ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME } from '../../src/shared/orcad-artifacts.ts' + +const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' +) + +export function orcadWindowsCliLauncherSource(root, arch) { + return join(root, '.build', 'windows-cli-launcher', arch, 'orca.exe') +} + +/** A Windows slot's `orca` command; a Windows build host compiles a missing one. */ +export function stageOrcadWindowsCliLauncher( + root, + outputDir, + target, + host = { platform: process.platform }, + compile = compileOrcadWindowsCliLauncher +) { + if (!target.startsWith('win32-')) { + return + } + const arch = target.slice('win32-'.length) + const source = orcadWindowsCliLauncherSource(root, arch) + if (!existsSync(source)) { + if (host.platform !== 'win32') { + throw new Error( + `Orcad ${target} requires the native CLI launcher. On Windows, run: ` + + `node config/scripts/build-windows-cli-launcher.mjs --arch ${arch} --output ${source}` + ) + } + compile(root, arch, source) + } + const machine = readPeMachine(source) + if (machine !== PE_MACHINE[arch]) { + throw new Error(`Orcad ${target} CLI launcher has ${describePeMachine(machine)}`) + } + const destination = join(outputDir, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(source, destination) +} + +function compileOrcadWindowsCliLauncher(root, arch, output) { + const result = runProcessSync({ + program: process.execPath, + args: [ + join(root, 'config/scripts/build-windows-cli-launcher.mjs'), + '--arch', + arch, + '--output', + output + ], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`Windows CLI launcher build for ${arch} failed with exit ${result.code}`) + } +} diff --git a/config/scripts/orcad-windows-cli-launcher.test.mjs b/config/scripts/orcad-windows-cli-launcher.test.mjs new file mode 100644 index 000000000000..5e12c5597e18 --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.test.mjs @@ -0,0 +1,66 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + orcadWindowsCliLauncherSource, + stageOrcadWindowsCliLauncher +} from './orcad-windows-cli-launcher.mjs' + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function peImage(machine) { + const bytes = Buffer.alloc(0x90) + bytes.write('MZ') + bytes.writeUInt32LE(0x80, 0x3c) + bytes.write('PE\0\0', 0x80) + bytes.writeUInt16LE(machine, 0x84) + return bytes +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orcad-cli-launcher-')) + roots.push(root) + const output = join(root, 'output') + mkdirSync(output) + return { root, output } +} + +function prepare(root, arch, machine) { + const source = orcadWindowsCliLauncherSource(root, arch) + mkdirSync(dirname(source), { recursive: true }) + writeFileSync(source, peImage(machine)) + return source +} + +it('stages the prepared launcher for the requested machine at the slot command path', () => { + const { root, output } = fixture() + const source = prepare(root, 'arm64', 0xaa64) + stageOrcadWindowsCliLauncher(root, output, 'win32-arm64', { platform: 'linux' }) + expect(readFileSync(join(output, 'bin', 'orca.exe'))).toEqual(readFileSync(source)) +}) + +it('rejects a launcher built for the wrong machine', () => { + const { root, output } = fixture() + prepare(root, 'x64', 0xaa64) + expect(() => stageOrcadWindowsCliLauncher(root, output, 'win32-x64')).toThrow('machine 0xaa64') +}) + +it('refuses a missing launcher off Windows and compiles it on Windows', () => { + const { root, output } = fixture() + expect(() => + stageOrcadWindowsCliLauncher(root, output, 'win32-x64', { platform: 'darwin' }) + ).toThrow('native CLI launcher') + const compile = vi.fn((_root, arch, path) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, peImage(arch === 'x64' ? 0x8664 : 0xaa64)) + }) + stageOrcadWindowsCliLauncher(root, output, 'win32-x64', { platform: 'win32' }, compile) + expect(compile).toHaveBeenCalledWith(root, 'x64', orcadWindowsCliLauncherSource(root, 'x64')) + expect(readFileSync(join(output, 'bin', 'orca.exe'))).toEqual(peImage(0x8664)) +}) + +it('ignores POSIX targets', () => { + expect(() => stageOrcadWindowsCliLauncher('absent', 'absent', 'linux-x64-glibc')).not.toThrow() +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 1d1d784db863..632fec09dcdf 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -369,6 +369,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', 'config/scripts/windows-pe-machine.test.mjs', + 'config/scripts/build-windows-cli-launcher.test.mjs', 'config/scripts/script-module-dependencies.test.mjs', 'src/main/windows-registry-addon.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index 00282fe455ee..ab513dea240e 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -9,16 +9,29 @@ //! user-writable directory that mutates environment variables and proxies a //! child process is the shape antivirus MSIL heuristics are trained on, and it //! was flagged as exactly that across several vendors (#23383). +//! +//! On a Windows server host, orcad copies this binary into its profile and +//! writes `orca-launcher.cfg` beside it; that file selects the server mode. use std::env; +use std::fs; use std::path::{Path, PathBuf}; use std::process::{exit, Command}; +const SERVER_CONFIG_FILENAME: &str = "orca-launcher.cfg"; + fn main() { let launcher = match env::current_exe() { Ok(path) => path, Err(error) => fail(&format!("Unable to start the Orca CLI: {error}")), }; + if let Some(config) = launcher + .parent() + .map(|directory| directory.join(SERVER_CONFIG_FILENAME)) + .filter(|config| config.is_file()) + { + run_server_cli(&config); + } let Some(resources_directory) = launcher.parent().and_then(Path::parent) else { fail(&format!( @@ -74,10 +87,57 @@ fn main() { }, ); + run_cli(&electron_path, &cli_path) +} + +/// The server mode: the pinned Node runs the active slot's CLI under the +/// server's profile, as the POSIX launcher does. +fn run_server_cli(config_path: &Path) -> ! { + let config = fs::read_to_string(config_path).unwrap_or_else(|error| { + fail(&format!( + "Unable to read the Orca CLI configuration at \"{}\": {error}", + config_path.display() + )) + }); + let node_path = PathBuf::from(config_value(&config, "node", config_path)); + let cli_path = PathBuf::from(config_value(&config, "cli", config_path)); + let user_data_path = config_value(&config, "user-data", config_path); + for path in [&node_path, &cli_path] { + if !path.is_file() { + fail(&format!( + "Unable to locate \"{}\"; reconnect to the Orca server to refresh the CLI", + path.display() + )); + } + } + env::set_var("ORCA_USER_DATA_PATH", user_data_path); + for name in ["NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "ELECTRON_RUN_AS_NODE"] { + env::remove_var(name); + } + run_cli(&node_path, &cli_path) +} + +fn config_value<'a>(config: &'a str, key: &str, config_path: &Path) -> &'a str { + config + .lines() + .find_map(|line| { + line.split_once('=') + .filter(|(name, value)| *name == key && !value.is_empty()) + .map(|(_, value)| value) + }) + .unwrap_or_else(|| { + fail(&format!( + "The Orca CLI configuration at \"{}\" has no {key}", + config_path.display() + )) + }) +} + +fn run_cli(program: &Path, cli_path: &Path) -> ! { // Each argument stays its own argv entry, so a body holding newlines reaches // the CLI intact. - let mut command = Command::new(&electron_path); - command.arg(&cli_path).args(env::args_os().skip(1)); + let mut command = Command::new(program); + command.arg(cli_path).args(env::args_os().skip(1)); match command.status() { Ok(status) => exit(status.code().unwrap_or(1)), diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index ba9e8c6b7127..30f049bf9956 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -46,6 +46,30 @@ describe('prependOrcaCliDirToChildPath', () => { expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() } ) + it('puts a Windows execution host CLI first on Path without a POSIX restore dir', () => { + const binDir = join(USER_DATA, 'cli', 'bin') + const launcher = join(binDir, 'orca.exe') + installFakeAppEnvironment({ getCliLauncherPath: () => launcher }) + const env: Record = { + Path: `C:\\Windows;${binDir};;C:\\Tools`, + ORCA_USER_DATA_PATH: 'C:\\parent\\profile', + ORCA_CLI_COMMAND: 'C:\\parent\\orca.exe', + ORCA_CLI_BIN_DIR: '/parent/bin' + } + expect( + prependOrcaCliDirToChildPath(env, { + isPackaged: true, + userDataPath: USER_DATA, + resourcesPath: null, + platform: 'win32' + }) + ).toBe(launcher) + expect(env).toEqual({ + Path: `${binDir};C:\\Windows;C:\\Tools`, + ORCA_USER_DATA_PATH: USER_DATA, + ORCA_CLI_COMMAND: launcher + }) + }) it('leads packaged Linux PATH with the bare-orca shim dir', () => { // Why this matters at all: the Linux CLI installs as `orca-ide` so it never claims GNOME // Orca's /usr/bin/orca screen reader, so bare `orca` only works through this shim. diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 3df5044d4583..6ae5efeb52d8 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -1,10 +1,17 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' -import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' -import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { + ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, + getOrcadCliLauncherPath, + prepareOrcadCliLauncher +} from './orcad-cli-launcher' const roots = vi.hoisted(() => ({ install: '', profile: '' })) vi.mock('./orcad-app-paths', () => ({ @@ -87,10 +94,58 @@ describe('orcad profile CLI launcher', () => { expect(getOrcadCliLauncherPath()).toBeNull() }) - it('does not create a cmd.exe message proxy on Windows', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - await writeCli() - await prepareOrcadCliLauncher() - expect(getOrcadCliLauncherPath()).toBeNull() + describe('on a Windows host', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + async function writeLauncher(contents: string): Promise { + const path = join(roots.install, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) + } + + const binDir = (): string => join(roots.profile, 'cli', 'bin') + const readConfig = (): Promise => + readFile(join(binDir(), ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME), 'utf8') + + it('installs the native launcher and points it at this slot, runtime and profile', async () => { + await writeCli() + await writeLauncher('native launcher v1') + await prepareOrcadCliLauncher() + const launcher = getOrcadCliLauncherPath() + expect(launcher).toBe(join(binDir(), 'orca.exe')) + expect(await readFile(launcher!, 'utf8')).toBe('native launcher v1') + expect(await readConfig()).toBe( + [ + `node=${process.execPath}`, + `cli=${join(roots.install, ...ORCAD_CLI_ENTRY_FILENAME.split('/'))}`, + `user-data=${roots.profile}`, + '' + ].join('\n') + ) + // cmd.exe reparses `%*`, so no batch or PowerShell proxy may shadow the exe. + expect((await readdir(binDir())).sort()).toEqual( + [ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, 'orca.exe'].sort() + ) + }) + + it('refreshes the launcher and its target when the host switches server slots', async () => { + await writeCli() + await writeLauncher('native launcher v1') + await prepareOrcadCliLauncher() + roots.install = join(directory, 'updated server') + await writeCli() + await writeLauncher('native launcher v2') + await prepareOrcadCliLauncher() + expect(await readFile(join(binDir(), 'orca.exe'), 'utf8')).toBe('native launcher v2') + expect(await readConfig()).toContain(`cli=${join(roots.install, 'out', 'cli', 'index.js')}`) + }) + + it('leaves a slot without the native launcher with no orca command', async () => { + await writeCli() + await prepareOrcadCliLauncher() + expect(getOrcadCliLauncherPath()).toBeNull() + }) }) }) diff --git a/src/main/orcad/orcad-cli-launcher.ts b/src/main/orcad/orcad-cli-launcher.ts index 01660164e0a2..7bb351a37391 100644 --- a/src/main/orcad/orcad-cli-launcher.ts +++ b/src/main/orcad/orcad-cli-launcher.ts @@ -1,11 +1,20 @@ -import { chmod, mkdir, readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { chmod, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { join } from 'node:path' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { nodeFileContentsEqual } from '../../shared/node-file-content-equality' import { buildUnixCliLauncher } from '../cli/cli-dev-launcher' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' +// Read by native/windows-cli-launcher: its presence beside orca.exe selects the server mode. +export const ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME = 'orca-launcher.cfg' +const RETIRED_LAUNCHER_SUFFIX = '.retired' + let launcherPath: string | null = null export function getOrcadCliLauncherPath(): string | null { @@ -14,19 +23,21 @@ export function getOrcadCliLauncherPath(): string | null { export async function prepareOrcadCliLauncher(): Promise { launcherPath = null - // Windows needs a native argv-preserving launcher; never proxy message bodies through cmd.exe. - if (process.platform === 'win32') { - return - } - const entry = join(resolveOrcadInstallRoot(), ...ORCAD_CLI_ENTRY_FILENAME.split('/')) + const installRoot = resolveOrcadInstallRoot() + const entry = join(installRoot, ...ORCAD_CLI_ENTRY_FILENAME.split('/')) // Older server slots and source-only runs may not include the CLI yet. if (!existsSync(entry)) { return } const userDataPath = resolveUserDataPath() - const path = join(userDataPath, 'cli', 'bin', 'orca') + const binDir = join(userDataPath, 'cli', 'bin') + if (process.platform === 'win32') { + launcherPath = await prepareWindowsLauncher(installRoot, entry, userDataPath, binDir) + return + } + const path = join(binDir, 'orca') const script = buildUnixCliLauncher(process.execPath, entry, userDataPath, 'node') - await mkdir(dirname(path), { recursive: true }) + await mkdir(binDir, { recursive: true }) const current = await readFile(path, 'utf8').catch(() => null) if (current !== script) { writeFileAtomically(path, script, { mode: 0o700 }) @@ -34,3 +45,53 @@ export async function prepareOrcadCliLauncher(): Promise { await chmod(path, 0o700) launcherPath = path } + +export function buildWindowsCliLauncherConfig( + nodePath: string, + cliEntryPath: string, + userDataPath: string +): string { + return `node=${nodePath}\ncli=${cliEntryPath}\nuser-data=${userDataPath}\n` +} + +/** A native exe, never a .cmd: cmd.exe reparses `%*` and cannot carry a multi-line body. */ +async function prepareWindowsLauncher( + installRoot: string, + entry: string, + userDataPath: string, + binDir: string +): Promise { + const source = join(installRoot, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + if (!existsSync(source)) { + return null + } + const path = join(binDir, 'orca.exe') + await mkdir(binDir, { recursive: true }) + await removeRetiredLaunchers(binDir) + const binary = await readFile(source) + if (!(await nodeFileContentsEqual(path, binary).catch(() => false))) { + try { + writeFileAtomically(path, binary) + } catch { + // A running `orca` keeps its image mapped; Windows still lets it be renamed away. + await rename(path, `${path}.${randomUUID()}${RETIRED_LAUNCHER_SUFFIX}`) + writeFileAtomically(path, binary) + } + } + const configPath = join(binDir, ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME) + const config = buildWindowsCliLauncherConfig(process.execPath, entry, userDataPath) + if ((await readFile(configPath, 'utf8').catch(() => null)) !== config) { + writeFileAtomically(configPath, config) + } + return path +} + +async function removeRetiredLaunchers(binDir: string): Promise { + const names = await readdir(binDir).catch(() => []) + await Promise.all( + names + .filter((name) => name.startsWith('orca.exe.') && name.endsWith(RETIRED_LAUNCHER_SUFFIX)) + // One still running stays until a later start. + .map((name) => rm(join(binDir, name), { force: true }).catch(() => {})) + ) +} diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index deaa320d8b38..0acabed675b0 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -27,6 +27,8 @@ import { decommissionRemoteOrcad } from './orcad-remote-decommission' import { readOrcadActivationRecord } from './orcad-activation-record-store' import { gcOldOrcadVersions } from './orcad-remote-gc' import { execOrcadRemote } from './orcad-remote-runtime-control' +import { orcadWindowsNodeCommandLine } from './orcad-remote-windows-node' +import { joinRemotePath } from './ssh-remote-platform' import { isWindowsOrcadCellId, readWindowsHostCellDescriptor, @@ -97,6 +99,14 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, slotDir)) ) expect(await liveness()).toBe('LIVE') + // Managed terminals' `orca`: the native launcher orcad installed in its profile. + const cli = joinRemotePath(options.host, context.userDataDir, 'cli', 'bin', 'orca.exe') + const status = JSON.parse( + await execOrcadRemote(options, orcadWindowsNodeCommandLine(cli, ['status', '--json'])) + ) + receipt.cliStatus = status.result + expect(status.result.runtime).toMatchObject({ reachable: true }) + expect(status.result.app.desktopWindowStatus).toBe('blocked') // The managed tunnel picks the stdio bridge on this account and reaches orcad through it. receipt.stdioBridge = await proveWindowsStdioBridge(conn, options.host, slotDir) expect(receipt.stdioBridge).toMatchObject({ diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 4e34b4f34dc4..4b2a44e205a8 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -13,6 +13,8 @@ export const ORCAD_BUN_RUNTIME_FILENAME = 'bun-runtime' export const ORCAD_WINDOWS_BUN_RUNTIME_FILENAME = 'bun-runtime.exe' export const ORCAD_BUILD_TARGET_FILENAME = '.build-target' export const ORCAD_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' +/** native/windows-cli-launcher: cmd.exe and .ps1 shims cannot carry every argv intact. */ +export const ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME = 'bin/orca.exe' export function orcadBunRuntimeFilename(target: string): string { return isWindowsTarget(target) ? ORCAD_WINDOWS_BUN_RUNTIME_FILENAME : ORCAD_BUN_RUNTIME_FILENAME @@ -205,7 +207,7 @@ export function orcadArtifactFilenames(target: string): string[] { ) filenames.push(...orcadNodePtyNativeArtifacts(target), orcadRipgrepArtifact(target)) if (isWindowsTarget(target)) { - filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME) + filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME, ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME) } return filenames }