import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { CliInstallStatus } from '../../shared/cli-install-types' import { hasAppImagePathEnvironment, resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' import { getAppImageCacheRootPath, resolveAppImageExtractedRoot, type AppImageExtractionOptions } from './appimage-extracted-root' import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { DEFAULT_MAC_COMMAND_PATH, DEV_COMMAND_NAME } from './cli-install-constants' import { ensureDevLauncher } from './cli-dev-launcher' import type { CliInstallerOptions, InstallSpec } from './cli-installer-contracts' import { isExecutableFile, samePathEntry, splitPathEntries, uniquePathEntries } from './cli-install-path-format' import { runMacPrivilegedCommand, writeWindowsUserPath } from './cli-privileged-processes' import { invalidateWindowsUserPathRegistryCache, readFreshWindowsUserPathRegistry, readWindowsUserPathRegistry, type WindowsUserPathReadResult } from './windows-user-path-registry' export abstract class CliInstallLocation { protected abstract inspectSymlink( commandPath: string, launcherPath: string ): Promise protected abstract isLinuxAppImage(): boolean protected readonly platform: NodeJS.Platform protected readonly isPackaged: boolean protected readonly userDataPath: string protected readonly resourcesPath: string protected readonly execPathValue: string protected readonly appPathValue: string protected readonly homePath: string protected readonly localAppDataPath: string protected readonly processPathEnv: string | null protected readonly commandPathOverride: string | null protected readonly macCommandPath: string protected readonly privilegedRunner: (command: string) => Promise protected readonly userPathReader: () => Promise protected readonly userPathMutationReader: () => Promise protected readonly userPathWriter: (value: string) => Promise protected readonly userPathCacheInvalidator: () => void protected readonly windowsEnvironment: NodeJS.ProcessEnv protected readonly appImagePath: string | null protected readonly hasUnverifiedAppImageRuntime: boolean protected readonly appImageCacheRootPath: string protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise protected 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_CLI_COMMAND_NAME : 'orca' } constructor(options: CliInstallerOptions = {}) { this.platform = options.platform ?? process.platform this.isPackaged = options.isPackaged ?? getAppEnvironment().isPackaged() this.userDataPath = options.userDataPath ?? getAppEnvironment().getPath('userData') this.resourcesPath = options.resourcesPath ?? process.resourcesPath this.execPathValue = options.execPath ?? process.execPath this.appPathValue = options.appPath ?? getAppEnvironment().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 here (getStatus is hot); /usr/local/bin is absent on Apple Silicon, so fall back to user-writable ~/.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 ?? readWindowsUserPathRegistry this.userPathMutationReader = options.userPathMutationReader ?? options.userPathReader ?? readFreshWindowsUserPathRegistry this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value)) this.userPathCacheInvalidator = options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache this.windowsEnvironment = options.windowsEnvironment ?? process.env const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') const runtimeAppImageIdentity = resolveAppImageRuntimeIdentity({ platform: this.platform, execPath: this.execPathValue, resourcesPath: this.resourcesPath }) this.hasUnverifiedAppImageRuntime = this.platform === 'linux' && this.isPackaged && !hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeAppImageIdentity this.appImagePath = this.platform === 'linux' && this.isPackaged ? hasExplicitAppImagePath ? (options.appImagePath ?? null) : (runtimeAppImageIdentity?.appImagePath ?? null) : null this.appImageCacheRootPath = options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath) this.appImageExtractRunner = options.appImageExtractRunner } protected resolveInstallSpec(): InstallSpec | null { const commandPath = this.resolveCommandPath() if (!commandPath) { return null } if (this.platform === 'darwin' || this.platform === 'linux') { return { commandPath, installMethod: 'symlink' } } if (this.platform === 'win32') { return { commandPath, installMethod: 'wrapper' } } return null } protected async resolveActiveInstallSpec( defaultSpec: InstallSpec, launcherPath: string ): Promise { 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 } protected async findActivePathCommand( launcherPath: string, defaultCommandPath: string ): Promise { 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 slot can be shadowed by installing there; no user file replaced. continue } // Why: PATH lookup is first-match-wins; return the command the shell will actually run, preserving shadowing conflicts. return commandPath } } return null } protected 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) } protected resolveCommandPath(): string | null { if (this.commandPathOverride) { return this.commandPathOverride } if (!this.isPackaged) { // Why: dev uses a separate command; tests/diagnostics still reach 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 lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir. // Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader. return join(this.homePath, '.local', 'bin', LINUX_CLI_COMMAND_NAME) } if (this.platform === 'win32') { // Why: NSIS /D installs can live outside LOCALAPPDATA, so use the packaged resources dir as authoritative. return getBundledLauncherPath(this.platform, this.resourcesPath) } return null } protected async resolveLauncherPath(): Promise { if (!['darwin', 'linux', 'win32'].includes(this.platform)) { return null } if (this.hasUnverifiedAppImageRuntime) { return null } if (this.isLinuxAppImage()) { if (!this.appImagePath || !existsSync(this.appImagePath)) { return null } const extractionOptions = this.appImageExtractionOptions() return extractionOptions ? (resolveAppImageExtractedRoot(extractionOptions)?.stableLauncherPath ?? null) : 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 }) } protected appImageExtractionOptions(): AppImageExtractionOptions | null { return this.appImagePath ? { appImagePath: this.appImagePath, cacheRootPath: this.appImageCacheRootPath, runExtract: this.appImageExtractRunner } : null } }