mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 05:41:29 +00:00
Review of T0 (Electron scaffold) approved with one Important finding + cheap
Minors. All fixes scoped to app/.
- usePolled: replace per-call cancel closure with a generation/epoch counter so
an orphaned in-flight fetch (from refresh() or an interval tick, discarded
cancel handle) can't resolve after a newer fetch and clobber fresh data. New
TDD race test (slow deps-A resolves after fast deps-B; keeps B).
- cli: nvm resolution now scans version dirs descending and takes the first
whose bin actually contains codeburn (was lexicographic max, unverified),
matching CodeburnCLI.swift. Export nodeManagerDirs + hermetic nvm test.
- index.html: tighten CSP connect-src ws: -> ws://localhost:5173.
- preload: import type { Envelope } from main instead of redeclaring it.
- main.test: table-driven channel->argv assertion over all 9 codeburn:* channels
plus a keys check and a non-spawning cliStatus case.
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { homedir, platform } from 'node:os'
|
|
import { delimiter, join } from 'node:path'
|
|
|
|
// Runs entirely in the Electron main process. This module must NOT import
|
|
// `electron` so it stays unit-testable in a plain node environment.
|
|
|
|
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout'
|
|
|
|
/** Structured failure so the renderer can pick the right empty/permission state. */
|
|
export class CliError extends Error {
|
|
readonly kind: CliErrorKind
|
|
constructor(kind: CliErrorKind, message: string) {
|
|
super(message)
|
|
this.name = 'CliError'
|
|
this.kind = kind
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TIMEOUT_MS = 45_000
|
|
|
|
// Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a
|
|
// GUI-launched app (minimal PATH) still finds a globally-installed `codeburn`.
|
|
export function nodeManagerDirs(): string[] {
|
|
const home = homedir()
|
|
const dirs = [
|
|
'/opt/homebrew/bin',
|
|
'/usr/local/bin',
|
|
join(home, '.volta', 'bin'),
|
|
join(home, '.npm-global', 'bin'),
|
|
join(home, '.asdf', 'shims'),
|
|
]
|
|
const nvmDir = process.env.NVM_DIR || join(home, '.nvm')
|
|
const nvmVersions = join(nvmDir, 'versions', 'node')
|
|
try {
|
|
// Scan version dirs newest-first and take the first whose bin actually holds
|
|
// `codeburn`. A lexicographic max ("v9" > "v22") is not a real "newest", and
|
|
// the top dir may not even contain the CLI — so verify, matching CodeburnCLI.swift.
|
|
const entries = readdirSync(nvmVersions).sort().reverse()
|
|
for (const entry of entries) {
|
|
const bin = join(nvmVersions, entry, 'bin')
|
|
if (isExecutableFile(join(bin, 'codeburn'))) {
|
|
dirs.push(bin)
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
// no nvm — ignore
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
/** The dirs searched for a `codeburn` executable. `CODEBURN_PATH_DIRS` overrides
|
|
* the whole search space (delimiter-separated) — used by tests and advanced setups. */
|
|
function searchDirs(): string[] {
|
|
const override = process.env.CODEBURN_PATH_DIRS
|
|
if (override !== undefined) return override.split(delimiter).filter(Boolean)
|
|
const pathDirs = (process.env.PATH || '').split(delimiter).filter(Boolean)
|
|
return [...pathDirs, ...nodeManagerDirs()]
|
|
}
|
|
|
|
function isExecutableFile(p: string): boolean {
|
|
try {
|
|
if (!statSync(p).isFile()) return false
|
|
accessSync(p, constants.X_OK)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Persisted-path file written by the (future) first-run "locate CLI" flow,
|
|
// mirroring the mac app's Application Support/CodeBurn/codeburn-cli-path.v1.
|
|
function persistedPathFile(): string {
|
|
const override = process.env.CODEBURN_CLI_PATH_FILE
|
|
if (override) return override
|
|
const home = homedir()
|
|
if (platform() === 'darwin') {
|
|
return join(home, 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
|
|
}
|
|
const base = process.env.XDG_CONFIG_HOME || join(home, '.config')
|
|
return join(base, 'CodeBurn', 'codeburn-cli-path.v1')
|
|
}
|
|
|
|
function readPersistedPath(): string | null {
|
|
try {
|
|
const file = persistedPathFile()
|
|
if (!existsSync(file)) return null
|
|
const value = readFileSync(file, 'utf-8').trim()
|
|
if (value && value.startsWith('/') && isExecutableFile(value)) return value
|
|
} catch {
|
|
// unreadable — fall through to PATH search
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path to the `codeburn` binary, or null if not found.
|
|
* Order: dev override (`CODEBURN_BIN`) → persisted-path file → PATH / brew /
|
|
* nvm / volta / asdf → null.
|
|
*/
|
|
export function resolveCodeburnPath(): string | null {
|
|
const override = process.env.CODEBURN_BIN
|
|
if (override && override.startsWith('/') && isExecutableFile(override)) return override
|
|
|
|
const persisted = readPersistedPath()
|
|
if (persisted) return persisted
|
|
|
|
for (const bin of searchDirs().map(dir => join(dir, 'codeburn'))) {
|
|
if (isExecutableFile(bin)) return bin
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Spawn `codeburn <args>` with plain argv (never a shell), collect stdout, and
|
|
* decode it as JSON. Rejects with a structured {@link CliError}:
|
|
* not-found no binary resolved
|
|
* nonzero process exited with a non-zero code (stderr surfaced)
|
|
* bad-json stdout was not valid JSON
|
|
* timeout the process was killed after `timeoutMs`
|
|
*/
|
|
export function spawnCli(args: string[], opts: { timeoutMs?: number } = {}): Promise<unknown> {
|
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
return new Promise<unknown>((resolve, reject) => {
|
|
const bin = resolveCodeburnPath()
|
|
if (!bin) {
|
|
reject(new CliError('not-found', 'codeburn CLI not found'))
|
|
return
|
|
}
|
|
|
|
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let settled = false
|
|
|
|
const finish = (fn: () => void) => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimeout(timer)
|
|
fn()
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
finish(() => {
|
|
child.kill('SIGKILL')
|
|
reject(new CliError('timeout', `codeburn ${args[0] ?? ''} timed out after ${timeoutMs}ms`))
|
|
})
|
|
}, timeoutMs)
|
|
|
|
child.stdout.on('data', chunk => { stdout += chunk })
|
|
child.stderr.on('data', chunk => { stderr += chunk })
|
|
|
|
child.on('error', err => {
|
|
finish(() => reject(new CliError('not-found', err.message)))
|
|
})
|
|
|
|
child.on('close', code => {
|
|
finish(() => {
|
|
if (code !== 0) {
|
|
reject(new CliError('nonzero', stderr.trim() || `codeburn exited with code ${code}`))
|
|
return
|
|
}
|
|
try {
|
|
resolve(JSON.parse(stdout))
|
|
} catch {
|
|
reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON'))
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|