mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
* fix(agent-hooks): refresh existing shared hook scripts when the CLI is no longer detected A CLI that falls off PATH (moved npm prefix, relocated shim) keeps its user-wide config invoking Orca's launcher script under ~/.orca/agent-hooks, but the presence gate skips install() with no removal — freezing the script at whatever Orca generated last. Anyone in that state kept the pre-#11568 more.com-leaking .cmd forever, because no launcher script is ever deleted and Windows startup deliberately skips shell PATH hydration. Reconcile before gating: every existing shared launcher/statusline script is rewritten to the current template on each install pass. Creating scripts stays behind the presence gate — an existing file is proof of a prior install; a missing one means the gate did its job. Amp and Hermes are deliberately absent: they write provider-native plugin code with its own install lifecycle, not shared launchers. - refreshManagedScriptIfPresent() in installer-utils (no-op unless the file exists) - refreshManagedScripts() on the 11 launcher-writing services (openclaude via the shared Claude class) - reconcile pass in installManagedAgentHooks before presence detection, filtered by the agents option, best-effort per agent - coverage gate: a launcher written to ~/.orca/agent-hooks without a matching refresher entry fails the suite, in both directions * perf(agent-hooks): refresh launchers off the main thread * test(agent-hooks): keep refresh mode assertion POSIX-only
91 lines
2.5 KiB
TypeScript
91 lines
2.5 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
|
|
|
|
type ExistingScript = { exists: false } | { exists: true; content: string | null }
|
|
|
|
function isMissingPathError(error: unknown): boolean {
|
|
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
|
}
|
|
|
|
async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
|
|
try {
|
|
return { exists: true, content: await readFile(scriptPath, 'utf-8') }
|
|
} catch (error) {
|
|
if (isMissingPathError(error)) {
|
|
return { exists: false }
|
|
}
|
|
try {
|
|
await stat(scriptPath)
|
|
return { exists: true, content: null }
|
|
} catch (statError) {
|
|
if (isMissingPathError(statError)) {
|
|
return { exists: false }
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
async function scriptStillExists(scriptPath: string): Promise<boolean> {
|
|
try {
|
|
await stat(scriptPath)
|
|
return true
|
|
} catch (error) {
|
|
if (isMissingPathError(error)) {
|
|
return false
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise<void> {
|
|
try {
|
|
await writeFile(scriptPath, content, 'utf-8')
|
|
} catch (error) {
|
|
if (isPermissionError(error) && process.platform === 'win32') {
|
|
try {
|
|
await grantDirAclAsync(dirname(scriptPath))
|
|
await writeFile(scriptPath, content, 'utf-8')
|
|
return
|
|
} catch {
|
|
// Re-throw the original permission error.
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Why: refresh must not block Electron's main thread or create state for an absent CLI.
|
|
export async function refreshManagedScriptIfPresent(
|
|
scriptPath: string,
|
|
content: string
|
|
): Promise<boolean> {
|
|
const existing = await readExistingScript(scriptPath)
|
|
if (!existing.exists) {
|
|
return false
|
|
}
|
|
if (existing.content === content) {
|
|
if (process.platform !== 'win32') {
|
|
await chmod(scriptPath, 0o755)
|
|
}
|
|
return true
|
|
}
|
|
|
|
const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`)
|
|
try {
|
|
await writeScriptWithAclRetry(tmpPath, content)
|
|
if (process.platform !== 'win32') {
|
|
await chmod(tmpPath, 0o755)
|
|
}
|
|
if (!(await scriptStillExists(scriptPath))) {
|
|
return false
|
|
}
|
|
await rename(tmpPath, scriptPath)
|
|
return true
|
|
} finally {
|
|
await rm(tmpPath, { force: true }).catch(() => undefined)
|
|
}
|
|
}
|