Files
orca/src/main/hooks.ts
T
2026-05-31 05:05:58 -07:00

640 lines
21 KiB
TypeScript

/* eslint-disable max-lines -- Why: hook parsing, layered issue-command resolution, and cross-platform runner setup share one execution surface, so keeping them together avoids subtle drift across create/read/write paths. */
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'fs'
import { dirname, join } from 'path'
import { exec, execFile } from 'child_process'
import { parse } from 'yaml'
import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { gitExecFileSync } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import type {
HookCommandSourcePolicy,
OrcaDefaultTabTemplate,
OrcaHooks,
Repo,
SetupDecision,
SetupRunPolicy,
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../shared/types'
const HOOK_TIMEOUT = 120_000 // 2 minutes
function getHookShell(): string | undefined {
if (process.platform === 'win32') {
return process.env.ComSpec || 'cmd.exe'
}
return '/bin/bash'
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function asTrimmedString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
const DEFAULT_TAB_COLOR_RE = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/
function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((entry) => {
const record = asRecord(entry)
if (!record) {
return null
}
const title = asTrimmedString(record.title)
const command = asTrimmedString(record.command)
const color = asTrimmedString(record.color)
const normalizedColor = color && DEFAULT_TAB_COLOR_RE.test(color) ? color : undefined
if (!title && !command && !normalizedColor) {
return null
}
return {
...(title ? { title } : {}),
...(normalizedColor ? { color: normalizedColor } : {}),
...(command ? { command } : {})
}
})
.filter((entry): entry is OrcaDefaultTabTemplate => entry !== null)
}
/**
* Parse the supported project defaults from `orca.yaml`.
*/
export function parseOrcaYaml(content: string): OrcaHooks | null {
let root: unknown
try {
root = parse(content)
} catch {
return null
}
const record = asRecord(root)
if (!record) {
return null
}
const scriptsRecord = asRecord(record.scripts)
const setup = scriptsRecord ? asTrimmedString(scriptsRecord.setup) : undefined
const archive = scriptsRecord ? asTrimmedString(scriptsRecord.archive) : undefined
const issueCommand = asTrimmedString(record.issueCommand)
const defaultTabs = normalizeDefaultTabs(record.defaultTabs)
if (!setup && !archive && !issueCommand && defaultTabs.length === 0) {
return null
}
return {
scripts: {
...(setup ? { setup } : {}),
...(archive ? { archive } : {})
},
...(issueCommand ? { issueCommand } : {}),
...(defaultTabs.length > 0 ? { defaultTabs } : {})
}
}
/**
* Load hooks from orca.yaml in the given repo root.
*/
export function loadHooks(repoPath: string): OrcaHooks | null {
const yamlPath = join(repoPath, 'orca.yaml')
if (!existsSync(yamlPath)) {
return null
}
try {
const content = readFileSync(yamlPath, 'utf-8')
return parseOrcaYaml(content)
} catch {
return null
}
}
/**
* Check whether an orca.yaml exists for a repo.
*/
export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
// Why: when a newer Orca release adds a top-level key to `orca.yaml` (like
// `issueCommand` was added here), older versions that don't recognise it will
// return `null` from `parseOrcaYaml` and show a confusing "could not be parsed"
// error. Detecting well-formed but unrecognised keys lets the UI suggest an
// update instead of implying the file is broken.
const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand', 'defaultTabs'])
/**
* Return true when `orca.yaml` contains at least one top-level key that this
* version of Orca does not handle.
*/
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
try {
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
return content.split(/\r?\n/).some((line) => {
// Why: bare `key:` at end-of-line (no trailing space) is valid YAML for
// a mapping with a block value on the next line. Match both forms so
// newer keys like `futureFeature:\n nested` are still detected.
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
return m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])
})
} catch {
return false
}
}
// ─── Issue command files ────────────────────────────────────────────────
// Why: `orca.yaml` is the tracked, project-wide defaults surface, while
// `.orca/issue-command` remains the per-user override. Keeping the local file in
// `.orca/` lets users customize agent automation without editing committed config.
const ORCA_DIR = '.orca'
const ISSUE_COMMAND_FILENAME = 'issue-command'
export function getIssueCommandFilePath(repoPath: string): string {
return join(repoPath, ORCA_DIR, ISSUE_COMMAND_FILENAME)
}
export function getSharedIssueCommand(repoPath: string): string | null {
return loadHooks(repoPath)?.issueCommand?.trim() || null
}
export type ResolvedIssueCommand = {
localContent: string | null
sharedContent: string | null
effectiveContent: string | null
localFilePath: string
source: 'local' | 'shared' | 'none'
}
/**
* Resolve the GitHub issue command using local override first, then tracked repo config.
*/
export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
const filePath = getIssueCommandFilePath(repoPath)
let localContent: string | null = null
if (existsSync(filePath)) {
try {
const content = readFileSync(filePath, 'utf-8').trim()
localContent = content || null
} catch {
localContent = null
}
}
const sharedContent = getSharedIssueCommand(repoPath)
const effectiveContent = localContent ?? sharedContent
return {
localContent,
sharedContent,
effectiveContent,
localFilePath: filePath,
source: localContent ? 'local' : sharedContent ? 'shared' : 'none'
}
}
/**
* Write the per-user issue command override to `{repoRoot}/.orca/issue-command`.
* Creates `.orca/` and ensures it is in `.gitignore` on first write.
* If content is empty, deletes only the override so the shared `orca.yaml`
* command becomes effective again.
*/
export function writeIssueCommand(repoPath: string, content: string): void {
const filePath = getIssueCommandFilePath(repoPath)
const trimmed = content.trim()
try {
if (!trimmed) {
rmSync(filePath, { force: true })
return
}
const orcaDir = join(repoPath, ORCA_DIR)
if (!existsSync(orcaDir)) {
mkdirSync(orcaDir, { recursive: true })
}
ensureOrcaDirIgnored(repoPath)
writeFileSync(filePath, `${trimmed}\n`, 'utf-8')
} catch (err) {
console.error('[hooks] Failed to write issue command:', err)
// Why: re-throw so the error propagates through the IPC handler to the
// renderer, which already has .catch() ready to surface write failures.
throw err
}
}
/**
* Ensure `.orca` is listed in the repo's `.gitignore` so the per-user
* directory is never accidentally committed.
*/
function ensureOrcaDirIgnored(repoPath: string): void {
const gitignorePath = join(repoPath, '.gitignore')
try {
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, 'utf-8')
if (/^\.orca\/?$/m.test(content)) {
return
}
const separator = content.endsWith('\n') ? '' : '\n'
writeFileSync(gitignorePath, `${content}${separator}.orca\n`, 'utf-8')
} else {
writeFileSync(gitignorePath, '.orca\n', 'utf-8')
}
} catch {
console.warn('[hooks] Could not update .gitignore to exclude .orca')
}
}
function getEffectiveHookScript(
yamlScript: string | undefined,
localScript: string | undefined,
policy: HookCommandSourcePolicy
): string | undefined {
const shared = yamlScript?.trim()
const local = localScript?.trim()
if (policy === 'local-only') {
return local || undefined
}
if (policy === 'run-both') {
return [shared, local].filter(Boolean).join('\n') || undefined
}
return shared || undefined
}
export function getEffectiveHooksFromConfig(
repo: Repo,
yamlHooks: OrcaHooks | null
): OrcaHooks | null {
const localSetup = repo.hookSettings?.scripts.setup
const localArchive = repo.hookSettings?.scripts.archive
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const setupPolicy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localSetup?.trim())
})
const archivePolicy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localArchive?.trim())
})
const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, setupPolicy)
const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, archivePolicy)
if (!setup && !archive) {
return null
}
// Why: committed `orca.yaml` and local Settings commands can intentionally
// coexist, but the source policy defines whether the committed file is an
// authoritative boundary, local settings are authoritative, or both run.
return {
scripts: {
...(setup ? { setup } : {}),
...(archive ? { archive } : {})
}
}
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const hooksRoot = worktreePath ?? repo.path
return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot))
}
export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy {
return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy!
}
export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'inherit'): boolean {
if (decision === 'run') {
return true
}
if (decision === 'skip') {
return false
}
const policy = getEffectiveSetupRunPolicy(repo)
if (policy === 'ask') {
throw new Error('Setup decision required for this repository')
}
return policy === 'run-by-default'
}
export function getDefaultTabCommandTrustContent(hooks: OrcaHooks | null): string {
const commands = (hooks?.defaultTabs ?? [])
.map((tab, index) => {
const command = tab.command?.trim()
if (!command) {
return null
}
const label = tab.title ? ` ${tab.title}` : ''
return `# defaultTabs[${index + 1}]${label}\n${command}`
})
.filter((entry): entry is string => entry !== null)
return [hooks?.scripts.setup?.trim(), ...commands].filter(Boolean).join('\n\n')
}
export function getDefaultTabsLaunch(
hooks: OrcaHooks | null,
repo: Repo,
decision: SetupDecision = 'inherit'
): WorktreeDefaultTabsLaunch | undefined {
const tabs = hooks?.defaultTabs ?? []
if (tabs.length === 0) {
return undefined
}
const hasCommands = tabs.some((tab) => Boolean(tab.command?.trim()))
const sharedCommandPolicy = resolveHookCommandSourcePolicy(
repo.hookSettings?.commandSourcePolicy,
{
hasLocalScript: Boolean(repo.hookSettings?.scripts.setup?.trim())
}
)
// Why: default tab commands come from committed `orca.yaml`; a repo set to
// local-only may still use shared titles/colors, but must not execute them.
const canRunSharedCommands = sharedCommandPolicy !== 'local-only'
const runCommands =
hasCommands && canRunSharedCommands ? shouldRunSetupForCreate(repo, decision) : false
return { tabs, runCommands }
}
export function getSetupCommandSource(
repo: Repo,
worktreePath?: string
): { source: 'yaml' | 'local' | 'both'; command: string } | null {
const hooksRoot = worktreePath ?? repo.path
const yamlHooks = loadHooks(hooksRoot)
const yamlSetup = yamlHooks?.scripts.setup?.trim()
const localSetup = repo.hookSettings?.scripts.setup?.trim()
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const policy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localSetup)
})
if (policy === 'local-only') {
return localSetup ? { source: 'local', command: localSetup } : null
}
if (policy === 'run-both' && yamlSetup && localSetup) {
return { source: 'both', command: `${yamlSetup}\n${localSetup}` }
}
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
return null
}
function getSetupEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: worktreePath,
ORCA_WORKSPACE_NAME: getRuntimePathBasename(worktreePath),
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path
}
}
function getGitPath(cwd: string, relativePath: string): string {
return gitExecFileSync(['rev-parse', '--git-path', relativePath], {
cwd
}).trim()
}
export function buildWindowsRunnerScript(script: string): string {
const lines = script.replace(/\r?\n/g, '\n').split('\n')
const runnerLines = ['@echo off', 'setlocal EnableExtensions']
for (const rawLine of lines) {
const command = rawLine.trim()
if (!command) {
runnerLines.push('')
continue
}
// Why: setup commands often invoke `npm`/`pnpm`, which are batch files on
// Windows. Calling one batch file from another without `call` never returns
// to later lines, and plain newline-separated commands also keep running
// after failures. Wrap each line in `call` and bail on non-zero exit codes
// so the generated runner matches the fail-fast behavior of `set -e`.
runnerLines.push(`call ${command}`)
runnerLines.push('if errorlevel 1 exit /b %errorlevel%')
}
return `${runnerLines.join('\r\n')}\r\n`
}
export function createSetupRunnerScript(
repo: Repo,
worktreePath: string,
script: string
): WorktreeSetupLaunch {
return createWorktreeRunnerScript(repo, worktreePath, script, 'setup-runner')
}
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return getSetupEnvVars(repo, worktreePath)
}
export function buildPosixRunnerScript(script: string): string {
return `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
}
export function createIssueCommandRunnerScript(
repo: Repo,
worktreePath: string,
command: string
): WorktreeSetupLaunch {
// Why: long issue-automation commands are user-visible shell input when
// written directly to the PTY, so terminal line editors can wrap or truncate
// them before execution. Writing the real command into a runner script keeps
// the shell startup path short and mirrors the already-stable setup runner
// flow instead of inventing a second launch mechanism.
return createWorktreeRunnerScript(repo, worktreePath, command, 'issue-command-runner')
}
function createWorktreeRunnerScript(
repo: Repo,
worktreePath: string,
script: string,
runnerBaseName: 'setup-runner' | 'issue-command-runner'
): WorktreeSetupLaunch {
const envVars = getSetupEnvVars(repo, worktreePath)
// Why: WSL worktrees run on a Linux filesystem even though process.platform
// is 'win32'. Use bash scripts for WSL, .cmd for native Windows.
const wslWorktree = isWslPath(worktreePath)
const useWindowsFormat = process.platform === 'win32' && !wslWorktree
const normalizedScript = useWindowsFormat
? script.replace(/\r?\n/g, '\r\n')
: script.replace(/\r\n/g, '\n')
// Why: linked git worktrees use a `.git` file that points at the real gitdir,
// so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path`
// resolves the actual per-worktree git storage path safely across platforms.
const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh`
let runnerScriptPath = getGitPath(worktreePath, gitRelPath)
// Why: for WSL worktrees, getGitPath returns a Linux path (e.g. /home/user/...)
// because git runs inside WSL. Convert it to a Windows UNC path so mkdirSync
// and writeFileSync (which run on Windows) can access it.
if (wslWorktree) {
const wslInfo = parseWslPath(worktreePath)
if (wslInfo) {
runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro)
}
}
mkdirSync(dirname(runnerScriptPath), { recursive: true })
if (useWindowsFormat) {
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(normalizedScript), 'utf-8')
} else {
writeFileSync(runnerScriptPath, `#!/usr/bin/env bash\nset -e\n${normalizedScript}\n`, 'utf-8')
// Why: chmod via UNC paths to WSL filesystem is supported by Windows and
// sets the execute bit correctly inside WSL.
chmodSync(runnerScriptPath, 0o755)
}
// Why: when the worktree is on WSL, env vars like ORCA_ROOT_PATH and
// ORCA_WORKTREE_PATH contain Windows UNC paths. The setup script runs
// inside WSL bash, so translate them to Linux paths.
if (wslWorktree) {
for (const key of Object.keys(envVars)) {
envVars[key] = toLinuxPath(envVars[key])
}
}
return { runnerScriptPath, envVars }
}
/**
* Run a named hook script in the given working directory.
*/
export function runHook(
hookName: 'setup' | 'archive',
cwd: string,
repo: Repo,
hooksPath?: string
): Promise<{ success: boolean; output: string }> {
const hooks = getEffectiveHooks(repo, hooksPath)
const script = hooks?.scripts[hookName]
if (!script) {
return Promise.resolve({ success: true, output: '' })
}
const wslInfo = parseWslPath(cwd)
if (wslInfo) {
// Why: use execFile('wsl.exe', [...]) instead of exec() to bypass the
// Windows shell (cmd.exe). exec() always routes through a shell, and
// cmd.exe doesn't understand single-quote escaping — it would mangle
// paths/scripts containing %, ^, &, |, etc.
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
const escapedScript = script.replace(/'/g, "'\\''")
const bashCmd = `cd '${escapedCwd}' && ${escapedScript}`
// Why: translate ORCA_ROOT_PATH / ORCA_WORKTREE_PATH to Linux paths so
// hook scripts that reference $ORCA_WORKTREE_PATH get usable paths
// inside WSL, not Windows UNC paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(envVars)) {
wslEnv[key] = toLinuxPath(value)
}
return new Promise((resolve) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error: Error | null, stdout = '', stderr = ''): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message)
resolve({
success: false,
output: `${stdout}\n${stderr}\n${error.message}`.trim()
})
} else {
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
resolve({
success: true,
output: `${stdout}\n${stderr}`.trim()
})
}
}
// Why: Node's execFile timeout only signals wsl.exe; if no callback
// arrives, hook setup/archive must still unblock after HOOK_TIMEOUT.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Hook timed out after ${HOOK_TIMEOUT}ms.`))
}, HOOK_TIMEOUT)
try {
child = execFile(
'wsl.exe',
['-d', wslInfo.distro, '--', 'bash', '-c', bashCmd],
{
timeout: HOOK_TIMEOUT,
encoding: 'utf-8',
env: { ...process.env, ...wslEnv }
},
(error, stdout, stderr) => {
finish(error ?? null, stdout, stderr)
}
)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
})
}
return new Promise((resolve) => {
exec(
script,
{
cwd,
timeout: HOOK_TIMEOUT,
shell: getHookShell(),
env: {
...process.env,
...getSetupEnvVars(repo, cwd)
}
},
(error, stdout, stderr) => {
if (error) {
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message)
resolve({
success: false,
output: `${stdout}\n${stderr}\n${error.message}`.trim()
})
} else {
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
resolve({
success: true,
output: `${stdout}\n${stderr}`.trim()
})
}
}
)
})
}