Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config/scripts/build-orcad.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
import {
buildOrcadEntry,
buildOrcadCli,
buildOrcadLauncher,
externalNativeAddons,
ORCAD_EXTERNAL_MODULES,
Expand All @@ -18,6 +19,7 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
Expand All @@ -31,6 +33,8 @@ import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
import {
ORCAD_EMOJI_SHORTCODE_DATASET,
ORCAD_CLI_ENTRY_FILENAME,
ORCAD_CLI_PACKAGE_FILENAME,
ORCAD_LAUNCHER_FILENAME,
ORCAD_SERVER_ENTRY_FILENAME,
ORCAD_NODE_PTY_DIR,
Expand Down Expand Up @@ -216,6 +220,12 @@ const childResults = await Promise.all(
)

const result = await buildOrcadEntry(SERVER_OUT_FILE)
const cliResult = await buildOrcadCli(join(OUT_DIR, ORCAD_CLI_ENTRY_FILENAME))
const { version: cliVersion } = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
writeFileSync(
join(OUT_DIR, ORCAD_CLI_PACKAGE_FILENAME),
`${JSON.stringify({ type: 'commonjs', private: true, version: cliVersion })}\n`
)
const launcherResult = await buildOrcadLauncher(OUT_FILE)

const output = Object.values(result.metafile.outputs).find(
Expand Down Expand Up @@ -244,6 +254,7 @@ function collectImporters(metafiles, matches) {
const metafiles = [
launcherResult.metafile,
result.metafile,
cliResult.metafile,
...childResults.map((child) => child.metafile)
]
const electronImporters = collectImporters(
Expand Down
15 changes: 15 additions & 0 deletions config/scripts/orcad-entry-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ export function buildOrcadEntry(outfile) {
})
}

export function buildOrcadCli(outfile) {
return build({
entryPoints: [join(root, 'src/cli/index.ts')],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile,
metafile: true,
minify: true,
sourcemap: false,
logLevel: 'error'
})
}

export function buildOrcadLauncher(outfile) {
const serverSha256 = createHash('sha256')
.update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME)))
Expand Down
16 changes: 16 additions & 0 deletions config/scripts/runtime-serve-terminal-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,22 @@ async function main() {
)
}
log('terminal round trip OK')
if (launch.env.ORCA_USER_DATA && process.platform !== 'win32') {
const cliNonce = randomBytes(8).toString('hex')
orca(pairingCode, [
'terminal',
'send',
'--terminal',
terminal.handle,
'--text',
`orca status && orca worktree ps --json && printf 'ORCA_CLI_%s\\n' '${cliNonce}'`,
'--enter'
])
if (!(await waitForNonce(pairingCode, terminal.handle, `ORCA_CLI_${cliNonce}`))) {
throw new Error('The managed terminal could not run the execution host CLI')
}
log('execution host CLI round trip OK')
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
} finally {
Expand Down
4 changes: 2 additions & 2 deletions src/main/cli/cli-command-privileged-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock('electron', () => ({
}))

import { CliInstaller } from './cli-installer'
import { buildUnixDevLauncher } from './cli-dev-launcher'
import { buildUnixCliLauncher } from './cli-dev-launcher'

const createdRoots: string[] = []
const protectedDirectories: string[] = []
Expand Down Expand Up @@ -140,7 +140,7 @@ describe.skipIf(process.platform !== 'darwin' || process.getuid?.() === 0)(
const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js')
await writeFile(
fixture.commandPath,
buildUnixDevLauncher('/Applications/Old.app/Contents/MacOS/Orca', oldCliPath, 'user-data')
buildUnixCliLauncher('/Applications/Old.app/Contents/MacOS/Orca', oldCliPath, 'user-data')
)
const foreignContent = 'foreign command written into the inspected inode'
let raced = false
Expand Down
15 changes: 12 additions & 3 deletions src/main/cli/cli-dev-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function ensureDevLauncher(args: {
const content =
args.platform === 'win32'
? buildWindowsDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
: buildUnixDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
: buildUnixCliLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
await writeFile(launcherPath, content, {
encoding: 'utf8',
mode: args.platform === 'win32' ? undefined : 0o755
Expand All @@ -49,11 +49,20 @@ export async function ensureDevLauncher(args: {
return launcherPath
}

export function buildUnixDevLauncher(
export function buildUnixCliLauncher(
execPathValue: string,
cliEntryPath: string,
userDataPath: string
userDataPath: string,
runtime: 'electron' | 'node' = 'electron'
): string {
if (runtime === 'node') {
return `#!/usr/bin/env sh
set -eu
export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)}
unset NODE_OPTIONS NODE_REPL_EXTERNAL_MODULE ELECTRON_RUN_AS_NODE
exec ${quoteShell(execPathValue)} ${quoteShell(cliEntryPath)} "$@"
`
}
return `#!/usr/bin/env bash
set -euo pipefail
ELECTRON=${quoteShell(execPathValue)}
Expand Down
32 changes: 31 additions & 1 deletion src/main/cli/orca-cli-child-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'

const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() }))
vi.mock('./linux-terminal-orca-cli-shim', () => shim)
Expand All @@ -11,11 +12,40 @@ const RESOURCES = '/app/Resources'
const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim')

beforeEach(() => {
installFakeAppEnvironment()
shim.ensureLinuxTerminalOrcaCliShimDir.mockReset()
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR)
})

afterEach(() => installFakeAppEnvironment())

describe('prependOrcaCliDirToChildPath', () => {
it.each(['linux', 'darwin'] as const)(
'uses the %s execution host CLI and profile over inherited values',
(platform) => {
const launcher = join(USER_DATA, 'cli', 'bin', 'orca')
installFakeAppEnvironment({ getCliLauncherPath: () => launcher })
const env: Record<string, string> = {
PATH: `/usr/bin:${join(USER_DATA, 'cli', 'bin')}::/bin`,
ORCA_USER_DATA_PATH: '/parent/profile',
ORCA_CLI_COMMAND: '/parent/orca',
ORCA_CLI_BIN_DIR: '/parent/bin'
}
expect(
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
resourcesPath: null,
platform
})
).toBe(launcher)
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin:/bin`)
expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA)
expect(env.ORCA_CLI_COMMAND).toBe(launcher)
expect(env.ORCA_CLI_BIN_DIR).toBe(join(USER_DATA, 'cli', 'bin'))
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
}
)
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.
Expand Down
17 changes: 16 additions & 1 deletion src/main/cli/orca-cli-child-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* every branch behaves exactly as it did inside `buildPtyHostEnv`.
*/

import { delimiter, join } from 'node:path'
import { delimiter, dirname, join } from 'node:path'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { readInheritedPath } from '../ipc/pty/host-env/path'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim'
Expand Down Expand Up @@ -43,6 +44,20 @@ export function prependOrcaCliDirToChildPath(
// Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
// drives a foreign platform through the seam.
const pathDelimiter = platform === 'win32' ? ';' : delimiter
const hostLauncher = hasAppEnvironment() ? getAppEnvironment().getCliLauncherPath?.() : null
if (hostLauncher) {
const binDir = dirname(hostLauncher)
const inheritedEntries = readInheritedPath(env, platform)
.split(pathDelimiter)
.filter((entry) => entry.length > 0 && entry !== binDir)
env[resolvePathEnvKey(env, platform)] = [binDir, ...inheritedEntries].join(pathDelimiter)
if (platform !== 'win32') {
env.ORCA_CLI_BIN_DIR = binDir
}
env.ORCA_USER_DATA_PATH = opts.userDataPath
env.ORCA_CLI_COMMAND = hostLauncher
return hostLauncher
}
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
Expand Down
96 changes: 96 additions & 0 deletions src/main/orcad/orcad-cli-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mkdtemp, mkdir, readFile, 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 '../../shared/child-process/run-process'
import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts'
import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher'

const roots = vi.hoisted(() => ({ install: '', profile: '' }))
vi.mock('./orcad-app-paths', () => ({
resolveOrcadInstallRoot: () => roots.install,
resolveUserDataPath: () => roots.profile
}))

let directory = ''
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')!

beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-node-cli-'))
roots.install = join(directory, "server ' slot")
roots.profile = join(directory, "profile ' data")
})

afterEach(async () => {
Object.defineProperty(process, 'platform', platformDescriptor)
await rm(directory, { recursive: true, force: true })
})

async function writeCli(): Promise<void> {
const path = join(roots.install, ...ORCAD_CLI_ENTRY_FILENAME.split('/'))
await mkdir(dirname(path), { recursive: true })
await writeFile(
path,
'console.log(JSON.stringify({profile:process.env.ORCA_USER_DATA_PATH, args:process.argv.slice(2), electron:process.env.ELECTRON_RUN_AS_NODE}))'
)
}

describe('orcad profile CLI launcher', () => {
it.skipIf(process.platform === 'win32')(
'launches pinned Node with intact arguments and the execution host profile',
async () => {
await writeCli()
await prepareOrcadCliLauncher()
const launcher = getOrcadCliLauncherPath()
expect(launcher).toBe(join(roots.profile, 'cli', 'bin', 'orca'))
if (!launcher) {
throw new Error('CLI launcher is missing')
}
const args = ['orchestration', 'send', 'a body\nwith "quotes" and $HOME']
const result = await runProcess({
program: launcher,
args,
env: {
...process.env,
ORCA_USER_DATA_PATH: '/another/owner',
ELECTRON_RUN_AS_NODE: '1',
NODE_OPTIONS: '--require /missing/inherited/preload'
},
timeoutMs: 5000
})
expect(result.code).toBe(0)
expect(JSON.parse(result.stdout)).toEqual({ profile: roots.profile, args })
}
)

it.skipIf(process.platform === 'win32')(
'refreshes the launcher when the host switches server slots',
async () => {
await writeCli()
await prepareOrcadCliLauncher()
roots.install = join(directory, 'updated server')
await writeCli()
await prepareOrcadCliLauncher()
const launcher = getOrcadCliLauncherPath()
if (!launcher) {
throw new Error('CLI launcher is missing')
}
expect(await readFile(launcher, 'utf8')).toContain(roots.install)
}
)

it('leaves old slots without a CLI usable and clears a previous launcher', async () => {
await writeCli()
await prepareOrcadCliLauncher()
roots.install = join(directory, 'old server')
await prepareOrcadCliLauncher()
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()
})
})
36 changes: 36 additions & 0 deletions src/main/orcad/orcad-cli-launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { chmod, mkdir, readFile } 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 { buildUnixCliLauncher } from '../cli/cli-dev-launcher'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths'

let launcherPath: string | null = null

export function getOrcadCliLauncherPath(): string | null {
return launcherPath
}

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('/'))
// 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 script = buildUnixCliLauncher(process.execPath, entry, userDataPath, 'node')
await mkdir(dirname(path), { recursive: true })
const current = await readFile(path, 'utf8').catch(() => null)
if (current !== script) {
writeFileAtomically(path, script, { mode: 0o700 })
}
await chmod(path, 0o700)
launcherPath = path
}
6 changes: 6 additions & 0 deletions src/main/orcad/orcad-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
import type { ServeReadiness } from '../server/serve-readiness'
import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths'
import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher'
import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address'
import {
flushOrcadProfileStoreForShutdown,
Expand Down Expand Up @@ -66,6 +67,7 @@ function createNodeAppEnvironment(): AppEnvironment {
// posture. Layout questions must ask whether the app root is an asar archive
// instead (see parcel-watcher-entry-path.ts).
isPackaged: () => true,
getCliLauncherPath: getOrcadCliLauncherPath,
onWillQuit: (handler) => quitHandlers.push(handler),
exit: (code = 0) => process.exit(code),
// Why []: there are no Chromium processes on this host to measure.
Expand Down Expand Up @@ -199,6 +201,10 @@ async function startOrcadRuntime(
const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin')

const runtimeUserDataPath = getAppEnvironment().getPath('userData')
// A missing `orca` command must never keep the server from starting.
await prepareOrcadCliLauncher().catch((error: unknown) => {
console.warn('[orcad] Could not prepare the profile CLI launcher', error)
})
const idleExitStartup = beginOrcadIdleExit(runtimeUserDataPath)
const { store: profileStore, authority: profileStateAuthority } =
await createOrcadProfileStateStartup(runtimeUserDataPath)
Expand Down
Loading
Loading