mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(orcad): provide the orca CLI in Windows managed-host terminals (#27324)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user