Files
orca/src/main/cli/cli-installer.ts
T
Jinjing 6729f683f6 Fix Orca CLI path resolution and improve registration UI (#6484)
* fix Orca CLI path resolution and improve registration UI

- Hydrate the shell PATH before running installer actions on macOS to
  match the user's terminal instead of launchd.
- Resolve the active macOS command path dynamically against shell PATH
  candidates, ignoring broken symlinks and non-executable files.
- Detect and report conflicts with unmanaged executables shadowing the
  configured command path.
- Add "Preparing..." and "Registering..." loading states to the settings
  and setup panels during preflight checks.

* Fix Orca CLI detection and installation path conflict checks

- Allow detecting a managed Orca command located later in PATH when the
  default path entry is missing or non-executable.
- Avoid raising installation conflicts for unmanaged commands located
  after the default install path, as they will be safely shadowed.
- Translate terminal setup and Orca CLI registration strings for
  Spanish, Japanese, Korean, and Chinese locales.

* Only open terminal when pre-install checks succeed

Prevent the terminal from opening and setting the terminal command if
onBeforeOpenTerminal or refreshPreInstallNotice throws an error.
2026-06-27 02:05:23 -07:00

1144 lines
38 KiB
TypeScript

/* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */
import { app } from 'electron'
import { execFile } from 'node:child_process'
import { constants, existsSync } from 'node:fs'
import {
access,
lstat,
mkdir,
readFile,
readlink,
stat,
symlink,
unlink,
writeFile
} from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { promisify } from 'node:util'
import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types'
import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
const execFileAsync = promisify(execFile)
const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca'
const DEV_COMMAND_NAME = 'orca-dev'
const LINUX_COMMAND_NAME = 'orca-ide'
const LEGACY_LINUX_COMMAND_NAME = 'orca'
const DEV_LAUNCHER_DIR = ['cli', 'bin']
const WINDOWS_PATH_COMMAND_TIMEOUT_MS = 5_000
type CliInstallerOptions = {
platform?: NodeJS.Platform
isPackaged?: boolean
userDataPath?: string
resourcesPath?: string
execPath?: string
appPath?: string
homePath?: string
localAppDataPath?: string
processPathEnv?: string | null
commandPathOverride?: string | null
/** Feeds into the /usr/local/bin existence check at construction time; used in tests to simulate absent /usr/local/bin on arm64 without relying on real filesystem state. */
defaultMacCommandPath?: string
privilegedRunner?: (command: string) => Promise<void>
userPathReader?: () => Promise<string | null>
userPathWriter?: (value: string) => Promise<void>
/** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */
appImagePath?: string | null
}
type InstallSpec = {
commandPath: string
installMethod: CliInstallMethod
}
export class CliInstaller {
private readonly platform: NodeJS.Platform
private readonly isPackaged: boolean
private readonly userDataPath: string
private readonly resourcesPath: string
private readonly execPathValue: string
private readonly appPathValue: string
private readonly homePath: string
private readonly localAppDataPath: string
private readonly processPathEnv: string | null
private readonly commandPathOverride: string | null
private readonly macCommandPath: string
private readonly privilegedRunner: (command: string) => Promise<void>
private readonly userPathReader: () => Promise<string | null>
private readonly userPathWriter: (value: string) => Promise<void>
private readonly appImagePath: string | null
private get commandName(): string {
if (!this.isPackaged && !this.commandPathOverride) {
// Why: development builds must not claim the production shell command.
return DEV_COMMAND_NAME
}
// Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca.
return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca'
}
constructor(options: CliInstallerOptions = {}) {
this.platform = options.platform ?? process.platform
this.isPackaged = options.isPackaged ?? app.isPackaged
this.userDataPath = options.userDataPath ?? app.getPath('userData')
this.resourcesPath = options.resourcesPath ?? process.resourcesPath
this.execPathValue = options.execPath ?? process.execPath
this.appPathValue = options.appPath ?? app.getAppPath()
this.homePath = options.homePath ?? homedir()
this.localAppDataPath =
options.localAppDataPath ??
process.env.LOCALAPPDATA ??
join(this.homePath, 'AppData', 'Local')
this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null
this.commandPathOverride =
options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null
// Why: resolved once at construction — existsSync must not run on every
// getStatus() call (hot path). /usr/local/bin is absent by default on Apple
// Silicon Macs (Homebrew moved to /opt/homebrew); fall back to ~/.local/bin
// which is user-writable, requires no elevated permissions, and is the
// XDG-standard user bin dir already on PATH via shell init on arm64.
// defaultMacCommandPath is a test seam: it feeds into the existence check
// so tests can simulate arm64 without relying on the real /usr/local/bin.
const candidateMacPath = options.defaultMacCommandPath ?? DEFAULT_MAC_COMMAND_PATH
this.macCommandPath = existsSync(dirname(candidateMacPath))
? candidateMacPath
: join(this.homePath, '.local', 'bin', 'orca')
this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand
this.userPathReader = options.userPathReader ?? (() => readWindowsUserPath())
this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value))
this.appImagePath =
this.platform === 'linux' && this.isPackaged
? (options.appImagePath ?? process.env.APPIMAGE ?? null)
: null
}
async getStatus(): Promise<CliInstallStatus> {
const defaultSpec = this.resolveInstallSpec()
if (!defaultSpec) {
return {
platform: this.platform,
commandName: this.commandName,
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason: 'platform_not_supported',
detail: 'CLI registration is not implemented on this platform.'
}
}
const launcherPath = await this.resolveLauncherPath()
if (!launcherPath) {
const detail =
this.isLinuxAppImage() && this.appImagePath
? `The AppImage file at ${this.appImagePath} is missing. Move it back or re-run CLI registration from the current AppImage location.`
: this.isPackaged
? 'The bundled CLI launcher is missing from this Orca build.'
: 'Development mode uses a generated launcher for validation only.'
return {
platform: this.platform,
commandName: this.commandName,
commandPath: defaultSpec.commandPath,
pathDirectory: dirname(defaultSpec.commandPath),
pathConfigured: false,
launcherPath: null,
installMethod: defaultSpec.installMethod,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason: this.isPackaged ? 'launcher_missing' : 'launch_mode_unavailable',
detail
}
}
const spec = await this.resolveActiveInstallSpec(defaultSpec, launcherPath)
const baseStatus =
spec.installMethod === 'symlink'
? await this.inspectSymlink(spec.commandPath, launcherPath)
: this.isLinuxAppImage()
? await this.inspectAppImageWrapper(spec.commandPath, launcherPath)
: await this.inspectWindowsWrapper(spec.commandPath, launcherPath)
const pathDirectory = dirname(spec.commandPath)
const pathConfigured = await this.isPathConfigured(pathDirectory)
return this.withPathInfo(baseStatus, pathDirectory, pathConfigured)
}
async install(): Promise<CliInstallStatus> {
const status = await this.getStatus()
if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) {
throw new Error(status.detail ?? 'CLI registration is unavailable on this build.')
}
if (status.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
// eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary.
if (status.installMethod === 'symlink') {
await this.installSymlink(status)
await this.removeLegacyLinuxCommandIfManaged(status.launcherPath)
} else if (this.isLinuxAppImage()) {
await this.installAppImageWrapper(status.commandPath, status.launcherPath)
await this.removeLegacyLinuxCommandIfManaged(status.launcherPath)
} else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) {
// Why: packaged Windows already ships resources/bin/orca.cmd. Registration
// only owns the user PATH entry; rewriting the asset makes it recurse.
} else {
// Why: mkdir stays here for the Windows wrapper path — the target dir is
// user-writable (%LOCALAPPDATA%) so EACCES cannot occur. The symlink path
// handles its own mkdir inside installSymlink so EACCES triggers the
// privileged-runner instead of propagating as an unhandled rejection.
await mkdir(dirname(status.commandPath), { recursive: true })
await this.installWindowsWrapper(status.commandPath, status.launcherPath)
}
if (this.platform === 'win32') {
// Why: Windows shells discover commands via the user PATH, not by walking
// arbitrary app install directories. The CLI installer therefore owns the
// user-scoped PATH entry instead of assuming the desktop installer did it.
await this.ensureWindowsPathEntry(dirname(status.commandPath))
}
return this.getStatus()
}
async remove(): Promise<CliInstallStatus> {
const status = await this.getStatus()
if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) {
return status
}
if (status.state === 'not_installed') {
await this.removeLegacyLinuxCommandIfManaged(status.launcherPath)
if (this.platform === 'win32') {
await this.removeWindowsPathEntry(dirname(status.commandPath))
return this.getStatus()
}
return status
}
if (status.state === 'conflict') {
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
}
if (status.state === 'stale') {
throw new Error(`Refusing to remove a command not owned by Orca at ${status.commandPath}.`)
}
if (status.installMethod === 'symlink') {
await this.removeSymlink(status.commandPath)
await this.removeLegacyLinuxCommandIfManaged(status.launcherPath)
} else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) {
await this.removeWindowsPathEntry(dirname(status.commandPath))
} else {
await unlink(status.commandPath)
await this.removeWindowsPathEntry(dirname(status.commandPath))
}
return this.getStatus()
}
private resolveInstallSpec(): InstallSpec | null {
const commandPath = this.resolveCommandPath()
if (!commandPath) {
return null
}
if (this.platform === 'darwin' || this.platform === 'linux') {
return {
commandPath,
installMethod: this.isLinuxAppImage() ? 'wrapper' : 'symlink'
}
}
if (this.platform === 'win32') {
return {
commandPath,
installMethod: 'wrapper'
}
}
return null
}
private async resolveActiveInstallSpec(
defaultSpec: InstallSpec,
launcherPath: string
): Promise<InstallSpec> {
if (
this.commandPathOverride ||
this.platform !== 'darwin' ||
defaultSpec.installMethod !== 'symlink'
) {
return defaultSpec
}
const activeCommandPath = await this.findActivePathCommand(
launcherPath,
defaultSpec.commandPath
)
return activeCommandPath
? {
commandPath: activeCommandPath,
installMethod: defaultSpec.installMethod
}
: defaultSpec
}
private async findActivePathCommand(
launcherPath: string,
defaultCommandPath: string
): Promise<string | null> {
let reachedDefaultCommandPath = false
for (const commandPath of this.getPathCommandCandidates(defaultCommandPath)) {
const isDefaultCommandPath = samePathEntry(this.platform, commandPath, defaultCommandPath)
reachedDefaultCommandPath ||= isDefaultCommandPath
if (!(await isExecutableFile(commandPath))) {
continue
}
const status = await this.inspectSymlink(commandPath, launcherPath)
if (status.state !== 'not_installed') {
if (reachedDefaultCommandPath && !isDefaultCommandPath && status.state === 'conflict') {
// Why: a non-Orca command after an empty/default install slot can be
// shadowed safely by installing there; no user file needs replacing.
continue
}
// Why: PATH lookup is first-match-wins; use the executable command the
// shell will actually run, while preserving conflicts that shadow Orca.
return commandPath
}
}
return null
}
private getPathCommandCandidates(defaultCommandPath: string): string[] {
const commandName = basename(defaultCommandPath)
const pathCandidates = splitPathEntries(this.platform, this.processPathEnv ?? '').map((entry) =>
join(entry, commandName)
)
return uniquePathEntries(this.platform, pathCandidates)
}
private resolveCommandPath(): string | null {
if (this.commandPathOverride) {
return this.commandPathOverride
}
if (!this.isPackaged) {
// Why: default dev registration is a separate command, while tests and
// diagnostics can still exercise production paths via commandPathOverride.
if (this.platform === 'darwin') {
return `/usr/local/bin/${DEV_COMMAND_NAME}`
}
if (this.platform === 'linux') {
return join(this.homePath, '.local', 'bin', DEV_COMMAND_NAME)
}
if (this.platform === 'win32') {
return join(this.localAppDataPath, 'Programs', 'Orca Dev', 'bin', `${DEV_COMMAND_NAME}.cmd`)
}
}
if (this.platform === 'darwin') {
return this.macCommandPath
}
if (this.platform === 'linux') {
// Why: Linux does not have a single privileged global shell-command flow
// equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the
// least surprising user-scoped location that many distros already expose.
// Why `orca-ide`: GNOME Orca (the screen reader) ships /usr/bin/orca on
// most Linux distros. Using `orca-ide` avoids shadowing that system
// command, matching the executableName already used for the Electron binary.
return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME)
}
if (this.platform === 'win32') {
return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd')
}
return null
}
private async resolveLauncherPath(): Promise<string | null> {
if (!['darwin', 'linux', 'win32'].includes(this.platform)) {
return null
}
if (this.isLinuxAppImage()) {
return this.appImagePath && existsSync(this.appImagePath) ? this.appImagePath : null
}
if (this.isPackaged) {
const bundledPath = getBundledLauncherPath(this.platform, this.resourcesPath)
return bundledPath && existsSync(bundledPath) ? bundledPath : null
}
return ensureDevLauncher({
platform: this.platform,
userDataPath: this.userDataPath,
execPath: this.execPathValue,
cliEntryPath: join(this.appPathValue, 'out', 'cli', 'index.js'),
commandName: this.commandName
})
}
private async installSymlink(status: CliInstallStatus): Promise<void> {
try {
if (status.state === 'installed') {
return
}
if (status.state === 'stale') {
await unlink(status.commandPath as string)
}
// Why: mkdir is placed here (not in install()) so that an EACCES/EPERM
// failure — e.g. /usr/local/bin absent on Intel Mac — falls into the
// privileged-runner catch below instead of surfacing as an unhandled
// rejection that leaves Settings silently showing "not installed".
await mkdir(dirname(status.commandPath as string), { recursive: true })
await symlink(status.launcherPath as string, status.commandPath as string)
} catch (error) {
if (this.platform !== 'darwin' || !isPermissionError(error)) {
throw error
}
// Why: macOS shell-command registration should behave like VS Code and
// place a stable symlink in /usr/local/bin instead of rewriting shell rc
// files. Fallback to an elevated shell command keeps the public command
// stable even when the app lacks direct write access to that directory.
await this.privilegedRunner(
`mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` +
`ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}`
)
}
}
private async removeSymlink(commandPath: string): Promise<void> {
try {
await unlink(commandPath)
} catch (error) {
if (this.platform !== 'darwin' || !isPermissionError(error)) {
throw error
}
await this.privilegedRunner(
`if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi`
)
}
}
private async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise<void> {
if (this.platform !== 'linux' || this.commandPathOverride || !launcherPath) {
return
}
const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME)
try {
const stats = await lstat(legacyCommandPath)
if (!stats.isSymbolicLink()) {
return
}
const currentTarget = await readlink(legacyCommandPath)
const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget)
if (!this.isManagedLegacyLinuxTarget(resolvedCurrentTarget, launcherPath)) {
return
}
// Why: after the Linux command rename, the old Orca-owned `orca` symlink
// would keep shadowing GNOME Orca even though the new command is installed.
await unlink(legacyCommandPath)
} catch (error) {
if (isMissingError(error)) {
return
}
throw error
}
}
private isManagedLegacyLinuxTarget(resolvedTarget: string, launcherPath: string): boolean {
const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME)
if (resolvedTarget === legacyLauncherPath) {
return true
}
if (basename(resolvedTarget) !== LEGACY_LINUX_COMMAND_NAME) {
return false
}
const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR)
const devRelative = relative(devLauncherDir, resolvedTarget)
if (devRelative && !devRelative.startsWith('..') && !isAbsolute(devRelative)) {
return true
}
// Why: AppImage upgrades can leave a legacy symlink into a now-gone FUSE
// mount; the stable AppImage path is not a sibling of that old target.
return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget)
}
private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise<void> {
await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8')
}
private async installAppImageWrapper(commandPath: string, appImagePath: string): Promise<void> {
// Why: unlike macOS symlink install, AppImage uses the user-writable Linux
// command dir and must create it before writing the wrapper file.
await mkdir(dirname(commandPath), { recursive: true })
await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), {
encoding: 'utf8',
mode: 0o755
})
}
private async inspectAppImageWrapper(
commandPath: string,
appImagePath: string
): Promise<CliInstallStatus> {
try {
const stats = await lstat(commandPath)
if (!stats.isFile()) {
return this.buildStatus({
commandPath,
launcherPath: appImagePath,
installMethod: 'wrapper',
supported: true,
state: 'conflict',
currentTarget: null,
detail: `${commandPath} exists but is not an Orca launcher script.`
})
}
const currentContent = await readFile(commandPath, 'utf8')
const expectedContent = buildAppImageCliWrapper(appImagePath)
return this.buildStatus({
commandPath,
launcherPath: appImagePath,
installMethod: 'wrapper',
supported: true,
state: currentContent === expectedContent ? 'installed' : 'stale',
currentTarget: appImagePath,
detail:
currentContent === expectedContent
? `Registered at ${commandPath}.`
: `${commandPath} points to a different launcher.`
})
} catch (error) {
if (isMissingError(error)) {
return this.buildStatus({
commandPath,
launcherPath: appImagePath,
installMethod: 'wrapper',
supported: true,
state: 'not_installed',
currentTarget: null,
detail: `Register ${commandPath} to use Orca from the terminal.`
})
}
throw error
}
}
private async inspectSymlink(
commandPath: string,
launcherPath: string
): Promise<CliInstallStatus> {
try {
const stats = await lstat(commandPath)
if (!stats.isSymbolicLink()) {
if (stats.isFile()) {
const currentContent = await readFile(commandPath, 'utf8')
const managedTarget = extractManagedUnixLauncherTarget(currentContent)
if (managedTarget) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: 'stale',
currentTarget: managedTarget,
detail: `${commandPath} contains an older Orca launcher.`
})
}
}
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: 'conflict',
currentTarget: null,
detail: `${commandPath} exists but is not an Orca symlink.`
})
}
const currentTarget = await readlink(commandPath)
const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget)
const resolvedLauncher = resolve(launcherPath)
const isInstalled = resolvedCurrentTarget === resolvedLauncher
const isManagedStaleTarget =
!isInstalled && this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath)
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: isInstalled ? 'installed' : isManagedStaleTarget ? 'stale' : 'conflict',
currentTarget: resolvedCurrentTarget,
detail: isInstalled
? `Registered at ${commandPath}.`
: isManagedStaleTarget
? `${commandPath} points to an older Orca launcher.`
: `${commandPath} points to a non-Orca launcher.`
})
} catch (error) {
if (isMissingError(error)) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'symlink',
supported: true,
state: 'not_installed',
currentTarget: null,
detail: `Register ${commandPath} to use Orca from the terminal.`
})
}
throw error
}
}
private isManagedSymlinkTarget(resolvedTarget: string, launcherPath: string): boolean {
const expectedName = basename(launcherPath)
if (this.isPackaged && this.isSiblingDevLauncherTarget(resolvedTarget, expectedName)) {
return true
}
if (basename(resolvedTarget) !== expectedName) {
return false
}
const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR)
if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) {
return true
}
if (this.platform === 'darwin') {
// Why: prior packaged installs can leave a symlink to an older Orca.app
// resources launcher, but arbitrary user-owned symlinks must not be replaced.
return /(?:^|[/\\])[^/\\]+\.app[/\\]Contents[/\\]Resources[/\\]bin[/\\][^/\\]+$/.test(
resolvedTarget
)
}
if (this.platform === 'linux') {
return /(?:^|[/\\])resources[/\\]bin[/\\][^/\\]+$/.test(resolvedTarget)
}
return false
}
private isSiblingDevLauncherTarget(
resolvedTarget: string,
packagedLauncherName: string
): boolean {
if (![packagedLauncherName, DEV_COMMAND_NAME].includes(basename(resolvedTarget))) {
return false
}
const packagedUserDataPath = resolve(this.userDataPath)
const siblingDevUserDataPath = `${packagedUserDataPath}-dev`
const siblingDevLauncherDir = resolve(siblingDevUserDataPath, ...DEV_LAUNCHER_DIR)
// Why: development builds generate launchers under the sibling `*-dev`
// profile; packaged Orca must be able to reclaim that public command.
return (
basename(siblingDevUserDataPath) === `${basename(packagedUserDataPath)}-dev` &&
isPathInsideOrEqual(siblingDevLauncherDir, resolvedTarget)
)
}
private isLinuxAppImage(): boolean {
return this.platform === 'linux' && Boolean(this.appImagePath)
}
private isWindowsPackagedBundledCommand(
commandPath: string | null,
launcherPath: string | null
): commandPath is string {
return (
this.platform === 'win32' &&
this.isPackaged &&
commandPath !== null &&
launcherPath !== null &&
samePathEntry('win32', commandPath, launcherPath)
)
}
private async inspectWindowsWrapper(
commandPath: string,
launcherPath: string
): Promise<CliInstallStatus> {
try {
const stats = await lstat(commandPath)
if (!stats.isFile()) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: 'conflict',
currentTarget: null,
detail: `${commandPath} exists but is not an Orca launcher script.`
})
}
if (this.isWindowsPackagedBundledCommand(commandPath, launcherPath)) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: 'installed',
currentTarget: launcherPath,
detail: `Registered at ${commandPath}.`
})
}
const currentContent = await readFile(commandPath, 'utf8')
const expectedContent = buildWindowsForwarder(launcherPath)
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: currentContent === expectedContent ? 'installed' : 'stale',
currentTarget: launcherPath,
detail:
currentContent === expectedContent
? `Registered at ${commandPath}.`
: `${commandPath} points to a different launcher.`
})
} catch (error) {
if (isMissingError(error)) {
return this.buildStatus({
commandPath,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: 'not_installed',
currentTarget: null,
detail: `Register ${commandPath} to use Orca from Command Prompt or PowerShell.`
})
}
throw error
}
}
private buildStatus(args: {
commandPath: string
launcherPath: string
installMethod: CliInstallMethod
supported: boolean
state: CliInstallStatus['state']
currentTarget: string | null
detail: string | null
}): CliInstallStatus {
return {
platform: this.platform,
commandName: this.commandName,
commandPath: args.commandPath,
pathDirectory: dirname(args.commandPath),
pathConfigured: false,
launcherPath: args.launcherPath,
installMethod: args.installMethod,
supported: args.supported,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail: args.detail
}
}
private async isPathConfigured(pathDirectory: string): Promise<boolean> {
const pathValue =
this.platform === 'win32' ? await this.userPathReader() : (this.processPathEnv ?? '')
return splitPathEntries(this.platform, pathValue).some((entry) =>
samePathEntry(this.platform, entry, pathDirectory)
)
}
private withPathInfo(
status: CliInstallStatus,
pathDirectory: string,
pathConfigured: boolean
): CliInstallStatus {
if (
this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath) &&
status.state === 'installed' &&
!pathConfigured
) {
return {
...status,
pathDirectory,
pathConfigured,
state: 'not_installed',
currentTarget: null,
detail: `Register ${status.commandPath} to use Orca from Command Prompt or PowerShell.`
}
}
if (status.state !== 'installed') {
return {
...status,
pathDirectory,
pathConfigured
}
}
if (pathConfigured) {
return {
...status,
pathDirectory,
pathConfigured
}
}
return {
...status,
pathDirectory,
pathConfigured,
detail:
this.platform === 'linux'
? `${status.commandPath} is registered, but ${pathDirectory} is not on PATH for this shell.`
: `${status.commandPath} is registered. Restart your shell if the command is not visible yet.`
}
}
private async ensureWindowsPathEntry(pathDirectory: string): Promise<void> {
const current = await this.userPathReader()
const entries = splitPathEntries('win32', current)
if (entries.some((entry) => samePathEntry('win32', entry, pathDirectory))) {
return
}
entries.push(pathDirectory)
await this.userPathWriter(entries.join(';'))
}
private async removeWindowsPathEntry(pathDirectory: string): Promise<void> {
if (this.platform !== 'win32') {
return
}
const current = await this.userPathReader()
const nextEntries = splitPathEntries('win32', current).filter(
(entry) => !samePathEntry('win32', entry, pathDirectory)
)
await this.userPathWriter(nextEntries.join(';'))
}
}
async function ensureDevLauncher(args: {
platform: NodeJS.Platform
userDataPath: string
execPath: string
cliEntryPath: string
commandName: string
}): Promise<string | null> {
if (
!isAbsoluteForPlatform(args.platform, args.execPath) ||
!isAbsolute(args.cliEntryPath) ||
!existsSync(args.cliEntryPath)
) {
return null
}
const launcherPath = join(
args.userDataPath,
...DEV_LAUNCHER_DIR,
args.platform === 'win32' ? `${args.commandName}.cmd` : args.commandName
)
await mkdir(dirname(launcherPath), { recursive: true })
// Why: packaged Orca ships real platform launchers under resources/bin, but
// development builds do not have that stable asset layout. Generating a
// launcher in userData lets us validate the shell-command flow without
// changing the packaged registration contract.
const content =
args.platform === 'win32'
? buildWindowsDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
: buildUnixDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
await writeFile(launcherPath, content, {
encoding: 'utf8',
mode: args.platform === 'win32' ? undefined : 0o755
})
if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') {
// Why: dev PTYs prepend userData/cli/bin to PATH, and product-owned
// commands are documented as `orca ...`. Keep that local alias fresh
// without claiming the global production command.
await writeFile(join(dirname(launcherPath), 'orca'), content, {
encoding: 'utf8',
mode: 0o755
})
}
return launcherPath
}
function buildUnixDevLauncher(
execPathValue: string,
cliEntryPath: string,
userDataPath: string
): string {
return `#!/usr/bin/env bash
set -euo pipefail
ELECTRON=${quoteShell(execPathValue)}
CLI=${quoteShell(cliEntryPath)}
export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)}
if [ -z "\${ORCA_APP_EXECUTABLE:-}" ]; then
export ORCA_APP_EXECUTABLE="$ELECTRON"
export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1
fi
export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}"
export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"
`
}
function buildWindowsDevLauncher(
execPathValue: string,
cliEntryPath: string,
userDataPath: string
): string {
return `@echo off
setlocal
set "ELECTRON=${escapeWindowsBatchValue(execPathValue)}"
set "CLI=${escapeWindowsBatchValue(cliEntryPath)}"
set "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"
if not defined ORCA_APP_EXECUTABLE (
set "ORCA_APP_EXECUTABLE=%ELECTRON%"
set "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"
)
set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%"
set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%"
set NODE_OPTIONS=
set NODE_REPL_EXTERNAL_MODULE=
set ELECTRON_RUN_AS_NODE=1
"%ELECTRON%" "%CLI%" %*
`
}
function buildWindowsForwarder(launcherPath: string): string {
return `@echo off
setlocal
set "ORCA_LAUNCHER=${escapeWindowsBatchValue(launcherPath)}"
"%ORCA_LAUNCHER%" %*
`
}
function extractManagedUnixLauncherTarget(content: string): string | null {
if (
!content.includes('ELECTRON_RUN_AS_NODE=1') ||
!content.includes('ORCA_NODE_OPTIONS') ||
!content.includes('NODE_REPL_EXTERNAL_MODULE')
) {
return null
}
const cliPath = extractShellAssignment(content, 'CLI')
if (!cliPath) {
return null
}
// Why: older dev installs wrote a generated shell launcher directly to
// /usr/local/bin/orca. Treat only Orca's compiled CLI entrypoints as managed;
// arbitrary user scripts that happen to launch Electron must stay conflicts.
return /(?:^|[/\\])(?:out|app\.asar\.unpacked[/\\]out)[/\\]cli[/\\]index\.js$/.test(cliPath)
? cliPath
: null
}
function extractShellAssignment(content: string, name: string): string | null {
const match = new RegExp(`^${name}=('([^']*)'|"([^"]*)"|([^\\n]+))$`, 'm').exec(content)
if (!match) {
return null
}
return (match[2] ?? match[3] ?? match[4] ?? '').trim()
}
function splitPathEntries(platform: NodeJS.Platform, value: string | null): string[] {
if (!value) {
return []
}
return value
.split(platform === 'win32' ? ';' : ':')
.map((entry) => entry.trim())
.filter(Boolean)
}
function uniquePathEntries(platform: NodeJS.Platform, entries: string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const entry of entries) {
const key = platform === 'win32' ? normalizeWindowsPath(entry) : entry
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(entry)
}
return result
}
function samePathEntry(platform: NodeJS.Platform, left: string, right: string): boolean {
return platform === 'win32'
? normalizeWindowsPath(left) === normalizeWindowsPath(right)
: left === right
}
function isPathInsideOrEqual(parentPath: string, childPath: string): boolean {
const childRelative = relative(parentPath, childPath)
return childRelative === '' || (!childRelative.startsWith('..') && !isAbsolute(childRelative))
}
async function isExecutableFile(commandPath: string): Promise<boolean> {
try {
const stats = await stat(commandPath)
if (!stats.isFile()) {
return false
}
await access(commandPath, constants.X_OK)
return true
} catch {
return false
}
}
function normalizeWindowsPath(value: string): string {
return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase()
}
function escapeWindowsBatchValue(value: string): string {
return value.replaceAll('"', '""')
}
function isPermissionError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
)
}
function isMissingError(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
async function runMacPrivilegedCommand(command: string): Promise<void> {
await execFileAsync('osascript', [
'-e',
`do shell script ${quoteAppleScript(command)} with administrator privileges`
])
}
function quoteAppleScript(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
}
function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolean {
if (platform === 'win32') {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
}
return isAbsolute(value)
}
async function readWindowsUserPath(): Promise<string | null> {
const stdout = await runWindowsPathCommand([
'-NoProfile',
'-Command',
"[Environment]::GetEnvironmentVariable('Path','User')"
])
return stdout.trim() || null
}
async function writeWindowsUserPath(value: string): Promise<void> {
await runWindowsPathCommand([
'-NoProfile',
'-Command',
// Why: PATH registration must stay user-scoped on Windows so the Orca
// desktop app can manage the public shell command without requiring
// elevation or mutating machine-wide environment state.
`[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')`
])
}
function runWindowsPathCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error: Error | null, stdout = ''): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
reject(error)
return
}
resolve(stdout)
}
// Why: Windows PATH reads/writes back CLI Settings; wedged PowerShell must
// not keep command registration status or install/remove pending forever.
const timeout = setTimeout(() => {
child?.kill()
finish(
new Error(`Windows PATH command timed out after ${WINDOWS_PATH_COMMAND_TIMEOUT_MS}ms.`)
)
}, WINDOWS_PATH_COMMAND_TIMEOUT_MS)
try {
child = execFile(
'powershell',
args,
{ encoding: 'utf8', timeout: WINDOWS_PATH_COMMAND_TIMEOUT_MS },
(error, stdout) => {
finish(error ?? null, stdout)
}
)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
})
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
export function getBundledLauncherPath(
platform: NodeJS.Platform,
resourcesPath: string
): string | null {
if (platform === 'darwin') {
return join(resourcesPath, 'bin', 'orca')
}
if (platform === 'linux') {
return join(resourcesPath, 'bin', LINUX_COMMAND_NAME)
}
if (platform === 'win32') {
return join(resourcesPath, 'bin', 'orca.cmd')
}
return null
}