mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix(shell-ready): honor ZDOTDIR without breaking zsh scoping Fixes #1866 This reimplements PR #1737 (reverted in #1864) with a safer approach that preserves normal zsh startup semantics. **Core fix**: Discover ZDOTDIR by sourcing user ~/.zshenv in a subshell instead of inside a wrapper function. This preserves top-level zsh scoping for common patterns like `typeset -U path` that broke in the original implementation. **Shell safety improvements**: - Use `printf '%s\n'` instead of `echo` for capturing ZDOTDIR (handles special characters in paths safely) - Subshell isolates early returns and side effects from wrapper **Code quality**: - Extract duplicated zsh wrapper template to `src/main/shell-templates.ts` - Both local-pty and daemon paths now share identical wrapper logic **Test coverage**: - Add live zsh subprocess tests that spawn real zsh to verify: - XDG ZDOTDIR discovery works - `typeset -U path` in .zshrc preserves top-level scoping - Early returns in .zshenv don't crash the wrapper - Vanilla (non-XDG) configs fall back to HOME correctly - Template structure tests validate subshell discovery logic Before (broken): ```zsh __orca_source_user_zshenv() { source "$HOME/.zshenv" # typeset becomes function-scoped } ``` After (fixed): ```zsh _orca_discovered_zdotdir=$( unset ZDOTDIR [[ -f "$HOME/.zshenv" ]] && source "$HOME/.zshenv" 2>/dev/null printf '%s\n' "${ZDOTDIR}" ) export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_spawn_orig_zdotdir:-$HOME}}" ``` The subshell sources .zshenv at top-level (preserving normal scoping), captures only the ZDOTDIR value, then exits. User rcfiles (.zshrc, etc.) are still sourced at the wrapper's top level, so all scoping works normally. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Add shell-script-literal test framework and improvements Adds a new declarative test framework for shell-ready tests that uses literal shell scripts (copy-pastable into terminals) with inline snapshots. Framework features: - Shell scripts as string literals with # Run: marker to split setup/test - Direct script execution (no brittle parsing) via temp files - Path normalization for reproducible snapshots (<HOME>, <WRAPPER_DIR>) - Auto-detects shell from command, supports bash/zsh/sh - Inline snapshot testing with vitest toMatchInlineSnapshot() Code quality improvements: - Extract escapeRegex to shared string-utils.ts (deduplicates 2 copies) - Refactor shell-templates.ts for readability (condense comments, add structure) - Pre-compile regex patterns to avoid hot-path allocation - Fix path normalization to sort by length (prevent nested path corruption) - Fix actualUserHome handling to skip empty values All tests passing (64/64 shell-ready tests, 55/55 affected tests). Files added: - src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts - src/main/providers/__tests__/shell-ready-framework/README.md - src/main/providers/__tests__/shell-ready-framework-example.test.ts - src/shared/string-utils.ts Files modified: - src/main/shell-templates.ts (readability cleanup) - src/main/codex/config-toml-trust.ts (use shared escapeRegex) - src/main/daemon/shell-ready.test.ts (updated for new framework) - src/main/providers/local-pty-shell-ready.test.ts (updated for new framework) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(shell-ready): preserve zshenv semantics --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
Neil
parent
6f18d362cc
commit
ddda84f529
@@ -11,6 +11,7 @@ import {
|
||||
} from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { escapeRegex } from '../../shared/string-utils'
|
||||
|
||||
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
|
||||
// ~/.codex/config.toml under [hooks.state."<key>"]. Without it the hook is in
|
||||
@@ -365,11 +366,6 @@ function buildProjectHeaderPattern(projectPath: string): RegExp {
|
||||
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
|
||||
)
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines
|
||||
// inside multi-line strings aren't headers, so we need a stateful scanner —
|
||||
// a flat regex misclassifies both cases.
|
||||
|
||||
@@ -130,6 +130,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
|
||||
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
@@ -155,6 +156,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
|
||||
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
@@ -185,6 +187,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
|
||||
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
@@ -210,9 +213,9 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
||||
expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
||||
expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]')
|
||||
expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
||||
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
||||
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
|
||||
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
||||
})
|
||||
|
||||
it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => {
|
||||
@@ -330,6 +333,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
|
||||
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/zsh')
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
@@ -410,4 +414,40 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => {
|
||||
// Why: PR #1737 sourced .zshenv inside a wrapper function, which broke
|
||||
// common patterns like "typeset -U path". The fix must keep .zshenv at
|
||||
// zsh top level while still capturing the ZDOTDIR it resolved.
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
||||
|
||||
expect(zshenv).toContain('unset ZDOTDIR')
|
||||
expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"')
|
||||
expect(zshenv).toContain('source "${_orca_zshenv_path}"')
|
||||
expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"')
|
||||
expect(zshenv).toContain(
|
||||
'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"'
|
||||
)
|
||||
expect(zshenv).toContain('export ZDOTDIR=')
|
||||
})
|
||||
|
||||
it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => {
|
||||
// Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper
|
||||
// should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME.
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
||||
|
||||
// Save spawn-env value before sourcing user .zshenv
|
||||
expect(zshenv).toContain('_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"')
|
||||
|
||||
// Fallback chain: discovered → normalized spawn-env path → HOME
|
||||
expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,16 +11,13 @@ import {
|
||||
isPowerShellExecutableName
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { getZshEnvTemplate } from '../shell-templates'
|
||||
|
||||
const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH'
|
||||
const SHELL_READY_MARKER = '\\033]777;orca-shell-ready\\007'
|
||||
|
||||
let didEnsureShellReadyWrappers = false
|
||||
|
||||
function quotePosixSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function getShellReadyWrapperRoot(): string {
|
||||
const userDataPath = process.env[ORCA_USER_DATA_PATH_ENV]
|
||||
// Why: older/test launchers may not seed ORCA_USER_DATA_PATH. Keep a
|
||||
@@ -65,6 +62,10 @@ function resolveOriginalZdotdir(): string {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOriginalZshenvSourceDir(): string {
|
||||
return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || ''
|
||||
}
|
||||
|
||||
function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] {
|
||||
return [
|
||||
join(root, 'zsh', '.zshenv'),
|
||||
@@ -249,42 +250,7 @@ function ensureShellReadyWrappers(): void {
|
||||
const zshDir = join(root, 'zsh')
|
||||
const bashDir = join(root, 'bash')
|
||||
|
||||
const zshEnv = `# Orca daemon zsh shell-ready wrapper
|
||||
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
|
||||
# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom
|
||||
# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its
|
||||
# preferred dir; pre-setting it (even to HOME) defeats that default.
|
||||
unset ZDOTDIR
|
||||
# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper.
|
||||
# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and
|
||||
# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\`
|
||||
# or \`export\` to escape.
|
||||
__orca_source_user_zshenv() {
|
||||
# Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent
|
||||
# shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still
|
||||
# get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env
|
||||
# ZDOTDIR was inherited.
|
||||
local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
|
||||
[[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv"
|
||||
}
|
||||
__orca_source_user_zshenv
|
||||
unfunction __orca_source_user_zshenv
|
||||
# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve
|
||||
# the spawn-env value (an inherited resolution from a parent Orca PTY);
|
||||
# else HOME.
|
||||
export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}"
|
||||
unset _orca_spawn_orig_zdotdir
|
||||
# Why: strip trailing slashes (matches Node-side normalizer) before the
|
||||
# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing
|
||||
# slashes still gets normalized away from .zprofile/.zshrc/.zlogin.
|
||||
while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do
|
||||
ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}"
|
||||
done
|
||||
case "\${ORCA_ORIG_ZDOTDIR}" in
|
||||
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
|
||||
esac
|
||||
export ZDOTDIR=${quotePosixSingle(zshDir)}
|
||||
`
|
||||
const zshEnv = getZshEnvTemplate(zshDir, 'daemon')
|
||||
const zshProfile = `# Orca daemon zsh shell-ready wrapper
|
||||
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
case "\${_orca_home%/}" in
|
||||
@@ -338,10 +304,25 @@ fi
|
||||
[join(bashDir, 'rcfile'), bashRc]
|
||||
] as const
|
||||
|
||||
for (const [path, content] of files) {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content, 'utf8')
|
||||
chmodSync(path, 0o644)
|
||||
try {
|
||||
for (const [path, content] of files) {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content, 'utf8')
|
||||
chmodSync(path, 0o644)
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: wrapper file creation can fail due to read-only filesystems, permission
|
||||
// issues, or disk space. Rather than crashing, log the error and continue.
|
||||
// The shell will launch without the wrapper, which means no shell-ready marker
|
||||
// but at least the PTY is usable.
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})`
|
||||
: String(error)
|
||||
console.error(`[daemon/shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`)
|
||||
console.error('[daemon/shell-ready] Shell will launch without wrapper (no shell-ready marker)')
|
||||
// Reset the flag so next attempt will try again
|
||||
didEnsureShellReadyWrappers = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +361,7 @@ function getWrappedShellLaunchConfig(
|
||||
args: ['-l'],
|
||||
env: {
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
|
||||
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
|
||||
ZDOTDIR: join(root, 'zsh'),
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Example test using the shell-script-literal framework.
|
||||
*
|
||||
* This demonstrates the pattern for future shell-ready tests.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { shellScriptTest } from './shell-ready-framework/shell-script-test'
|
||||
|
||||
const { getUserDataPathMock } = vi.hoisted(() => ({
|
||||
getUserDataPathMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: (name: string) => {
|
||||
if (name === 'userData') {
|
||||
return getUserDataPathMock()
|
||||
}
|
||||
throw new Error(`unexpected app.getPath(${name})`)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
|
||||
describePosix('shell-script-literal framework example', () => {
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'shell-test-userdata-'))
|
||||
getUserDataPathMock.mockReturnValue(userDataPath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers ZDOTDIR when .zshenv sources another file', async () => {
|
||||
const { stdout } = await shellScriptTest(
|
||||
`
|
||||
# Setup multi-file config
|
||||
mkdir -p ~/.config/zsh
|
||||
cat > ~/.config/zsh/env <<'EOF'
|
||||
export ZDOTDIR="$HOME/.config/zsh"
|
||||
EOF
|
||||
|
||||
cat > ~/.zshenv <<'EOF'
|
||||
source "$HOME/.config/zsh/env"
|
||||
EOF
|
||||
|
||||
# Run: check discovered ZDOTDIR
|
||||
zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort'
|
||||
`,
|
||||
{ userDataPath }
|
||||
)
|
||||
|
||||
expect(stdout).toMatchInlineSnapshot(`
|
||||
"HOME=<HOME>
|
||||
ZDOTDIR=<WRAPPER_DIR>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
it('handles conditional ZDOTDIR based on SSH_CONNECTION', async () => {
|
||||
const { stdout } = await shellScriptTest(
|
||||
`
|
||||
mkdir -p ~/.config/zsh-local ~/.config/zsh-remote
|
||||
cat > ~/.zshenv <<'EOF'
|
||||
if [[ -n "$SSH_CONNECTION" ]]; then
|
||||
export ZDOTDIR="$HOME/.config/zsh-remote"
|
||||
else
|
||||
export ZDOTDIR="$HOME/.config/zsh-local"
|
||||
fi
|
||||
EOF
|
||||
|
||||
# Run with SSH_CONNECTION set
|
||||
SSH_CONNECTION='192.168.1.100 52100 192.168.1.1 22' zsh -c 'env | grep ZDOTDIR | sort'
|
||||
`,
|
||||
{ userDataPath }
|
||||
)
|
||||
|
||||
expect(stdout).toMatchInlineSnapshot(`
|
||||
"ORCA_ORIG_ZDOTDIR=<HOME>/.config/zsh-remote
|
||||
ZDOTDIR=<WRAPPER_DIR>
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
# Shell-Script-Literal Test Framework
|
||||
|
||||
Framework for writing shell-ready tests as literal shell scripts that can be copy-pasted into a terminal to replicate.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { shellScriptTest } from '../__tests__/shell-ready-framework/shell-script-test'
|
||||
|
||||
it('discovers ZDOTDIR from multi-file config', async () => {
|
||||
const { stdout } = await shellScriptTest(`
|
||||
# Setup multi-file config
|
||||
mkdir -p ~/.config/zsh
|
||||
cat > ~/.config/zsh/env <<'EOF'
|
||||
export ZDOTDIR="$HOME/.config/zsh"
|
||||
EOF
|
||||
|
||||
cat > ~/.zshenv <<'EOF'
|
||||
source "$HOME/.config/zsh/env"
|
||||
EOF
|
||||
|
||||
# Run: check discovered ZDOTDIR
|
||||
zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort'
|
||||
`)
|
||||
|
||||
expect(stdout).toMatchInlineSnapshot(`
|
||||
"HOME=<HOME>
|
||||
ORCA_ORIG_ZDOTDIR=<HOME>/.config/zsh
|
||||
ZDOTDIR=<WRAPPER_DIR>
|
||||
"
|
||||
`)
|
||||
})
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Creates temp directories** for `$HOME` and Orca's `userDataPath`
|
||||
|
||||
2. **Splits the script** on the `# Run:` marker:
|
||||
- Lines before the marker → setup commands
|
||||
- Lines after the marker → run command to test
|
||||
|
||||
3. **Gets Orca's wrapper config** by calling `getShellReadyLaunchConfig()`
|
||||
|
||||
4. **Executes setup** (if present) with bash in temp HOME, using wrapper env
|
||||
|
||||
5. **Executes run command** with the wrapper's shell + args + env
|
||||
|
||||
6. **Normalizes output** by replacing temp paths with placeholders:
|
||||
- Temp HOME → `<HOME>`
|
||||
- Wrapper dir → `<WRAPPER_DIR>`
|
||||
- Actual user HOME → `<USER_HOME>`
|
||||
|
||||
7. **Cleans up** temp directories
|
||||
|
||||
8. **Returns** stdout/stderr/exitCode ready for snapshot testing
|
||||
|
||||
## Supported shell syntax
|
||||
|
||||
**All shell syntax is supported** because the script is executed directly by bash/zsh, not parsed:
|
||||
|
||||
- Heredocs (any delimiter, quoted or unquoted)
|
||||
- Pipes, redirects, command substitution
|
||||
- Conditionals (`if`, `[[ ]]`, `&&`, `||`)
|
||||
- Loops, functions, variables
|
||||
- Any valid shell script
|
||||
|
||||
## Manual replication
|
||||
|
||||
To manually replicate a test scenario, copy the shell commands from the test:
|
||||
|
||||
```bash
|
||||
# Setup commands (before # Run: marker):
|
||||
mkdir -p ~/.config/zsh
|
||||
cat > ~/.config/zsh/env <<'EOF'
|
||||
export ZDOTDIR="$HOME/.config/zsh"
|
||||
EOF
|
||||
|
||||
cat > ~/.zshenv <<'EOF'
|
||||
source "$HOME/.config/zsh/env"
|
||||
EOF
|
||||
|
||||
# Run command (after # Run: marker):
|
||||
zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort'
|
||||
```
|
||||
|
||||
**Note**: The test framework applies Orca's wrapper configuration (sets `ZDOTDIR` to wrapper directory, etc.). When running manually, you'll see different output unless you also configure the wrapper environment.
|
||||
|
||||
## Snapshot testing
|
||||
|
||||
Use `toMatchInlineSnapshot()` to keep expected output visible in the test file:
|
||||
|
||||
```typescript
|
||||
expect(stdout).toMatchInlineSnapshot(`
|
||||
"HOME=<HOME>
|
||||
ORCA_ORIG_ZDOTDIR=<HOME>/.config/zsh
|
||||
ZDOTDIR=<WRAPPER_DIR>
|
||||
"
|
||||
`)
|
||||
```
|
||||
|
||||
Update snapshots with `vitest -u`.
|
||||
|
||||
## When to use this framework
|
||||
|
||||
Use this framework for **new shell-ready tests** where:
|
||||
- You want the test to be easy to replicate manually
|
||||
- The setup is shell-script-based (file creation, env vars)
|
||||
- You want a declarative snapshot-driven style
|
||||
|
||||
**Don't migrate existing tests** - this framework is opt-in for new tests only.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { tmpdir } from 'os'
|
||||
import { join, basename } from 'path'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { getShellReadyLaunchConfig } from '../../local-pty-shell-ready'
|
||||
import { escapeRegex } from '../../../../shared/string-utils'
|
||||
|
||||
const RUN_MARKER = /^[ \t]*#[ \t]*Run:.*$/m
|
||||
|
||||
/**
|
||||
* Shell-script-literal test framework for shell-ready tests.
|
||||
*
|
||||
* Takes shell scripts as string literals that can be literally copy-pasted
|
||||
* into a terminal to replicate the test scenario.
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* const { stdout } = await shellScriptTest(`
|
||||
* mkdir -p ~/.config/zsh
|
||||
* cat > ~/.zshenv <<'EOF'
|
||||
* export ZDOTDIR="$HOME/.config/zsh"
|
||||
* EOF
|
||||
*
|
||||
* zsh -c 'env | grep ZDOTDIR'
|
||||
* `, { userDataPath })
|
||||
* expect(stdout).toMatchInlineSnapshot(...)
|
||||
* ```
|
||||
*/
|
||||
|
||||
export type ShellScriptTestResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export type ShellScriptTestOptions = {
|
||||
userDataPath?: string
|
||||
shell?: string
|
||||
}
|
||||
|
||||
function detectShellFromCommand(command: string, fallback: string): string {
|
||||
const shellMatch = command.match(/(?:^|\s)((?:\/[\w/-]+\/)?(?:zsh|bash|sh))\s/)
|
||||
return shellMatch ? shellMatch[1] : fallback
|
||||
}
|
||||
|
||||
export async function shellScriptTest(
|
||||
script: string,
|
||||
options: ShellScriptTestOptions = {}
|
||||
): Promise<ShellScriptTestResult> {
|
||||
const testHome = mkdtempSync(join(tmpdir(), 'shell-test-home-'))
|
||||
const userDataPath = options.userDataPath || mkdtempSync(join(tmpdir(), 'shell-test-userdata-'))
|
||||
const cleanupUserDataPath = !options.userDataPath
|
||||
|
||||
try {
|
||||
const parts = script.split(RUN_MARKER)
|
||||
const hasRunMarker = parts.length === 2
|
||||
const setupScript = hasRunMarker ? parts[0].trim() : ''
|
||||
const runScript = hasRunMarker ? parts[1].trim() : script.trim()
|
||||
|
||||
const wrapperShell = detectShellFromCommand(runScript, options.shell || '/bin/zsh')
|
||||
const config = getShellReadyLaunchConfig(wrapperShell)
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...config.env,
|
||||
HOME: testHome
|
||||
}
|
||||
|
||||
const spawnOptions = {
|
||||
env: env as NodeJS.ProcessEnv,
|
||||
cwd: testHome,
|
||||
encoding: 'utf8' as const
|
||||
}
|
||||
|
||||
if (setupScript) {
|
||||
const setupPath = join(testHome, '.setup.sh')
|
||||
writeFileSync(setupPath, setupScript, 'utf8')
|
||||
const setupResult = spawnSync('/bin/bash', [setupPath], spawnOptions)
|
||||
if (setupResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Setup script failed with exit code ${setupResult.status}\nstderr: ${setupResult.stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const runPath = join(testHome, '.run.sh')
|
||||
writeFileSync(runPath, runScript, 'utf8')
|
||||
const shellArgs = config.args ? [...config.args, runPath] : [runPath]
|
||||
const result = spawnSync(wrapperShell, shellArgs, spawnOptions)
|
||||
|
||||
const normalizationContext = {
|
||||
testHome,
|
||||
userDataPath,
|
||||
actualUserHome: process.env.HOME || '',
|
||||
shellName: basename(wrapperShell).toLowerCase()
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: normalizeOutput(result.stdout || '', normalizationContext),
|
||||
stderr: normalizeOutput(result.stderr || '', normalizationContext),
|
||||
exitCode: result.status ?? -1
|
||||
}
|
||||
} finally {
|
||||
rmSync(testHome, { recursive: true, force: true })
|
||||
if (cleanupUserDataPath) {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TEMP_PATH_PATTERN =
|
||||
/\/(?:var\/folders|tmp)\/[^\s]+?\/(?:shell-test|orca|shell-ready)-[a-z]+-[a-z0-9-]+/g
|
||||
const PID_PATTERN = /\bpid:\s*\d+/gi
|
||||
|
||||
function normalizeOutput(
|
||||
output: string,
|
||||
ctx: {
|
||||
testHome: string
|
||||
userDataPath: string
|
||||
actualUserHome: string
|
||||
shellName: string
|
||||
}
|
||||
): string {
|
||||
if (!output) {
|
||||
return output
|
||||
}
|
||||
|
||||
const wrapperDir = join(ctx.userDataPath, 'shell-ready', ctx.shellName)
|
||||
|
||||
const paths: { path: string; placeholder: string }[] = [
|
||||
{ path: wrapperDir, placeholder: '<WRAPPER_DIR>' },
|
||||
{ path: ctx.testHome, placeholder: '<HOME>' }
|
||||
]
|
||||
|
||||
if (ctx.actualUserHome) {
|
||||
paths.push({ path: ctx.actualUserHome, placeholder: '<USER_HOME>' })
|
||||
}
|
||||
|
||||
const replacements = paths
|
||||
.sort((a, b) => b.path.length - a.path.length)
|
||||
.map(({ path, placeholder }) => ({
|
||||
pattern: new RegExp(escapeRegex(path), 'g'),
|
||||
placeholder
|
||||
}))
|
||||
|
||||
let normalized = output
|
||||
for (const { pattern, placeholder } of replacements) {
|
||||
normalized = normalized.replace(pattern, placeholder)
|
||||
}
|
||||
|
||||
normalized = normalized.replace(TEMP_PATH_PATTERN, '<TEMP_PATH>')
|
||||
normalized = normalized.replace(PID_PATTERN, 'pid: <PID>')
|
||||
|
||||
return normalized
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { tmpdir } from 'os'
|
||||
import { basename, win32 as pathWin32 } from 'path'
|
||||
import { mkdirSync, writeFileSync, chmodSync } from 'fs'
|
||||
import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import type * as pty from 'node-pty'
|
||||
import {
|
||||
@@ -21,13 +21,10 @@ import {
|
||||
isPowerShellExecutableName
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { getZshEnvTemplate } from '../shell-templates'
|
||||
|
||||
let didEnsureShellReadyWrappers = false
|
||||
|
||||
function quotePosixSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500
|
||||
const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready'
|
||||
const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007'
|
||||
@@ -86,6 +83,20 @@ function getShellReadyWrapperRoot(): string {
|
||||
return `${userDataPath}/shell-ready`
|
||||
}
|
||||
|
||||
function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] {
|
||||
return [
|
||||
`${root}/zsh/.zshenv`,
|
||||
`${root}/zsh/.zprofile`,
|
||||
`${root}/zsh/.zshrc`,
|
||||
`${root}/zsh/.zlogin`,
|
||||
`${root}/bash/rcfile`
|
||||
]
|
||||
}
|
||||
|
||||
function shellReadyWrappersExist(): boolean {
|
||||
return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path))
|
||||
}
|
||||
|
||||
// Why: if our own process inherited ZDOTDIR from a parent shell that was
|
||||
// itself an Orca PTY (e.g. the user launched `pn dev` from a terminal inside
|
||||
// a running Orca), that ZDOTDIR points at an Orca shell-ready wrapper dir.
|
||||
@@ -123,6 +134,10 @@ function resolveOriginalZdotdir(): string {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOriginalZshenvSourceDir(): string {
|
||||
return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || ''
|
||||
}
|
||||
|
||||
export function getBashShellReadyRcfileContent(): string {
|
||||
return `# Orca bash shell-ready wrapper
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
@@ -288,7 +303,10 @@ preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]})
|
||||
}
|
||||
|
||||
function ensureShellReadyWrappers(): void {
|
||||
if (didEnsureShellReadyWrappers || process.platform === 'win32') {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) {
|
||||
return
|
||||
}
|
||||
didEnsureShellReadyWrappers = true
|
||||
@@ -297,42 +315,7 @@ function ensureShellReadyWrappers(): void {
|
||||
const zshDir = `${root}/zsh`
|
||||
const bashDir = `${root}/bash`
|
||||
|
||||
const zshEnv = `# Orca zsh shell-ready wrapper
|
||||
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
|
||||
# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom
|
||||
# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its
|
||||
# preferred dir; pre-setting it (even to HOME) defeats that default.
|
||||
unset ZDOTDIR
|
||||
# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper.
|
||||
# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and
|
||||
# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\`
|
||||
# or \`export\` to escape.
|
||||
__orca_source_user_zshenv() {
|
||||
# Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent
|
||||
# shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still
|
||||
# get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env
|
||||
# ZDOTDIR was inherited.
|
||||
local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
|
||||
[[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv"
|
||||
}
|
||||
__orca_source_user_zshenv
|
||||
unfunction __orca_source_user_zshenv
|
||||
# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve
|
||||
# the spawn-env value (an inherited resolution from a parent Orca PTY);
|
||||
# else HOME.
|
||||
export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}"
|
||||
unset _orca_spawn_orig_zdotdir
|
||||
# Why: strip trailing slashes (matches Node-side normalizer) before the
|
||||
# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing
|
||||
# slashes still gets normalized away from .zprofile/.zshrc/.zlogin.
|
||||
while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do
|
||||
ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}"
|
||||
done
|
||||
case "\${ORCA_ORIG_ZDOTDIR}" in
|
||||
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
|
||||
esac
|
||||
export ZDOTDIR=${quotePosixSingle(zshDir)}
|
||||
`
|
||||
const zshEnv = getZshEnvTemplate(zshDir)
|
||||
const zshProfile = `# Orca zsh shell-ready wrapper
|
||||
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
case "\${_orca_home%/}" in
|
||||
@@ -386,11 +369,26 @@ fi
|
||||
[`${bashDir}/rcfile`, bashRc]
|
||||
] as const
|
||||
|
||||
for (const [path, content] of files) {
|
||||
const dir = path.slice(0, path.lastIndexOf('/'))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(path, content, 'utf8')
|
||||
chmodSync(path, 0o644)
|
||||
try {
|
||||
for (const [path, content] of files) {
|
||||
const dir = path.slice(0, path.lastIndexOf('/'))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(path, content, 'utf8')
|
||||
chmodSync(path, 0o644)
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: wrapper file creation can fail due to read-only filesystems, permission
|
||||
// issues, or disk space. Rather than crashing, log the error and continue.
|
||||
// The shell will launch without the wrapper, which means no shell-ready marker
|
||||
// but at least the PTY is usable.
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})`
|
||||
: String(error)
|
||||
console.error(`[shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`)
|
||||
console.error('[shell-ready] Shell will launch without wrapper (no shell-ready marker)')
|
||||
// Reset the flag so next attempt will try again
|
||||
didEnsureShellReadyWrappers = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +412,7 @@ function getWrappedShellLaunchConfig(
|
||||
args: ['-l'],
|
||||
env: {
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
|
||||
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
|
||||
ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`,
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery;
|
||||
// small drift here breaks different terminal transports in different ways.
|
||||
|
||||
function quotePosixSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
|
||||
const header = headerPrefix
|
||||
? `Orca ${headerPrefix} zsh shell-ready wrapper`
|
||||
: 'Orca zsh shell-ready wrapper'
|
||||
return `# ${header}
|
||||
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
|
||||
_orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
|
||||
_orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"
|
||||
_orca_zshenv_path=""
|
||||
unset ORCA_ZSHENV_SOURCE_DIR
|
||||
|
||||
# Normalize fallback and source roots before reading user .zshenv so nested
|
||||
# Orca PTYs never source another Orca wrapper recursively.
|
||||
while [[ "\${_orca_user_zdotdir}" == */ ]]; do
|
||||
_orca_user_zdotdir="\${_orca_user_zdotdir%/}"
|
||||
done
|
||||
case "\${_orca_user_zdotdir}" in
|
||||
""|*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;
|
||||
esac
|
||||
while [[ "\${_orca_zshenv_source_dir}" == */ ]]; do
|
||||
_orca_zshenv_source_dir="\${_orca_zshenv_source_dir%/}"
|
||||
done
|
||||
case "\${_orca_zshenv_source_dir}" in
|
||||
""|*/shell-ready/zsh) _orca_zshenv_source_dir="$HOME" ;;
|
||||
esac
|
||||
|
||||
# Why: source at wrapper top level, not in a function/subshell, so .zshenv
|
||||
# exports, functions, path/fpath typesets, and zsh options keep normal scope.
|
||||
unset ZDOTDIR
|
||||
if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then
|
||||
_orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv"
|
||||
fi
|
||||
if [[ -n "\${_orca_zshenv_path:-}" ]]; then
|
||||
source "\${_orca_zshenv_path}"
|
||||
fi
|
||||
|
||||
_orca_discovered_zdotdir="\${ZDOTDIR:-}"
|
||||
|
||||
while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do
|
||||
_orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}"
|
||||
done
|
||||
|
||||
case "\${_orca_discovered_zdotdir}" in
|
||||
*[![:space:]]*) ;;
|
||||
*) _orca_discovered_zdotdir="" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then
|
||||
[[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2
|
||||
_orca_discovered_zdotdir=""
|
||||
fi
|
||||
|
||||
export ORCA_ORIG_ZDOTDIR="\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}"
|
||||
|
||||
while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do
|
||||
ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}"
|
||||
done
|
||||
|
||||
case "\${ORCA_ORIG_ZDOTDIR}" in
|
||||
""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
|
||||
esac
|
||||
|
||||
export ZDOTDIR=${quotePosixSingle(zshDir)}
|
||||
unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Escape special regex characters in a string for use in RegExp constructor.
|
||||
*
|
||||
* Why: When building a regex from user input or file paths, special regex
|
||||
* characters (. * + ? ^ $ { } ( ) | [ ] \) must be escaped to match literally.
|
||||
*
|
||||
* @param str - String to escape
|
||||
* @returns Escaped string safe for use in new RegExp()
|
||||
*/
|
||||
export function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
Reference in New Issue
Block a user