mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
* Honor configured shells during worktree setup
* Align setup launch paths with selected Windows shells
* Carry setup shell selection through deferred launches
* Prove Windows setup shell routing at its real adapters
* Ground remote PowerShell proof in the real writer
* Preserve Git Bash across deferred setup launches
* Harden Windows setup runner shell selection
- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
'auto' implementation could route the remote runner to a pwsh.exe the remote
lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
$LASTEXITCODE before $?, so a failing native command surfaces its real code
instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.
* Restore setup-shell scope narrowing over the rebase
The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:
- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures
* Satisfy the changed-code gates for the setup-shell runner
- createWorktreeRunnerScript took 7 positional parameters, tripping the
changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
assert the cmd shell now returned for native Windows worktrees.
* Carry the setup launch shell through observed and issue runners
- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): close counsel P1 gaps for Windows setup shells
Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.
* Convert setup env to MSYS form and harden the bare cmd runner launch
C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.
Co-authored-by: Orca <help@stably.ai>
* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution
Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.
* revert: drop windows-setup-shell doc allowlist and AGENTS link
Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.
* fix(plugins): contain Parcel unsubscribe rejections under Vitest
Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.
* fix(plugins): keep in-process unsubscribe rejection surface
Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
755 lines
26 KiB
TypeScript
755 lines
26 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 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { exec, execFile } from 'node:child_process'
|
|
import { getDefaultRepoHookSettings } from '../shared/constants'
|
|
import { getRuntimePathBasename } from '../shared/cross-platform-path'
|
|
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
|
|
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
|
|
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
|
|
import { parseOrcaYaml } from '../shared/orca-yaml'
|
|
import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command'
|
|
import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell'
|
|
import { resolveWindowsGitBashShellPath } from './git-bash'
|
|
import { gitExecFileSync, promptGuardShellEnv } from './git/runner'
|
|
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
|
|
import { addWorktreeSetupWslInteropEnv } from './pty/wsl-orca-env'
|
|
import type {
|
|
HookCommandSourcePolicy,
|
|
OrcaHooks,
|
|
Repo,
|
|
SetupDecision,
|
|
SetupRunPolicy,
|
|
WorktreeDefaultTabsLaunch,
|
|
WorktreeSetupLaunch
|
|
} from '../shared/types'
|
|
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
|
import type { SetupRunnerShell } from '../shared/setup-runner-command'
|
|
|
|
const HOOK_TIMEOUT = 120_000 // 2 minutes
|
|
|
|
export type HookRuntimeTarget = {
|
|
wslDistro?: string | null
|
|
}
|
|
|
|
type SetupRunnerShellSettings = Record<string, unknown> | undefined
|
|
|
|
function getHookShell(): string | undefined {
|
|
if (process.platform === 'win32') {
|
|
return process.env.ComSpec || 'cmd.exe'
|
|
}
|
|
|
|
return '/bin/bash'
|
|
}
|
|
|
|
export { parseOrcaYaml }
|
|
|
|
/**
|
|
* 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: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
|
|
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
|
|
'scripts',
|
|
'issueCommand',
|
|
'defaultTabs',
|
|
'environmentRecipes',
|
|
'worktree'
|
|
])
|
|
|
|
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
|
|
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
|
|
try {
|
|
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
|
|
for (const line of iterateLfScriptLines(content)) {
|
|
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
|
|
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
|
|
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ─── Issue command files ────────────────────────────────────────────────
|
|
// Why: `.orca/issue-command` is the per-user override; `orca.yaml` is the tracked project default.
|
|
|
|
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`.
|
|
* Empty content deletes the override so the shared `orca.yaml` command applies 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 IPC handler surfaces the write failure to the renderer's .catch().
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/** Ensure `.orca` is in `.gitignore` so the per-user directory is never 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 can coexist; the source policy decides which is authoritative.
|
|
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: local-only repos may use shared tab titles/colors but must not run the committed orca.yaml commands.
|
|
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
|
|
}
|
|
|
|
// Why: kept in sync with pty/wsl-orca-env.ts, which path-translates the same keys for WSL.
|
|
// ORCA_WORKSPACE_NAME is a display name and the credential-guard policy is an enum — never paths.
|
|
const SETUP_RUNNER_PATH_ENV_KEYS = [
|
|
'ORCA_ROOT_PATH',
|
|
'ORCA_WORKTREE_PATH',
|
|
'CONDUCTOR_ROOT_PATH',
|
|
'GHOSTX_ROOT_PATH'
|
|
] as const
|
|
|
|
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, runtimeTarget?: HookRuntimeTarget): string {
|
|
return gitExecFileSync(['rev-parse', '--git-path', relativePath], {
|
|
cwd,
|
|
...(runtimeTarget?.wslDistro ? { wslDistro: runtimeTarget.wslDistro } : {})
|
|
}).trim()
|
|
}
|
|
|
|
function getHookRuntimeTarget(
|
|
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
|
|
): HookRuntimeTarget | undefined {
|
|
if (!projectRuntime) {
|
|
return undefined
|
|
}
|
|
|
|
if ('status' in projectRuntime) {
|
|
if (projectRuntime.status === 'repair-required') {
|
|
return projectRuntime.repair.preferredRuntime.kind === 'wsl'
|
|
? { wslDistro: projectRuntime.repair.preferredRuntime.distro }
|
|
: undefined
|
|
}
|
|
return projectRuntime.runtime.kind === 'wsl'
|
|
? { wslDistro: projectRuntime.runtime.distro }
|
|
: undefined
|
|
}
|
|
|
|
return projectRuntime.wslDistro ? { wslDistro: projectRuntime.wslDistro } : undefined
|
|
}
|
|
|
|
function getHookWslContext(
|
|
cwd: string,
|
|
runtimeTarget?: HookRuntimeTarget
|
|
): { distro: string | null; linuxPath: string } | null {
|
|
const pathInfo = parseWslPath(cwd)
|
|
if (pathInfo) {
|
|
return pathInfo
|
|
}
|
|
|
|
const wslDistro = runtimeTarget?.wslDistro?.trim()
|
|
if (!wslDistro) {
|
|
return null
|
|
}
|
|
|
|
// Why: project runtime can route a Windows checkout through WSL, so hooks need the Linux view of the path.
|
|
return {
|
|
distro: wslDistro,
|
|
linuxPath: toLinuxPath(cwd)
|
|
}
|
|
}
|
|
|
|
export function buildWindowsRunnerScript(script: string): string {
|
|
// Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an
|
|
// inherited delayed-expansion state — without this, every `!` in a user setup line is eaten.
|
|
let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n'
|
|
|
|
for (const rawLine of iterateLfScriptLines(script)) {
|
|
const command = rawLine.trim()
|
|
if (!command) {
|
|
runnerScript += '\r\n'
|
|
continue
|
|
}
|
|
|
|
// Why: npm/pnpm are Windows batch files; `call` each line and bail on errorlevel for set -e fail-fast behavior.
|
|
runnerScript += `call ${command}\r\nif errorlevel 1 exit /b %errorlevel%\r\n`
|
|
}
|
|
|
|
return runnerScript
|
|
}
|
|
|
|
function* iterateLfScriptLines(script: string): Generator<string> {
|
|
let lineStart = 0
|
|
|
|
for (let index = 0; index < script.length; index++) {
|
|
if (script.charCodeAt(index) !== 10) {
|
|
continue
|
|
}
|
|
const lineEnd = index > lineStart && script.charCodeAt(index - 1) === 13 ? index - 1 : index
|
|
yield script.slice(lineStart, lineEnd)
|
|
lineStart = index + 1
|
|
}
|
|
|
|
if (lineStart <= script.length) {
|
|
yield script.slice(lineStart)
|
|
}
|
|
}
|
|
|
|
export function createSetupRunnerScript(
|
|
repo: Repo,
|
|
worktreePath: string,
|
|
script: string,
|
|
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
|
|
setupShell?: SetupRunnerShell
|
|
): WorktreeSetupLaunch {
|
|
return createWorktreeRunnerScript({
|
|
repo,
|
|
worktreePath,
|
|
script,
|
|
runnerBaseName: 'setup-runner',
|
|
runtimeTarget: getHookRuntimeTarget(projectRuntime),
|
|
waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup(
|
|
repo.hookSettings?.setupAgentStartupPolicy
|
|
),
|
|
setupShell
|
|
})
|
|
}
|
|
|
|
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
|
|
return {
|
|
...getSetupEnvVars(repo, worktreePath),
|
|
// Why: the Setup terminal is unattended automation, so force the credential guard regardless of user opt-out.
|
|
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
|
|
}
|
|
}
|
|
|
|
export function buildPosixRunnerScript(script: string): string {
|
|
return `#!/usr/bin/env bash\nset -e\n${normalizeCrlfScriptLineEndings(script)}\n`
|
|
}
|
|
|
|
function normalizeCrlfScriptLineEndings(script: string): string {
|
|
let crlfStart = script.indexOf('\r\n')
|
|
if (crlfStart === -1) {
|
|
return script
|
|
}
|
|
|
|
let normalized = script.slice(0, crlfStart)
|
|
let chunkStart = crlfStart + 2
|
|
normalized += '\n'
|
|
crlfStart = script.indexOf('\r\n', chunkStart)
|
|
|
|
while (crlfStart !== -1) {
|
|
normalized += script.slice(chunkStart, crlfStart)
|
|
normalized += '\n'
|
|
chunkStart = crlfStart + 2
|
|
crlfStart = script.indexOf('\r\n', chunkStart)
|
|
}
|
|
|
|
return `${normalized}${script.slice(chunkStart)}`
|
|
}
|
|
|
|
export function createIssueCommandRunnerScript(
|
|
repo: Repo,
|
|
worktreePath: string,
|
|
command: string,
|
|
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
|
|
setupShell?: SetupRunnerShell
|
|
): WorktreeSetupLaunch {
|
|
// Why: writing long commands into a runner script avoids the PTY line editor wrapping/truncating them.
|
|
return createWorktreeRunnerScript({
|
|
repo,
|
|
worktreePath,
|
|
script: command,
|
|
runnerBaseName: 'issue-command-runner',
|
|
runtimeTarget: getHookRuntimeTarget(projectRuntime),
|
|
// Why: issue commands run in the same terminal as setup, so a Git Bash setup
|
|
// runner must not be paired with a cmd issue runner in one session.
|
|
setupShell
|
|
})
|
|
}
|
|
|
|
function createWorktreeRunnerScript(args: {
|
|
repo: Repo
|
|
worktreePath: string
|
|
script: string
|
|
runnerBaseName: 'setup-runner' | 'issue-command-runner'
|
|
runtimeTarget?: HookRuntimeTarget
|
|
waitForAgentStartup?: boolean
|
|
setupShell?: SetupRunnerShell
|
|
}): WorktreeSetupLaunch {
|
|
const {
|
|
repo,
|
|
worktreePath,
|
|
script,
|
|
runnerBaseName,
|
|
runtimeTarget,
|
|
waitForAgentStartup,
|
|
setupShell
|
|
} = args
|
|
const envVars = getSetupRunnerEnvVars(repo, worktreePath)
|
|
// Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows.
|
|
const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro)
|
|
const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree
|
|
const runnerShell: SetupRunnerShell = nativeWindowsWorktree
|
|
? (setupShell ?? { family: 'cmd' })
|
|
: { family: 'posix' }
|
|
const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree
|
|
? runnerShell
|
|
: process.platform === 'win32' && runtimeTarget?.wslDistro
|
|
? { family: 'posix', executable: 'wsl.exe' }
|
|
: undefined
|
|
// Why: linked worktrees use a `.git` file, so resolve the real per-worktree gitdir via git rev-parse --git-path.
|
|
const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh'
|
|
const gitRelPath = `orca/${runnerBaseName}.${runnerExtension}`
|
|
let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget)
|
|
|
|
// Why: git runs inside WSL and returns a Linux path; convert to a UNC path so the Windows fs calls can reach it.
|
|
if (wslWorktree) {
|
|
const wslInfo = getHookWslContext(worktreePath, runtimeTarget)
|
|
if (wslInfo?.distro) {
|
|
runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro)
|
|
}
|
|
}
|
|
|
|
mkdirSync(dirname(runnerScriptPath), { recursive: true })
|
|
|
|
if (runnerShell.family === 'cmd') {
|
|
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8')
|
|
} else {
|
|
writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8')
|
|
if (!nativeWindowsWorktree) {
|
|
// Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL.
|
|
chmodSync(runnerScriptPath, 0o755)
|
|
}
|
|
}
|
|
|
|
// Why: setup script runs inside WSL bash, so translate the Windows UNC env-var paths to Linux paths.
|
|
if (wslWorktree) {
|
|
for (const key of Object.keys(envVars)) {
|
|
envVars[key] = toLinuxPath(envVars[key])
|
|
}
|
|
} else if (nativeWindowsWorktree && runnerShell.family === 'posix') {
|
|
// Why: a Git Bash runner already receives its own path as /c/..., and the shell exports HOME
|
|
// and PWD the same way, so leaving ORCA_* in C:\ form would make them the lone exception.
|
|
// Only path-valued keys convert; the workspace name and policy values are not paths.
|
|
for (const key of SETUP_RUNNER_PATH_ENV_KEYS) {
|
|
const value = envVars[key]
|
|
if (value) {
|
|
envVars[key] = nativeWindowsPathToPosixShellPath(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
runnerScriptPath,
|
|
envVars,
|
|
// Why: WSL git returns /mnt paths that Node converts back to C:\ for file
|
|
// writes; retain the runtime signal so launch converts them to /mnt again.
|
|
// Issue-command runners take the same resolved shell, so one session never
|
|
// mixes a bash setup runner with a cmd issue runner.
|
|
...(launchShell ? { shell: launchShell } : {}),
|
|
...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
|
|
}
|
|
}
|
|
|
|
export function resolveSetupRunnerShell(
|
|
settings: SetupRunnerShellSettings,
|
|
platform: NodeJS.Platform = process.platform,
|
|
options: { resolveGitBashShellPath?: (shell: string) => string | null } = {}
|
|
): SetupRunnerShell | undefined {
|
|
if (platform !== 'win32') {
|
|
return undefined
|
|
}
|
|
|
|
const terminalWindowsShell = settings?.terminalWindowsShell
|
|
const configuredShell =
|
|
typeof terminalWindowsShell === 'string' && terminalWindowsShell.trim()
|
|
? terminalWindowsShell.trim()
|
|
: 'powershell.exe'
|
|
const shellBasename = configuredShell.replaceAll('\\', '/').split('/').pop()?.toLowerCase()
|
|
const family = resolveWindowsShellStartupFamily(configuredShell)
|
|
if (family === 'posix' && shellBasename !== 'wsl.exe' && shellBasename !== 'wsl') {
|
|
// Why: the PTY resolves Git Bash independently and falls back to PowerShell when it
|
|
// is missing, so gate the .sh runner on the same resolution. This also keeps
|
|
// non-Git bash flavors (Cygwin's /cygdrive, the System32 WSL shim's /mnt) off the
|
|
// MSYS-only /c/... form the posix runner emits.
|
|
const resolveGitBashShellPath =
|
|
options.resolveGitBashShellPath ??
|
|
((shell: string) => resolveWindowsGitBashShellPath(shell, { platform }))
|
|
if (resolveGitBashShellPath(configuredShell)) {
|
|
// Note: Git Bash users with batch-syntax orca.yaml setup content get a bash
|
|
// interpreter from here on. The flip is intentional and documented in
|
|
// docs/reference/windows-setup-shell.md.
|
|
return { family: 'posix' }
|
|
}
|
|
}
|
|
|
|
// Why: existing Windows setup scripts were authored for Orca's cmd runner;
|
|
// PowerShell, wsl.exe-as-terminal, and Windows-host projects can invoke it
|
|
// without changing syntax, so they intentionally stay on the cmd runner.
|
|
return { family: 'cmd' }
|
|
}
|
|
|
|
/**
|
|
* Run a named hook script in the given working directory.
|
|
*/
|
|
export function runHook(
|
|
hookName: 'setup' | 'archive',
|
|
cwd: string,
|
|
repo: Repo,
|
|
hooksPath?: string,
|
|
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
|
|
): Promise<{ success: boolean; output: string }> {
|
|
const hooks = getEffectiveHooks(repo, hooksPath)
|
|
const script = hooks?.scripts[hookName]
|
|
|
|
if (!script) {
|
|
return Promise.resolve({ success: true, output: '' })
|
|
}
|
|
|
|
const runtimeTarget = getHookRuntimeTarget(projectRuntime)
|
|
const wslInfo = getHookWslContext(cwd, runtimeTarget)
|
|
|
|
if (wslInfo) {
|
|
// Why: use execFile to bypass cmd.exe, which mangles single-quote escaping of %, ^, &, |, etc.
|
|
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
|
|
const escapedScript = script.replace(/'/g, "'\\''")
|
|
const bashCmd = `cd '${escapedCwd}' && ${escapedScript}`
|
|
// Why: hook scripts run inside WSL, so translate the ORCA_* Windows UNC paths to Linux paths.
|
|
const envVars = getSetupEnvVars(repo, cwd)
|
|
const wslEnv: Record<string, string> = {}
|
|
for (const [key, value] of Object.entries(envVars)) {
|
|
wslEnv[key] = toLinuxPath(value)
|
|
}
|
|
const hookEnv: NodeJS.ProcessEnv = { ...process.env, ...wslEnv }
|
|
// Why: wsl.exe only imports Windows env vars named in WSLENV; without
|
|
// registering them the setup vars never reach the guest (#9206).
|
|
addWorktreeSetupWslInteropEnv(hookEnv)
|
|
|
|
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: execFile's timeout only signals wsl.exe; force-unblock after HOOK_TIMEOUT if no callback arrives.
|
|
const timeout = setTimeout(() => {
|
|
child?.kill()
|
|
finish(new Error(`Hook timed out after ${HOOK_TIMEOUT}ms.`))
|
|
}, HOOK_TIMEOUT)
|
|
|
|
try {
|
|
const distroArgs = wslInfo.distro ? ['-d', wslInfo.distro] : []
|
|
child = execFile(
|
|
'wsl.exe',
|
|
[...distroArgs, '--', 'bash', '-c', bashCmd],
|
|
{
|
|
timeout: HOOK_TIMEOUT,
|
|
encoding: 'utf-8',
|
|
// Why: same unattended-git guard as the non-WSL branch below
|
|
// (issue #7652) — WSL repos are the likeliest to hit the GCM
|
|
// popup, and the guard's WSLENV registration is what carries it
|
|
// across the wsl.exe boundary. Wrap hookEnv (not a fresh env) so
|
|
// the setup-var WSLENV entries registered above (#9206) are kept —
|
|
// promptGuardShellEnv appends its own keys to the existing WSLENV.
|
|
env: promptGuardShellEnv(hookEnv)
|
|
},
|
|
(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(),
|
|
// Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652).
|
|
env: promptGuardShellEnv({
|
|
...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()
|
|
})
|
|
}
|
|
}
|
|
)
|
|
})
|
|
}
|