diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh index efe6133de7e..efbca6592fc 100755 --- a/config/docker/headless-serve-shutdown/run-signal-case.sh +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -104,6 +104,12 @@ if [[ "$entrypoint_kind" == appimage ]]; then echo "FAIL: installed CLI payload ${installed_target:-missing} is in the legacy namespace or missing" >&2 exit 1 fi + # A fresh cache means this serve unpacked the AppImage first, with progress on stderr. + if ! grep -q '\[serve\] AppImage copy ready after' "$stderr_log"; then + cat "$stderr_log" >&2 + echo "FAIL: first AppImage serve did not report unpacking progress" >&2 + exit 1 + fi registered_cli_verified=true fi diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts index 07564276bd1..bfc5800e7a2 100644 --- a/src/main/cli/appimage-extracted-root.ts +++ b/src/main/cli/appimage-extracted-root.ts @@ -31,10 +31,18 @@ export type AppImageExtractedRoot = { stableLauncherPath: string } +export type AppImageExtractRunner = ( + appImagePath: string, + cwd: string, + timeoutMs: number +) => Promise + export type AppImageExtractionOptions = { appImagePath: string cacheRootPath?: string - runExtract?: (appImagePath: string, cwd: string) => Promise + runExtract?: AppImageExtractRunner + /** Absolute time (ms) by which every extraction attempt must have finished. */ + deadlineAt?: number } export function getAppImageCacheRootPath(homePath = homedir()): string { @@ -139,7 +147,7 @@ export async function ensureAppImageExtractedRoot( ): Promise { for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) { const root = resolveAppImageExtractedRoot(options) - if (!root) { + if (!root || remainingExtractionMs(options) <= 0) { return null } const complete = @@ -178,7 +186,11 @@ async function extractAppImageGeneration( const stagingPath = await mkdtemp(join(namespacePath, EXTRACTION_STAGING_PREFIX)) const stopTracking = trackAppImageExtraction(stagingPath) try { - await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath) + const timeoutMs = remainingExtractionMs(options) + if (timeoutMs <= 0) { + return false + } + await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath, timeoutMs) const extractedPath = join(stagingPath, EXTRACT_OUTPUT_DIR) if (!hasPayloadLauncher(extractedPath) || !isCurrentGeneration(options, root)) { return false @@ -195,6 +207,12 @@ async function extractAppImageGeneration( } } +function remainingExtractionMs(options: AppImageExtractionOptions): number { + return options.deadlineAt === undefined + ? APPIMAGE_EXTRACTION_TIMEOUT_MS + : Math.min(APPIMAGE_EXTRACTION_TIMEOUT_MS, options.deadlineAt - Date.now()) +} + function digest(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 24) } @@ -273,12 +291,16 @@ async function renameRoot(sourcePath: string, destinationPath: string): Promise< } } -async function runAppImageExtract(appImagePath: string, cwd: string): Promise { +async function runAppImageExtract( + appImagePath: string, + cwd: string, + timeoutMs: number +): Promise { const result = await runProcess({ program: appImagePath, args: ['--appimage-extract'], cwd, - timeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS, + timeoutMs, maxOutputBytes: 1024 * 1024, terminationBarrier: true }) diff --git a/src/main/cli/appimage-registration-lock.ts b/src/main/cli/appimage-registration-lock.ts index 7c6350e249f..a0b63902e6f 100644 --- a/src/main/cli/appimage-registration-lock.ts +++ b/src/main/cli/appimage-registration-lock.ts @@ -21,21 +21,29 @@ const LOCK_RETRIES = { export async function withAppImageRegistrationLock( cacheRootPath: string, - operation: () => Promise + operation: () => Promise, + options: { deadlineAt?: number } = {} ): Promise { await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + const waitMs = Math.min( + LOCK_ACQUIRE_DEADLINE_MS, + options.deadlineAt === undefined ? Infinity : options.deadlineAt - Date.now() + ) let release: () => Promise try { + if (waitMs <= 0) { + throw new Error('deadline already passed') + } release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { realpath: false, - retries: LOCK_RETRIES, + retries: { ...LOCK_RETRIES, maxRetryTime: waitMs }, stale: LOCK_STALE_MS, update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 }) } catch (error) { throw new Error( `Timed out waiting for another Orca process to finish CLI registration ` + - `(waited ${Math.round(LOCK_ACQUIRE_DEADLINE_MS / 1000)}s). ` + + `(waited ${Math.round(Math.max(waitMs, 0) / 1000)}s). ` + `If no other Orca is running, remove ${join(cacheRootPath, LOCK_TARGET_NAME)}.lock and retry.`, { cause: error } ) diff --git a/src/main/cli/appimage-serve-preparation.test.ts b/src/main/cli/appimage-serve-preparation.test.ts new file mode 100644 index 00000000000..882788d1173 --- /dev/null +++ b/src/main/cli/appimage-serve-preparation.test.ts @@ -0,0 +1,182 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { prepareAppImageCopyForServe, probeAppImageCache } from './appimage-serve-preparation' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-serve-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +async function writePayload(cwd: string): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '', { mode: 0o755 }) +} + +const acceptCache = async (): Promise => null + +describe('prepareAppImageCopyForServe', () => { + it('returns at once, silently, when the copy already exists', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(join(root.rootPath, 'resources', 'bin'), { recursive: true }) + await writeFile(join(root.rootPath, 'resources', 'bin', 'orca-ide'), '', { mode: 0o755 }) + const lines: string[] = [] + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + log: (line) => lines.push(line), + runExtract: async () => { + throw new Error('must not extract') + } + }) + + expect(result.status).toBe('ready') + expect(lines).toEqual([]) + }) + + it('reports progress while unpacking and when it is done', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const lines: string[] = [] + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + probeCache: acceptCache, + progressIntervalMs: 5, + log: (line) => lines.push(line), + runExtract: async (_path, cwd) => { + await new Promise((resolve) => setTimeout(resolve, 40)) + await writePayload(cwd) + } + }) + + expect(result.status).toBe('ready') + expect(lines[0]).toContain('unpacking a stable copy') + expect(lines.some((line) => line.includes('Still unpacking'))).toBe(true) + expect(lines.at(-1)).toContain('AppImage copy ready') + }) + + it('bounds every extraction attempt by one overall deadline', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const timeouts: number[] = [] + const startedAt = Date.now() + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + budgetMs: 200, + probeCache: acceptCache, + log: () => {}, + runExtract: async (_path, _cwd, timeoutMs) => { + timeouts.push(timeoutMs) + await new Promise((resolve) => setTimeout(resolve, timeoutMs + 5)) + throw new Error('AppImage extraction timed out.') + } + }) + + expect(result).toMatchObject({ status: 'unavailable' }) + expect(timeouts.length).toBeGreaterThanOrEqual(1) + expect(timeouts.every((timeout) => timeout <= 200)).toBe(true) + expect(Date.now() - startedAt).toBeLessThan(2_000) + }) + + it('refuses before extracting when the cache cannot hold a runnable copy', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extracted = false + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + probeCache: async () => 'cache is mounted noexec', + log: () => {}, + runExtract: async () => { + extracted = true + } + }) + + expect(result).toMatchObject({ status: 'unavailable', reason: 'cache is mounted noexec' }) + expect(extracted).toBe(false) + }) + + it('gives up waiting for the lock at the deadline', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let release!: () => void + const held = withAppImageRegistrationLock( + cacheRootPath, + () => new Promise((resolve) => (release = resolve)) + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const result = await prepareAppImageCopyForServe({ + appImagePath, + cacheRootPath, + budgetMs: 300, + probeCache: acceptCache, + log: () => {}, + runExtract: async (_path, cwd) => writePayload(cwd) + }) + release() + await held + + expect(result).toMatchObject({ status: 'unavailable' }) + }) +}) + +// POSIX permissions and shebang execution are what the probe measures. +describe.skipIf(process.platform === 'win32')('probeAppImageCache', () => { + it('accepts a private, writable cache that can run programs', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toBeNull() + }) + + it('accepts an AppImage in a read-only directory', async () => { + const { root, cacheRootPath } = await makeFixture() + const readOnlyDir = join(root, 'opt') + await mkdir(readOnlyDir) + const appImagePath = join(readOnlyDir, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await chmod(readOnlyDir, 0o555) + try { + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toBeNull() + } finally { + await chmod(readOnlyDir, 0o755) + } + }) + + it('refuses a cache other users can write to', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + await mkdir(cacheRootPath, { recursive: true }) + await chmod(cacheRootPath, 0o777) + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toContain( + 'writable by other users' + ) + }) + + it.skipIf(process.getuid?.() === 0)('refuses a cache it cannot write to', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + await mkdir(cacheRootPath, { recursive: true }) + await chmod(cacheRootPath, 0o500) + try { + await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toContain( + 'not writable' + ) + } finally { + await chmod(cacheRootPath, 0o700) + } + }) +}) diff --git a/src/main/cli/appimage-serve-preparation.ts b/src/main/cli/appimage-serve-preparation.ts new file mode 100644 index 00000000000..178e18dd2a4 --- /dev/null +++ b/src/main/cli/appimage-serve-preparation.ts @@ -0,0 +1,146 @@ +/** + * The first `AppImage serve` after an update extracts the AppImage before it registers the CLI. + * That wait gets one deadline across the lock and every extraction attempt, progress on stderr, + * and a fast refusal when the private cache cannot hold a runnable copy. + */ +import { randomUUID } from 'node:crypto' +import { mkdir, stat, statfs, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { + ensureAppImageExtractedRoot, + isAppImageExtractionComplete, + resolveAppImageCacheRootPath, + resolveAppImageExtractedRoot, + type AppImageExtractionOptions +} from './appimage-extracted-root' +import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' + +export const APPIMAGE_SERVE_PREPARATION_BUDGET_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS +const PROGRESS_INTERVAL_MS = 15_000 +// An extracted payload is about 2.7x the AppImage file; leave headroom for the staging copy. +const FREE_SPACE_FACTOR = 3 + +export type AppImageServePreparation = + | { status: 'ready'; deadlineAt: number } + | { status: 'unavailable'; reason: string; deadlineAt: number } + +export type AppImageServePreparationOptions = Omit & { + budgetMs?: number + progressIntervalMs?: number + log?: (line: string) => void + probeCache?: (cacheRootPath: string, appImagePath: string) => Promise +} + +export async function prepareAppImageCopyForServe( + options: AppImageServePreparationOptions +): Promise { + const budgetMs = options.budgetMs ?? APPIMAGE_SERVE_PREPARATION_BUDGET_MS + const deadlineAt = Date.now() + budgetMs + const log = options.log ?? ((line: string) => console.log(line)) + const root = resolveAppImageExtractedRoot(options) + if (!root) { + return { status: 'unavailable', reason: 'the AppImage file is not readable', deadlineAt } + } + if (isAppImageExtractionComplete(root)) { + return { status: 'ready', deadlineAt } + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + const startedAt = Date.now() + log( + `[serve] First start of this AppImage: unpacking a stable copy into ${cacheRootPath} ` + + `(up to ${formatSeconds(budgetMs)})` + ) + const refusal = await (options.probeCache ?? probeAppImageCache)( + cacheRootPath, + options.appImagePath + ) + if (refusal) { + log(`[serve] Cannot unpack the AppImage copy: ${refusal}`) + return { status: 'unavailable', reason: refusal, deadlineAt } + } + const progress = setInterval( + () => + log( + `[serve] Still unpacking the AppImage copy (${formatSeconds(Date.now() - startedAt)} elapsed)` + ), + options.progressIntervalMs ?? PROGRESS_INTERVAL_MS + ) + progress.unref?.() + try { + const extracted = await withAppImageRegistrationLock( + cacheRootPath, + () => ensureAppImageExtractedRoot({ ...options, deadlineAt }), + { deadlineAt } + ) + if (!extracted) { + const reason = + Date.now() >= deadlineAt + ? `it did not finish within ${formatSeconds(budgetMs)}` + : 'the AppImage could not be unpacked' + log(`[serve] Cannot unpack the AppImage copy: ${reason}`) + return { status: 'unavailable', reason, deadlineAt } + } + log(`[serve] AppImage copy ready after ${formatSeconds(Date.now() - startedAt)}`) + return { status: 'ready', deadlineAt } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + log(`[serve] Cannot unpack the AppImage copy: ${reason}`) + return { status: 'unavailable', reason, deadlineAt } + } finally { + clearInterval(progress) + } +} + +/** Null when the cache is a private, writable directory that can run programs and has room. */ +export async function probeAppImageCache( + cacheRootPath: string, + appImagePath: string +): Promise { + try { + await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + const stats = await stat(cacheRootPath) + const uid = process.getuid?.() + if (uid !== undefined && stats.uid !== uid) { + return `${cacheRootPath} belongs to another user` + } + if ((stats.mode & 0o022) !== 0) { + return `${cacheRootPath} is writable by other users` + } + const needed = (await stat(appImagePath)).size * FREE_SPACE_FACTOR + const fsStats = await statfs(cacheRootPath) + if (fsStats.bavail * fsStats.bsize < needed) { + return `${cacheRootPath} needs about ${Math.ceil(needed / 1024 / 1024)} MB free` + } + } catch (error) { + return `${cacheRootPath} is not usable (${describeError(error)})` + } + const probePath = join(cacheRootPath, `.exec-probe-${process.pid}-${randomUUID()}`) + try { + try { + await writeFile(probePath, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + } catch (error) { + return `${cacheRootPath} is not writable (${describeError(error)})` + } + try { + const result = await runProcess({ program: probePath, args: [], timeoutMs: 5_000 }) + return result.code === 0 ? null : `${cacheRootPath} cannot run programs` + } catch (error) { + return `${cacheRootPath} cannot run programs (${describeError(error)}; is it mounted noexec?)` + } + } finally { + await unlink(probePath).catch(() => {}) + } +} + +function describeError(error: unknown): string { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { + return error.code + } + return error instanceof Error ? error.message : String(error) +} + +function formatSeconds(ms: number): string { + return `${Math.max(0, Math.round(ms / 1000))}s` +} diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts index 228e574addc..4be9d60302a 100644 --- a/src/main/cli/cli-install-location.ts +++ b/src/main/cli/cli-install-location.ts @@ -58,6 +58,7 @@ export abstract class CliInstallLocation { protected readonly hasUnverifiedAppImageRuntime: boolean protected readonly appImageCacheRootPath: string protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise + protected readonly appImageDeadlineAt?: number protected get commandName(): string { if (!this.isPackaged && !this.commandPathOverride) { @@ -117,6 +118,7 @@ export abstract class CliInstallLocation { this.appImageCacheRootPath = options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath) this.appImageExtractRunner = options.appImageExtractRunner + this.appImageDeadlineAt = options.appImageDeadlineAt } protected resolveInstallSpec(): InstallSpec | null { @@ -274,7 +276,8 @@ export abstract class CliInstallLocation { ? { appImagePath: this.appImagePath, cacheRootPath: this.appImageCacheRootPath, - runExtract: this.appImageExtractRunner + runExtract: this.appImageExtractRunner, + deadlineAt: this.appImageDeadlineAt } : null } diff --git a/src/main/cli/cli-installer-contracts.ts b/src/main/cli/cli-installer-contracts.ts index 40a75cb984f..079820ae9b1 100644 --- a/src/main/cli/cli-installer-contracts.ts +++ b/src/main/cli/cli-installer-contracts.ts @@ -24,6 +24,8 @@ export type CliInstallerOptions = { appImagePath?: string | null appImageCacheRootPath?: string appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise + /** Absolute time (ms) by which AppImage extraction and its lock wait must finish. */ + appImageDeadlineAt?: number } export type InstallSpec = { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 1be0f88b545..761b6f00c58 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -222,7 +222,9 @@ export class CliInstaller extends CliPathRegistration { private runAppImageRegistrationOperation(operation: () => Promise): Promise { return this.isLinuxAppImage() - ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation) + ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation, { + deadlineAt: this.appImageDeadlineAt + }) : operation() } } diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index ac48c7d6b25..91979020fc2 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -32,6 +32,8 @@ export type LinuxBareOrcaDispatcherOptions = { appImageCacheRootPath?: string /** Test seam — defaults to running the AppImage's own `--appimage-extract`. */ appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise + /** Absolute time (ms) by which AppImage extraction and its lock wait must finish. */ + appImageDeadlineAt?: number } export type LinuxBareOrcaDispatcherState = @@ -100,7 +102,8 @@ async function resolveStableLauncherPath( const extractionOptions = { appImagePath, cacheRootPath: options.appImageCacheRootPath, - runExtract: options.appImageExtractRunner + runExtract: options.appImageExtractRunner, + deadlineAt: options.appImageDeadlineAt } return withAppImageRegistrationLock( resolveAppImageCacheRootPath(extractionOptions), @@ -113,7 +116,8 @@ async function resolveStableLauncherPath( ) } return extractedRoot?.stableLauncherPath ?? null - } + }, + { deadlineAt: options.appImageDeadlineAt } ) } const launcher = getBundledLauncherPath('linux', options.resourcesPath) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 2789aca517c..e30f8ee6f1a 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -33,8 +33,7 @@ import { recordRuntimeRpcStartFailure, showRuntimeRpcStartupFailureDialog } from '../runtime/runtime-rpc-startup-failure' -import { CliInstaller } from '../cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { registerCliForHeadlessServe } from './serve-cli-registration' import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' import { startDesktopPushService } from './main-process-push-startup' @@ -175,42 +174,7 @@ async function launchServeMode( startDesktopPushService(runtimeRpc) settleDesktopActivation() installServeQuitHandling() - // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). - if (process.platform === 'darwin' || process.platform === 'linux') { - try { - // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ - privilegedRunner: async () => { - throw new Error('serve CLI auto-install must not request administrator privileges') - } - }).install() - console.log( - `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` - ) - } catch (error) { - console.warn( - '[serve] orca CLI install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. - if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { - try { - const dispatcher = await installLinuxBareOrcaDispatcher({ - resourcesPath: process.resourcesPath - }) - console.log( - `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + - `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` - ) - } catch (error) { - console.warn( - '[serve] bare orca dispatcher install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } + await registerCliForHeadlessServe() // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. state.automations?.start() // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is diff --git a/src/main/startup/serve-cli-registration.ts b/src/main/startup/serve-cli-registration.ts new file mode 100644 index 00000000000..e5c01910b9f --- /dev/null +++ b/src/main/startup/serve-cli-registration.ts @@ -0,0 +1,61 @@ +import { app } from 'electron' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' +import { prepareAppImageCopyForServe } from '../cli/appimage-serve-preparation' +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' + +/** Headless serve has no renderer to run the normal cli:install flow, so it registers here. */ +export async function registerCliForHeadlessServe(): Promise { + // Windows-excluded: install() only mutates registry PATH, not child terminals. + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return + } + const appImagePath = + process.platform === 'linux' ? (resolveAppImageRuntimeIdentity()?.appImagePath ?? null) : null + let appImageDeadlineAt: number | undefined + if (appImagePath) { + // Why first: CLI install and the bare dispatcher would otherwise each wait out their own + // extraction timeout; this gives the whole AppImage wait one deadline and visible progress. + const preparation = await prepareAppImageCopyForServe({ appImagePath }) + if (preparation.status === 'unavailable') { + console.warn(`[serve] orca CLI install skipped: ${preparation.reason}`) + return + } + appImageDeadlineAt = preparation.deadlineAt + } + try { + // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + }, + ...(appImageDeadlineAt === undefined ? {} : { appImageDeadlineAt }) + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath, + ...(appImageDeadlineAt === undefined ? {} : { appImageDeadlineAt }) + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } +}