mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 00:19:03 +02:00
feat(render): Aside-first local-HTML renderer with the bundled browser as fallback
lib/aside-render.ts serves the HTML's directory on loopback (Aside refuses file:// URLs), opens it with waitUntil load, prints through CDP Page.printToPDF so tagged output, outlines, header/footer templates and page numbers survive, emulates device metrics for sized screenshots, and writes in-page evaluations to files; when Aside is absent it runs the same spec through the browse daemon (newtab, load, js, pdf, screenshot, closetab) and reports ENGINE=aside|browse. bin/gstack-render.ts is the CLI skill templates call. lib/claude-bin.ts and lib/error-handling.ts become the canonical copies (browse/src re-exports them). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,73 +1,2 @@
|
||||
/**
|
||||
* claude-bin.ts — Cross-platform `claude` binary resolution.
|
||||
*
|
||||
* Uses Bun.which() for the platform handling (PATH parsing, Windows PATHEXT,
|
||||
* X_OK, case-insensitive Path/PATH on Windows). Adds the gstack-specific
|
||||
* override + arg-prefix logic on top.
|
||||
*
|
||||
* Override precedence:
|
||||
* 1. GSTACK_CLAUDE_BIN (or CLAUDE_BIN as fallback) — absolute path or
|
||||
* PATH-resolvable command. `wsl` resolves through Bun.which('wsl') just
|
||||
* like a bare `claude` lookup would.
|
||||
* 2. Plain `Bun.which('claude')` if no override is set.
|
||||
*
|
||||
* Arg prefix:
|
||||
* GSTACK_CLAUDE_BIN_ARGS (or CLAUDE_BIN_ARGS) prepends arguments to every
|
||||
* spawn. Accepts a JSON array (e.g. '["claude", "--no-cache"]') or a single
|
||||
* scalar string treated as one argument. Only applied when an override is
|
||||
* active — bare `claude` resolution doesn't pick up an arg prefix.
|
||||
*
|
||||
* Returns null when nothing resolves; callers should degrade (e.g. transcript
|
||||
* classifier returns degraded:true) rather than throw.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
|
||||
export interface ClaudeCommand {
|
||||
command: string;
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
return value.replace(/^"(.*)"$/, '$1');
|
||||
}
|
||||
|
||||
function parseOverrideArgs(env: NodeJS.ProcessEnv): string[] {
|
||||
const raw = env.GSTACK_CLAUDE_BIN_ARGS ?? env.CLAUDE_BIN_ARGS;
|
||||
if (!raw?.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as a single scalar argument.
|
||||
}
|
||||
return [stripWrappingQuotes(raw.trim())];
|
||||
}
|
||||
|
||||
export function resolveClaudeCommand(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ClaudeCommand | null {
|
||||
const argsPrefix = parseOverrideArgs(env);
|
||||
const override = (env.GSTACK_CLAUDE_BIN ?? env.CLAUDE_BIN)?.trim();
|
||||
// Honor case-insensitive Path/PATH on Windows. Bun.which itself reads
|
||||
// process.env so we forward whichever the caller passed.
|
||||
const PATH = env.PATH ?? env.Path ?? '';
|
||||
|
||||
if (override) {
|
||||
const trimmed = stripWrappingQuotes(override);
|
||||
// Absolute path: use as-is. Otherwise PATH-resolve through Bun.which so
|
||||
// overrides like GSTACK_CLAUDE_BIN=wsl find the actual binary.
|
||||
const resolved = path.isAbsolute(trimmed) ? trimmed : Bun.which(trimmed, { PATH });
|
||||
return resolved ? { command: resolved, argsPrefix } : null;
|
||||
}
|
||||
|
||||
const command = Bun.which('claude', { PATH });
|
||||
return command ? { command, argsPrefix: [] } : null;
|
||||
}
|
||||
|
||||
/** Convenience wrapper for callers that only need the command path. */
|
||||
export function resolveClaudeBinary(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
return resolveClaudeCommand(env)?.command ?? null;
|
||||
}
|
||||
// Canonical copy lives in lib/claude-bin.ts (shared by test helpers and scripts).
|
||||
export * from '../../lib/claude-bin';
|
||||
|
||||
@@ -1,72 +1,2 @@
|
||||
/**
|
||||
* Shared error-handling utilities for browse server and CLI.
|
||||
*
|
||||
* Each wrapper uses selective catches (checks err.code) to avoid masking
|
||||
* unexpected errors. Empty catches would be flagged by slop-scan.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
// ─── Filesystem ────────────────────────────────────────────────
|
||||
|
||||
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
|
||||
export function safeUnlink(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a file, ignoring ALL errors. Use only in best-effort cleanup (shutdown, emergency). */
|
||||
export function safeUnlinkQuiet(filePath: string): void {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
|
||||
// ─── Process ───────────────────────────────────────────────────
|
||||
|
||||
/** Send a signal to a process, ignoring ESRCH (already dead). Rethrows other errors. */
|
||||
export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ESRCH') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PID is alive. Pure boolean probe — never throws.
|
||||
*
|
||||
* Signal 0 on EVERY platform (#1952). Node maps `process.kill(pid, 0)` to an
|
||||
* OpenProcess existence check on Windows — and on Windows the browse daemon
|
||||
* runs under Node (dist/server-node.mjs + bun-polyfill, the documented
|
||||
* fallback for oven-sh/bun#4253) — so the POSIX idiom is portable here.
|
||||
*
|
||||
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and
|
||||
* string-match the CSV. That was wrong in two ways, both hit in production:
|
||||
*
|
||||
* 1. FALSE NEGATIVES UNDER LOAD (#2414/#2295): tasklist takes ~700-1700ms
|
||||
* on an idle box and far longer under memory pressure. A Bun.spawnSync
|
||||
* that hits its `timeout` still RETURNS, carrying partial stdout — so
|
||||
* the `.includes()` match came back false and a LIVE process was
|
||||
* reported dead. Callers that validate liveness before killing
|
||||
* (killAgentByRecord, the terminal-agent watchdog) then skipped the
|
||||
* kill and respawned around the survivor — one leaked terminal-agent
|
||||
* per tick, self-reinforcing (each orphan slows the next tasklist).
|
||||
* 2. A console window per probe (#1952): the watchdog blinked a conhost
|
||||
* window into the foreground every 60s for the whole session.
|
||||
*
|
||||
* Signal 0 spawns nothing, cannot time out, and is orders of magnitude
|
||||
* faster (~0.004ms vs ~270ms measured in #2414).
|
||||
*
|
||||
* EPERM means the process EXISTS but we lack rights to signal it. That is
|
||||
* alive — returning false there would reintroduce failure mode 1.
|
||||
*/
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return err?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
// Canonical copy lives in lib/error-handling.ts (shared by test helpers and scripts).
|
||||
export * from '../../lib/error-handling';
|
||||
|
||||
Reference in New Issue
Block a user