fix(orcad): provide the orca CLI in Windows managed-host terminals (#27324)

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 17:18:01 -07:00
committed by GitHub
co-authored by m4air
parent ee8cd8e58f
commit 77d5de1f6c
19 changed files with 539 additions and 24 deletions
@@ -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/<arch>/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 }
}
+3
View File
@@ -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: |
+19
View File
@@ -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: |
+1
View File
@@ -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
+8
View File
@@ -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:
@@ -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: |
+2
View File
@@ -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(
+24 -3
View File
@@ -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))
)
@@ -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 {
@@ -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',
@@ -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}`)
}
}
@@ -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()
})
+1
View File
@@ -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',
+62 -2
View File
@@ -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)),
+24
View File
@@ -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<string, string> = {
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.
+63 -8
View File
@@ -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<void> {
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<string> =>
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()
})
})
})
+71 -10
View File
@@ -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<void> {
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<void> {
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<string | null> {
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<void> {
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(() => {}))
)
}
@@ -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({
+3 -1
View File
@@ -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
}