Fix AppImage CLI launcher path

This commit is contained in:
Neil 2026-05-29 14:19:56 -07:00
parent f097c81e5e
commit 38feb52864
6 changed files with 327 additions and 10 deletions

View File

@ -125,6 +125,7 @@ module.exports = {
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName)
verifyPackagedMainRuntimeDeps(resourcesDir)
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
if (!filename.startsWith('agent-browser-')) {
continue
@ -286,6 +287,21 @@ module.exports = {
}
}
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
if (electronPlatformName === 'win32') {
return
}
for (const launcherName of ['orca', 'orca-ide']) {
const launcherPath = join(resourcesDir, 'bin', launcherName)
if (!existsSync(launcherPath)) {
continue
}
// Why: packaged Unix installs expose these extraResources as public shell
// commands, and source/packager mode drift must not ship a non-executable CLI.
chmodSync(launcherPath, 0o755)
}
}
async function signMacComputerUseHelper(helperAppPath, packager) {
if (!existsSync(helperAppPath)) {
if (isMacRelease) {

View File

@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -89,10 +89,7 @@ describe('electron-builder config', () => {
const sources = new Map([
['out\\main\\index.js', 'const z = require("zod")'],
[
'out\\main\\agent-hooks\\managed-agent-hook-controls.js',
'const YAML = require("yaml")'
]
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
@ -136,9 +133,9 @@ describe('electron-builder config', () => {
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
).resolves.toEqual([])
await expect(readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))).resolves.toEqual(
[]
)
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
).resolves.toEqual([])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
@ -197,4 +194,27 @@ describe('electron-builder config', () => {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux'
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})

View File

@ -1,5 +1,5 @@
import { execFile } from 'node:child_process'
import { copyFile, chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { copyFile, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -62,7 +62,7 @@ describe('packaged CLI assets', () => {
await mkdir(launcherDir, { recursive: true })
await mkdir(cliDir, { recursive: true })
await copyFile(linuxLauncherAsset, launcherPath)
await chmod(launcherPath, 0o755)
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
await writeFile(cliPath, '', 'utf8')
await writeFile(
electronPath,

View File

@ -52,6 +52,7 @@ import {
patchPackagedProcessPath,
shouldInstallManagedHooks
} from './startup/configure-process'
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
@ -138,6 +139,14 @@ let automations: AutomationService | null = null
let keybindings: KeybindingService | null = null
let expectedRendererReload: { webContentsId: number; until: number } | null = null
const isServeMode = process.argv.includes('--serve')
const appImageCliRedirect = maybeRedirectAppImageCliLaunch({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
execPath: process.execPath
})
if (appImageCliRedirect.redirected) {
app.exit(appImageCliRedirect.status)
}
// Why: the store/runtime singletons live here in index.ts; injecting them keeps
// the rename orchestrator free of module-level state and unit-testable.

View File

@ -0,0 +1,92 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { getAppImageCliArgs, maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect'
const commandNames = ['status', 'terminal']
describe('AppImage CLI redirect', () => {
it('detects direct AppImage CLI commands', () => {
expect(
getAppImageCliArgs(
['orca-linux.AppImage', 'status', '--json'],
{ APPIMAGE: '/opt/orca' },
{
platform: 'linux',
isPackaged: true,
commandNames
}
)
).toEqual(['status', '--json'])
})
it('allows CLI global flags before the command', () => {
expect(
getAppImageCliArgs(
['orca-linux.AppImage', '--pairing-code', 'abc123', '--json', 'terminal', 'list'],
{
APPIMAGE: '/opt/orca'
},
{
platform: 'linux',
isPackaged: true,
commandNames
}
)
).toEqual(['--pairing-code', 'abc123', '--json', 'terminal', 'list'])
})
it('does not redirect normal desktop AppImage launches', () => {
expect(
getAppImageCliArgs(
['AppRun', '--no-sandbox', 'file:///tmp/example.txt'],
{
APPIMAGE: '/opt/orca'
},
{
platform: 'linux',
isPackaged: true,
commandNames
}
)
).toBeNull()
})
it('spawns the unpacked CLI entrypoint with Electron node mode', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-'))
const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js')
await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true })
await writeFile(cliEntryPath, '', 'utf8')
const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 }))
const result = maybeRedirectAppImageCliLaunch({
argv: ['orca-linux.AppImage', 'status', '--json'],
env: {
APPIMAGE: '/opt/orca/orca-linux.AppImage',
NODE_OPTIONS: '--inspect',
NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js'
},
platform: 'linux',
isPackaged: true,
resourcesPath: root,
execPath: '/opt/orca/orca-ide',
commandNames,
spawn: spawn as never
})
expect(result).toEqual({ redirected: true, status: 0 })
expect(spawn).toHaveBeenCalledWith('/opt/orca/orca-ide', [cliEntryPath, 'status', '--json'], {
env: expect.objectContaining({
APPIMAGE: '/opt/orca/orca-linux.AppImage',
ELECTRON_RUN_AS_NODE: '1',
ORCA_NODE_OPTIONS: '--inspect',
ORCA_NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js'
}),
stdio: 'inherit'
})
const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined
expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS')
expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE')
})
})

View File

@ -0,0 +1,180 @@
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
type RedirectResult =
| {
redirected: false
}
| {
redirected: true
status: number
}
type RedirectOptions = {
argv?: string[]
env?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
isPackaged?: boolean
resourcesPath?: string
execPath?: string
commandNames?: readonly string[]
spawn?: typeof spawnSync
}
const HELP_FLAGS = new Set(['--help', '-h', 'help'])
const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox'])
const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code'])
// Why: the main tsconfig cannot import the CLI project, but AppImage direct
// launches need a conservative allow-list before bypassing the GUI startup.
const APPIMAGE_CLI_COMMAND_NAMES = [
'agent',
'automations',
'back',
'capture',
'check',
'clear',
'click',
'clipboard',
'computer',
'console',
'cookie',
'dblclick',
'dialog',
'download',
'drag',
'environment',
'eval',
'exec',
'file',
'fill',
'find',
'focus',
'forward',
'full-screenshot',
'geolocation',
'get',
'goto',
'highlight',
'hover',
'inserttext',
'intercept',
'is',
'keypress',
'mouse',
'network',
'open',
'orchestration',
'pdf',
'reload',
'repo',
'screenshot',
'scroll',
'scrollintoview',
'select',
'select-all',
'serve',
'set',
'snapshot',
'status',
'storage',
'tab',
'terminal',
'type',
'uncheck',
'upload',
'viewport',
'wait',
'worktree'
]
export function maybeRedirectAppImageCliLaunch(options: RedirectOptions = {}): RedirectResult {
const argv = options.argv ?? process.argv
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const isPackaged = options.isPackaged ?? false
const resourcesPath = options.resourcesPath ?? process.resourcesPath
const execPath = options.execPath ?? process.execPath
const spawn = options.spawn ?? spawnSync
const cliArgs = getAppImageCliArgs(argv, env, {
platform,
isPackaged,
commandNames: options.commandNames ?? APPIMAGE_CLI_COMMAND_NAMES
})
if (!cliArgs) {
return { redirected: false }
}
const cliEntryPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
if (!existsSync(cliEntryPath)) {
process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`)
return { redirected: true, status: 1 }
}
const childEnv = buildElectronRunAsNodeEnv(env)
const result = spawn(execPath, [cliEntryPath, ...cliArgs], {
env: childEnv,
stdio: 'inherit'
}) as SpawnSyncReturns<Buffer>
if (result.error) {
process.stderr.write(`${result.error.message}\n`)
return { redirected: true, status: 1 }
}
return { redirected: true, status: result.status ?? 1 }
}
export function getAppImageCliArgs(
argv: string[],
env: NodeJS.ProcessEnv,
options: {
platform: NodeJS.Platform
isPackaged: boolean
commandNames: readonly string[]
}
): string[] | null {
if (options.platform !== 'linux' || !options.isPackaged) {
return null
}
if (!env.APPIMAGE && !env.APPDIR) {
return null
}
const args = argv.slice(1)
if (args.length === 0 || args.some((arg) => APPIMAGE_DESKTOP_FLAGS.has(arg))) {
return null
}
if (args.some((arg) => HELP_FLAGS.has(arg))) {
return args
}
const commandNames = new Set(options.commandNames)
const firstPositional = findFirstCommandCandidate(args)
return firstPositional && commandNames.has(firstPositional) ? args : null
}
function findFirstCommandCandidate(args: string[]): string | null {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (!arg.startsWith('-')) {
return arg
}
const flagName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg
if (CLI_FLAGS_WITH_VALUES.has(flagName) && !arg.includes('=')) {
index += 1
}
}
return null
}
function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const childEnv = { ...env }
childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? ''
childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? ''
childEnv.ELECTRON_RUN_AS_NODE = '1'
delete childEnv.NODE_OPTIONS
delete childEnv.NODE_REPL_EXTERNAL_MODULE
return childEnv
}