mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 23:30:09 +02:00
14fc0866d9
* docs(todos): P3 content-hash diagram render cache for make-pdf Deferred from the diagram-engine eng review (Codex outside-voice D7): repeat make-pdf runs re-render every fence; cache keyed on fence source + bundle version once multi-diagram docs make it worth building. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(diagram-render): offline mermaid+excalidraw render bundle for browse Single self-contained page (dist/diagram-render.html, 9.2MB, committed per eng-review D2) exposing __renderMermaid / __mermaidToExcalidraw / __excalidrawToSvg / __rasterize / __probeImage through browse load-html + js --out. Render contract per D3: securityLevel strict, per-fence ids, print-css font lock, htmlLabels off (canvas-taint-safe). Deterministic build (same sha twice); drift test pins dist == BUILD_INFO == package.json pins and rebuild-reproducibility when toolchain matches. Spike-proven offline: flowchart + sequence SVG, editable .excalidraw scene, 300dpi PNG. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(diagram-render): __downscaleRaster for print-resolution image normalization Data-URI rasters re-encode in their own format (JPEG stays JPEG at q0.9 — PNG-encoding photos bloats them) at an explicit target pixel width. Used by make-pdf's pre-pass for the 300dpi content-box ceiling (eng-review D4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(make-pdf): diagram pre-pass — mermaid/excalidraw fences render as vector SVG; local images inline as data URIs ```mermaid / ```excalidraw fences extract to placeholder tokens, render in one diagram-render bundle tab per run (reset contract: bundle page reloads after any render error), and substitute back as accessible <figure> blocks with the raw source preserved in a comment. Render failures produce a loud red diagnostic block, never silent raw code. render=false keeps a fence as code; title="..." becomes the aria-label and caption. Local images now actually render: page.setContent loads at about:blank (tab-session.ts:194), so relative paths silently 404'd before. The pre-pass resolves them against the markdown's directory, inlines as data URIs, probes intrinsic dimensions from the bytes (pure-TS PNG/JPEG/GIF/WebP/SVG sniffing), and downscales rasters wider than 2x the content box at 300dpi. Remote URLs warn (offline posture, --allow-network exempts); missing files get a visible placeholder; --strict hard-fails both for CI pipelines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(make-pdf): diagram pre-pass unit suite + e2e render gates 34 unit tests (fence extraction incl. nested/tilde/unclosed/render=false, info-string parsing, slot substitution, diagnostic/figure escaping + SVG script strip, byte-level dimension probing across 5 formats, content-box math, image inlining incl. strict/remote/missing/data-URI paths). E2E gate proves through the compiled binary: both fences render as vector text (id-collision check), raw mermaid ships only via render=false, broken fence yields the diagnostic block, and the relative fixture image rasterizes to colored pixels (CRITICAL regression for the about:blank image fix). --strict exits non-zero on a missing image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(make-pdf): width directives + conservative auto-landscape via CSS named pages `{width=full|<pct>|<dim>}` and `{page=landscape|portrait}` suffixes translate to data-gstack-* attrs in render() (before the sanitizer, which keeps data- attributes; unrecognized brace groups stay visible text). Default width rule needs no code: intrinsic CSS-px capped at the content box, never upscaled — figure img max-width owns it. Auto-landscape promotes a block to `@page wide { size: <pagesize> landscape }` only when aspect >= 1.8 AND intrinsic width > 2.5x the content box (~1600px on letter) AND diagram provenance (rendered fences) or a whole-word alt token (diagram|architecture|flowchart|chart|graph) for plain images. {page=...} forces or vetoes; fence info strings accept page=... too. preferCSSPageSize is passed to Chromium only when a promotion exists, so every other document prints exactly as before. False negatives are cheap; false positives feel broken (eng-review P4, Codex challenge accepted). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(make-pdf): width-policy unit suite + landscape e2e gate with negative fixtures 24 unit tests weighted toward the false-positive guards: wide screenshot without an alt hint stays portrait, sub-threshold and tall images stay portrait, deterministic 1560/1561px boundary, whole-word alt matching ('photographic' must not match 'graph'), page=portrait veto beats every heuristic, diagnostic blocks never promote. E2E gate asserts pdfinfo per-page boxes through the compiled binary: exactly 3 of 5 fixture blocks get landscape pages (alt-hinted image, directive-forced image, wide sequence diagram) while the unhinted screenshot and the veto'd diagram stay portrait — plus the --toc combo proving TOC and named-page landscape coexist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(make-pdf): --to html|docx output formats --to html writes the assembled self-contained document directly (no print round-trip): inline vector diagrams, data-URI images, zero network references, plus an @media screen layer for browser reading. --to docx is the content-fidelity export (eng-review P8): html-to-docx@1.8.0 (exact pin; pure JS, bun-compile-verified) maps headings/tables/code/lists; diagrams and SVG images rasterize at 300dpi of the content-box width via the render tab; diagnostic figures convert to plain p/pre so the converter can't silently drop an error. --format keeps its page-size-alias meaning; --to is the output format, and the CLI says so when confused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(make-pdf): format gate — html no-network-refs + docx zip content checks HTML: zero src/href network refs, no script/link tags, inline SVG diagrams, data-URI images, screen layer, diagnostic survives. DOCX: valid OOXML zip (document.xml + Content_Types), >=2 PNG media (diagram raster + fixture image), headings + render=false source + diagnostic text in document.xml, no leaked mermaid source from rendered fences. Plus --to validation UX. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(diagram): /diagram skill — English in, editable diagram triplet out New skill: agent authors mermaid from the user's description and renders the triplet through the offline diagram-render bundle in the browse daemon — .mmd source (the single source of truth), editable .excalidraw (opens at excalidraw.com, round-trips back through re-render), and SVG + PNG. Flowcharts convert to fully editable scenes; other mermaid types render with an explicit upstream-converter limitation note. Never ships an unrendered source file; offline is the contract (no CDN fallback). Inventory rows in AGENTS.md + docs/skills.md; generated SKILL.md + llms.txt via gen:skill-docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(diagram): paid E2E pair — gate triplet contract + periodic authoring judge diagram-triplet (gate, deterministic functional): a fresh claude -p agent following the skill extract must emit a parseable triplet — graph LR/TD in .mmd, excalidraw scene with >3 elements, SVG markup, PNG magic bytes. Verified live: pass, $0.17, 58s. diagram-authoring-quality (periodic, LLM-judged): faithfulness/labels/size rubric with a diagnostic-path cap, floor 6/10. Verified live: pass at exactly 6 with substantive critique. Touchfiles select both on diagram/** and lib/diagram-render/** changes; tier split per E2E_TIERS rules (eng-review D5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(diagram): register /diagram in the skill coverage matrix Gate: triplet contract + structural floor; periodic: authoring-quality judge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(make-pdf): typography scale-up, zero image truncation, landscape vertical centering Dogfooding round on the repo README surfaced four output-quality bugs: - Type was too small everywhere: body 11→12pt, h1 22→26pt, h2 15→18pt, cover title 32→56pt with poster spacing, cover meta 10→13pt, TOC 11→12pt with tighter leading, code 9.5→10.5pt, tables 10→11pt. - Zero image truncation, ever: the max-width cap was figure-scoped, but markdown images render as <p><img> — a 1850px GitHub screenshot ran off the page edge. Global img { max-width: 100%; height: auto; } cap. - hyphens: auto put real 'dif-\nferent' breaks into the PDF text layer the moment 12pt made lines wrap (combined-gate caught it). Clean copy-paste is the product contract; left-aligned rag doesn't need hyphenation → hyphens: manual. - Promoted landscape blocks now vertically center. CSS flex/min-height centering fragments into phantom empty landscape pages in Chromium (bisected: min-height at ANY value; 3 promotions printed 5 pages), so image-policy computes an inline margin-top from each block's known aspect ratio against the landscape content box instead — fragmentation handles margins fine. .page-wide also drops its explicit break-before/ after (the page-name change already breaks on both sides). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(make-pdf): pin zero-truncation invariant, typography floor, centering math Global img cap pinned as a regex invariant (the figure-scoped-cap regression class); typography floor (12pt body, 56pt cover, 12pt TOC); .page-wide must NOT carry min-height/flex (the phantom-landscape-page regression class); centering margin math verified both ways (2400×1000 image → 1.38in, 2050×600 viewBox diagram → 1.93in, page-filling directive block → no margin). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: diagram + multi-format documentation across README, make-pdf skill, and how-to guide README gains /make-pdf (Publisher) and /diagram (Diagram Maker) rows in the sprint table. make-pdf's skill doc — the agent-facing contract — gains Core patterns for mermaid/excalidraw fences (title/render=false/page= options), the image policy ({width=}/{page=} directives, zero-truncation, conservative auto-landscape), --to html|docx, and --strict, plus the --to vs --format disambiguation in Common flags. New docs/howto-diagrams-and-formats.md is the user-facing walkthrough: fences, directives, formats, /diagram triplet, the mermaid racetrack trick, troubleshooting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(make-pdf): fill ship-audit coverage gaps — downscale, reset contract, excalidraw fence, WebP Ship coverage audit found 9 gaps (85%); this fills the 2 HIGH + 3 MEDIUM and most LOW. diagram-gate fixture gains a 4200px incompressible photo (the only live coverage of __downscaleRaster AND the 64KB chunked jsViaBuffer eval transport — asserted via the downscale stderr warning), an ```excalidraw scene fence rendered through exportToSvg (vector labels + caption in pdftotext, no leaked scene JSON), and the broken fence MOVED BETWEEN the two mermaid fences so the second diagram rendering proves the D6.2 reset contract end-to-end. New coverage-gaps.test.ts (16 tests): mock-tab reset contract (exactly one reload, post-failure fence renders), excalidraw fail-fast diagnostic without a bundle call, rasterize error fallbacks (figure/tag kept, never silent), WebP VP8/VP8L/VP8X byte parsers, landscapeContentBox a4/asymmetric margins, bare-token slot fallback, resolveBundlePath env override + error shape, screenCss media scoping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(make-pdf): pre-landing review wave — fence fidelity, injection hardening, Windows paths, transport rework Review army (6 specialists + red team) findings, all fixed: - Indented fences replay byte-for-byte and indented diagram fences are NOT extracted (red-team conf-9: the pre-pass reconstructed fences at column 0, splitting any list containing fenced code — every ordinary document). - String.replace $-pattern injection killed at every seam: substituteSlots, mergeStyle, img/src rewrites all use function replacements (a diagram label containing $' duplicated the document tail). - Big-expression transport reworked: browse `eval <file>` (one spawn, any size, Windows-safe) replaces the 64KB chunked window-buffer eval — fixes the per-chunk spawn cost, the char-vs-byte argv units, AND the Windows 32,767-char command-line ceiling in one move. - Staged-bundle trust: content verified by hash even when the file exists, and the rename-failure path re-hashes the survivor (sticky-bit /tmp EPERM would otherwise ride a pre-planted file past the check). - Windows drive-letter img srcs (C:/x.png) reach the local-path branch instead of being swallowed as unknown URL schemes. - DOCX rasterize-failure now embeds the decoded source as visible text — returning the figure made diagrams vanish silently (converter drops svg). - Fence source preserved as base64 data-gstack-source attribute (the comment encoding corrupted every '-->' arrow); decodeFigureSource() round-trips. - inlineLocalImages memoizes per path; file:// uses fileURLToPath; preview prints a divergence note for fences/local images; --to docx strips the watermark div and warns about print-only flags; TOC links resolve in html/docx (heading ids assigned); waitForExpression sleeps instead of busy-spinning; escapeHtml/svg-dims deduped to single definitions; typography stragglers (blockquote 12pt, footnotes 10pt, 42em screen measure); bundle BUILD_INFO gains srcSha256 for no-node_modules drift detection; MAX_TARGET_PX shared guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: make-pdf gate covers the diagram-render bundle; bundle pinned to LF make-pdf-gate.yml paths gain lib/diagram-render/** and the drift test (a bundle-only PR previously skipped every render gate AND no CI lane ran the drift check at all). .gitattributes pins dist html/json to LF so Windows autocrlf can't break the hash-pinned bundle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(make-pdf)+feat(diagram): review-wave test pins + skill transport hardening Tests: indented-fence byte-for-byte replay + no-extraction-in-lists, drive-letter local-path routing, $-pattern slot immunity, base64 source round-trip ('A --> B' exact), existing-style merge preservation, DOCX rasterize-failure surfaces source, srcSha256 + font-stack drift guards, landscape veto asserted as some-portrait/no-landscape (layout-order-proof), judge rubric cap lowered to 5 so it actually fails, vacuous error-shape test removed honestly, tmpdir cleanup. /diagram skill: base64 transport (template literals corrupted backticks/${ in sources), content-addressed staging with hash verification, and --tab-id pinned on every browse call so a concurrent /qa session can't be clobbered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(make-pdf): out-of-tree image reads warn; --strict makes them fatal (D8.1) Local CLI semantics stay (absolute paths and ../ still inline, like pandoc), but never silently: an agent PDF-ing untrusted markdown can't quietly embed a file from outside the input directory into a shareable document without a visible warning, and --strict pipelines hard-fail. Two unit tests. Also: TODOS.md gains the deferred e2e-harness dedup entry (D8.2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-existing test failure in skill-e2e-bws operational-learning Root cause was the fixture, not model behavior: gstack-learnings-log gained an import of lib/jsonl-store.ts in the v1.57.5.0 injection-sanitization wave, but the test copies only bin/ scripts into its sandbox — the inline bun import failed and the script exited 1 before writing, on every run, on main too (reproduced ata5833c41). Fixture now stages lib/jsonl-store.ts beside bin/; verified deterministically (script exits 0, learning written) and via the paid test (1 pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(make-pdf): adversarial-review wave — offline posture enforced, symlink-aware confinement, bounded reads Codex adversarial + structured review findings: - Remote images are now BLOCKED with a visible placeholder instead of warn-and-keep — leaving the tag meant Chromium fetched the URL at print time anyway, so the offline posture was a lie (tracking pixels and internal-URL probes ran without --allow-network). - The out-of-tree read check compares REAL paths: a symlink inside the input dir pointing at ~/.ssh/... passed the string-prefix check, including under --strict. Ordered after the existence check (realpath of a missing file false-positives on macOS /var → /private/var). - Image reads are bounded BEFORE reading: statSync first, non-regular files (fifo/device/dir) and >64MB files degrade to placeholders instead of hanging or exhausting memory; malformed percent-encoding (foo%zz.png) degrades to missing-image instead of crashing decodeURIComponent. - browse shell-outs get a 120s timeout — a wedged daemon or hostile mermaid source fails the run instead of hanging it. - TOC entries link to the heading's ACTUAL id (pre-id'd raw-HTML headings previously got dead #toc-N links); per-side margins compose into the CSS @page shorthand so a landscape promotion flipping preferCSSPageSize no longer silently reverts --margin-left/right to defaults (Codex P2). - The image memo is a typed object — literal NUL-byte separators had made diagram-prepass.ts register as binary to text tooling. Codex structured review GATE: PASS (no P1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.58.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync make-pdf image-policy docs with final shipped behavior (v1.58.0.0) The docs wave (87594420) predated the final review-wave commits, so two docs drifted from shipped behavior: - make-pdf/SKILL.md.tmpl + generated SKILL.md: remote images are BLOCKED with a visible placeholder (not warned-and-kept); out-of-tree reads (including via symlink) warn and --strict makes them fatal; --strict also covers oversized (>64MB) and non-regular files; troubleshooting entry now names the actual "[remote image blocked]" symptom. - docs/howto-diagrams-and-formats.md: same corrections in the image section, CI section, and troubleshooting. - README.md: docs/howto-diagrams-and-formats.md added to the Docs table (was unreachable from any entry-point doc). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: apply Codex doc-review findings for v1.58.0.0 Cross-model doc review (Codex, read-only) checked the v1.58.0.0 docs against the shipped code. Fixes: - howto + make-pdf SKILL: diagram source is preserved base64 in a data-gstack-source attribute, not an HTML comment (-- in mermaid arrows would corrupt a comment); fences must start at column 0; fence options example gains page=portrait; --to html "zero network refs" qualified (--allow-network deliberately keeps remote tags). - /diagram description, README + docs/skills.md rows: the hand-drawn aesthetic belongs to the .excalidraw artifact; rendered SVG/PNG use mermaid's clean neutral theme (lib/diagram-render entry.ts pins theme: "neutral"). - CHANGELOG v1.58.0.0 wording: --strict coverage lists all five fatal classes (missing/remote/out-of-tree/oversized/non-regular); fences are vector SVG in pdf+html, 300dpi PNG in docx; hand-drawn claim scoped to the .excalidraw file. - lib/diagram-render/README: Page API table gains __downscaleRaster. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
/**
|
|
* Typed shell-out wrapper for the browse CLI.
|
|
*
|
|
* Every browse call goes through this file. Reasons:
|
|
* - One place to do binary resolution.
|
|
* - One place to enforce the --from-file convention for large payloads
|
|
* (Windows argv cap is 8191 chars; 200KB HTML dies without this).
|
|
* - One place that maps non-zero exit codes to typed errors.
|
|
*
|
|
* Binary resolution order (Codex round 2 #4, v1.24-aligned):
|
|
* 1. $GSTACK_BROWSE_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern)
|
|
* 2. $BROWSE_BIN env override (back-compat alias)
|
|
* 3. sibling dir: dirname(argv[0])/../browse/dist/browse[.exe]
|
|
* 4. ~/.claude/skills/gstack/browse/dist/browse[.exe]
|
|
* 5. PATH lookup via Bun.which('browse') — handles Windows PATHEXT natively
|
|
* 6. error with setup hint
|
|
*
|
|
* Windows quirks:
|
|
* - bun build --compile --outfile X emits X.exe on win32, so candidate paths
|
|
* need a .exe probe pass (fs.accessSync(X_OK) degrades to existence-checking
|
|
* on Windows per Node docs, so the bare path silently misses the .exe file).
|
|
* - `which` only exists in Git Bash; Bun.which() handles cmd.exe / PowerShell
|
|
* natively via PATHEXT semantics.
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import * as crypto from "node:crypto";
|
|
|
|
import { BrowseClientError } from "./types";
|
|
|
|
export interface LoadHtmlOptions {
|
|
html: string; // raw HTML string
|
|
waitUntil?: "load" | "domcontentloaded" | "networkidle";
|
|
tabId: number;
|
|
}
|
|
|
|
export interface PdfOptions {
|
|
output: string;
|
|
tabId: number;
|
|
format?: string;
|
|
width?: string;
|
|
height?: string;
|
|
marginTop?: string;
|
|
marginRight?: string;
|
|
marginBottom?: string;
|
|
marginLeft?: string;
|
|
headerTemplate?: string;
|
|
footerTemplate?: string;
|
|
pageNumbers?: boolean;
|
|
tagged?: boolean;
|
|
outline?: boolean;
|
|
printBackground?: boolean;
|
|
preferCSSPageSize?: boolean;
|
|
toc?: boolean;
|
|
}
|
|
|
|
export interface JsOptions {
|
|
tabId: number;
|
|
expression: string; // JS expression to evaluate
|
|
}
|
|
|
|
/**
|
|
* Resolve an absolute or PATH-resolvable command via Bun.which-style semantics,
|
|
* with a Windows .exe/.cmd/.bat extension probe for absolute paths. Mirrors
|
|
* the v1.24 claude-bin.ts override-resolution shape.
|
|
*
|
|
* Returns null if nothing resolves; callers degrade with a typed error rather
|
|
* than throwing here.
|
|
*/
|
|
function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null {
|
|
if (!value?.trim()) return null;
|
|
const trimmed = value.trim().replace(/^"(.*)"$/, '$1');
|
|
if (path.isAbsolute(trimmed)) return findExecutable(trimmed);
|
|
const PATH = env.PATH ?? env.Path ?? '';
|
|
return Bun.which(trimmed, { PATH }) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Probe a base path for executability, honoring Windows extension suffixes.
|
|
*
|
|
* On POSIX, isExecutable(base) is the only check that matters. On Windows,
|
|
* fs.accessSync(p, X_OK) degrades to an existence check — so a bare-path probe
|
|
* misses bun-compiled binaries (which land at base.exe). After the bare probe
|
|
* fails on win32, try .exe / .cmd / .bat. Linux/macOS behavior is unchanged.
|
|
*/
|
|
export function findExecutable(base: string): string | null {
|
|
if (isExecutable(base)) return base;
|
|
if (process.platform === "win32") {
|
|
for (const ext of [".exe", ".cmd", ".bat"]) {
|
|
const withExt = base + ext;
|
|
if (isExecutable(withExt)) return withExt;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Locate the browse binary. Throws a BrowseClientError with a
|
|
* canonical setup message if not found. See header for resolution order.
|
|
*/
|
|
export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env): string {
|
|
// 1 + 2: env overrides (GSTACK_BROWSE_BIN preferred, BROWSE_BIN back-compat).
|
|
const overrideRaw = env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN;
|
|
const override = resolveOverride(overrideRaw, env);
|
|
if (override) return override;
|
|
|
|
// 3: sibling — make-pdf and browse co-located in dist/.
|
|
const selfDir = path.dirname(process.argv[0]);
|
|
const siblingCandidates = [
|
|
path.resolve(selfDir, "../browse/dist/browse"),
|
|
path.resolve(selfDir, "../../browse/dist/browse"),
|
|
path.resolve(selfDir, "../browse"),
|
|
];
|
|
for (const candidate of siblingCandidates) {
|
|
const found = findExecutable(candidate);
|
|
if (found) return found;
|
|
}
|
|
|
|
// 4: global install.
|
|
const home = os.homedir();
|
|
const globalPath = path.join(home, ".claude/skills/gstack/browse/dist/browse");
|
|
const globalFound = findExecutable(globalPath);
|
|
if (globalFound) return globalFound;
|
|
|
|
// 5: PATH lookup via Bun.which — handles Windows PATHEXT natively (no `which`
|
|
// dependency on cmd.exe / PowerShell, no `where`-vs-`which` branch).
|
|
const PATH = env.PATH ?? env.Path ?? '';
|
|
const onPath = Bun.which('browse', { PATH });
|
|
if (onPath) return onPath;
|
|
|
|
throw new BrowseClientError(
|
|
/* exitCode */ 127,
|
|
"resolve",
|
|
[
|
|
"browse binary not found.",
|
|
"",
|
|
"make-pdf needs browse (the gstack Chromium daemon) to render PDFs.",
|
|
"Tried:",
|
|
` - $GSTACK_BROWSE_BIN (${env.GSTACK_BROWSE_BIN || "unset"})`,
|
|
` - $BROWSE_BIN (${env.BROWSE_BIN || "unset"})`,
|
|
` - sibling: ${siblingCandidates.join(", ")}`,
|
|
` - global: ${globalPath}`,
|
|
" - PATH: `browse`",
|
|
"",
|
|
"To fix: run gstack setup from the gstack repo:",
|
|
" cd ~/.claude/skills/gstack && ./setup",
|
|
"",
|
|
"Or set GSTACK_BROWSE_BIN explicitly:",
|
|
process.platform === "win32"
|
|
? ' setx GSTACK_BROWSE_BIN "C:\\path\\to\\browse.exe"'
|
|
: " export GSTACK_BROWSE_BIN=/path/to/browse",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function isExecutable(p: string): boolean {
|
|
try {
|
|
fs.accessSync(p, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a browse command. Returns stdout on success.
|
|
* Throws BrowseClientError on non-zero exit.
|
|
*/
|
|
function runBrowse(args: string[]): string {
|
|
const bin = resolveBrowseBin();
|
|
try {
|
|
return execFileSync(bin, args, {
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024, // 16MB; tab content can be large
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
// A wedged daemon (or a hostile mermaid source spinning the renderer)
|
|
// must fail the run, not hang it forever.
|
|
timeout: 120_000,
|
|
});
|
|
} catch (err: any) {
|
|
const exitCode = typeof err.status === "number" ? err.status : 1;
|
|
const stderr = typeof err.stderr === "string"
|
|
? err.stderr
|
|
: (err.stderr?.toString() ?? "");
|
|
throw new BrowseClientError(exitCode, args[0] || "unknown", stderr);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a payload to a tmp file and return the path. Used for any payload
|
|
* >4KB to avoid Windows argv limits (Codex round 2 #3).
|
|
*
|
|
* Path must be under the browse safe-dirs allowlist (/tmp or cwd on
|
|
* non-Windows; os.tmpdir on Windows). v1.6.0.0 tightened --from-file
|
|
* validation to close a CLI/API parity gap (PR #1103), so os.tmpdir()
|
|
* on macOS (/var/folders/...) now fails validateReadPath. Use the same
|
|
* TEMP_DIR convention as browse/src/platform.ts.
|
|
*/
|
|
const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
|
|
|
function writePayloadFile(payload: Record<string, unknown>): string {
|
|
const hash = crypto.createHash("sha256")
|
|
.update(JSON.stringify(payload))
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
const tmpPath = path.join(PAYLOAD_TMP_DIR, `make-pdf-browse-${process.pid}-${hash}.json`);
|
|
fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
|
|
return tmpPath;
|
|
}
|
|
|
|
function cleanupPayloadFile(p: string): void {
|
|
try { fs.unlinkSync(p); } catch { /* best-effort */ }
|
|
}
|
|
|
|
// ─── Public API ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open a new tab. Returns the tabId.
|
|
* Requires `$B newtab --json` to be available (added in the browse flag
|
|
* extension for this feature). If --json isn't supported yet, the fallback
|
|
* parses "Opened tab N" from stdout.
|
|
*/
|
|
export function newtab(url?: string): number {
|
|
const args = ["newtab"];
|
|
if (url) args.push(url);
|
|
// Try --json first (preferred path for programmatic use)
|
|
try {
|
|
const out = runBrowse([...args, "--json"]);
|
|
const parsed = JSON.parse(out);
|
|
if (typeof parsed.tabId === "number") return parsed.tabId;
|
|
} catch {
|
|
// Fall back to stdout-string parsing. Brittle, but works on older browse builds.
|
|
}
|
|
const out = runBrowse(args);
|
|
const m = out.match(/tab\s+(\d+)/i);
|
|
if (!m) throw new BrowseClientError(1, "newtab", `could not parse tab id from: ${out}`);
|
|
return parseInt(m[1], 10);
|
|
}
|
|
|
|
/**
|
|
* Close a tab (by id or the active tab).
|
|
*/
|
|
export function closetab(tabId?: number): void {
|
|
const args = ["closetab"];
|
|
if (tabId !== undefined) args.push(String(tabId));
|
|
runBrowse(args);
|
|
}
|
|
|
|
/**
|
|
* Load raw HTML into a specific tab.
|
|
* Uses --from-file for any payload >4KB (Codex round 2 #3).
|
|
*/
|
|
export function loadHtml(opts: LoadHtmlOptions): void {
|
|
// Always use --from-file to dodge argv limits. The HTML is almost always >4KB.
|
|
const payload = {
|
|
html: opts.html,
|
|
waitUntil: opts.waitUntil ?? "domcontentloaded",
|
|
};
|
|
const payloadFile = writePayloadFile(payload);
|
|
try {
|
|
runBrowse([
|
|
"load-html",
|
|
"--from-file", payloadFile,
|
|
"--tab-id", String(opts.tabId),
|
|
]);
|
|
} finally {
|
|
cleanupPayloadFile(payloadFile);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load an HTML file (already under browse's safe dirs, e.g. /tmp) into a tab
|
|
* by path. Cheaper than loadHtml for large pages — no JSON payload round-trip;
|
|
* browse reads the file directly (diagram-render bundle is ~9MB).
|
|
*/
|
|
export function loadHtmlFile(opts: { file: string; tabId: number; waitUntil?: "load" | "domcontentloaded" | "networkidle" }): void {
|
|
const args = ["load-html", opts.file, "--tab-id", String(opts.tabId)];
|
|
if (opts.waitUntil) args.push("--wait-until", opts.waitUntil);
|
|
runBrowse(args);
|
|
}
|
|
|
|
/**
|
|
* Evaluate a JS expression in a tab. Returns the serialized result as string.
|
|
*/
|
|
export function js(opts: JsOptions): string {
|
|
return runBrowse([
|
|
"js",
|
|
opts.expression,
|
|
"--tab-id", String(opts.tabId),
|
|
]).trim();
|
|
}
|
|
|
|
/**
|
|
* Evaluate a JS file in a tab (`browse eval <file>`): the argv-safe transport
|
|
* for expressions too large for a command-line element. The file must live
|
|
* under browse's safe dirs (/tmp or cwd).
|
|
*/
|
|
export function evalFile(opts: { file: string; tabId: number }): string {
|
|
return runBrowse([
|
|
"eval",
|
|
opts.file,
|
|
"--tab-id", String(opts.tabId),
|
|
]).trim();
|
|
}
|
|
|
|
/**
|
|
* Poll a boolean JS expression until it evaluates to true, or timeout.
|
|
* Returns true if it succeeded, false if timed out.
|
|
*/
|
|
export function waitForExpression(opts: {
|
|
expression: string;
|
|
tabId: number;
|
|
timeoutMs: number;
|
|
pollIntervalMs?: number;
|
|
}): boolean {
|
|
const poll = opts.pollIntervalMs ?? 200;
|
|
const deadline = Date.now() + opts.timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const result = js({ expression: opts.expression, tabId: opts.tabId });
|
|
if (result === "true") return true;
|
|
} catch {
|
|
// Tab may still be loading; keep polling
|
|
}
|
|
const wait = Math.min(poll, Math.max(0, deadline - Date.now()));
|
|
if (wait <= 0) break;
|
|
// Real sleep, not a busy-wait: this poll now runs on every diagram-render
|
|
// bundle load (and after every fence render error), exactly while Chromium
|
|
// is parsing a 9MB page on the same machine — spinning a core competes
|
|
// with the work being awaited.
|
|
Bun.sleepSync(wait);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Generate a PDF from the given tab. Uses --from-file when header/footer
|
|
* templates are present (they can be HTML strings of arbitrary size).
|
|
*/
|
|
export function pdf(opts: PdfOptions): void {
|
|
// If any large payload is present, send via --from-file
|
|
const hasLargePayload =
|
|
(opts.headerTemplate && opts.headerTemplate.length > 1024) ||
|
|
(opts.footerTemplate && opts.footerTemplate.length > 1024);
|
|
|
|
if (hasLargePayload) {
|
|
const payloadFile = writePayloadFile({
|
|
output: opts.output,
|
|
tabId: opts.tabId,
|
|
...optionsToPdfFlags(opts),
|
|
});
|
|
try {
|
|
runBrowse(["pdf", "--from-file", payloadFile]);
|
|
} finally {
|
|
cleanupPayloadFile(payloadFile);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Small payload: pass flags via argv
|
|
const args = ["pdf", opts.output, "--tab-id", String(opts.tabId)];
|
|
pushFlagsFromOptions(args, opts);
|
|
runBrowse(args);
|
|
}
|
|
|
|
function optionsToPdfFlags(opts: PdfOptions): Record<string, unknown> {
|
|
// Shape mirrors what the browse `pdf` case expects when reading --from-file
|
|
const out: Record<string, unknown> = {};
|
|
if (opts.format) out.format = opts.format;
|
|
if (opts.width) out.width = opts.width;
|
|
if (opts.height) out.height = opts.height;
|
|
if (opts.marginTop) out.marginTop = opts.marginTop;
|
|
if (opts.marginRight) out.marginRight = opts.marginRight;
|
|
if (opts.marginBottom) out.marginBottom = opts.marginBottom;
|
|
if (opts.marginLeft) out.marginLeft = opts.marginLeft;
|
|
if (opts.headerTemplate !== undefined) out.headerTemplate = opts.headerTemplate;
|
|
if (opts.footerTemplate !== undefined) out.footerTemplate = opts.footerTemplate;
|
|
if (opts.pageNumbers !== undefined) out.pageNumbers = opts.pageNumbers;
|
|
if (opts.tagged !== undefined) out.tagged = opts.tagged;
|
|
if (opts.outline !== undefined) out.outline = opts.outline;
|
|
if (opts.printBackground !== undefined) out.printBackground = opts.printBackground;
|
|
if (opts.preferCSSPageSize !== undefined) out.preferCSSPageSize = opts.preferCSSPageSize;
|
|
if (opts.toc !== undefined) out.toc = opts.toc;
|
|
return out;
|
|
}
|
|
|
|
function pushFlagsFromOptions(args: string[], opts: PdfOptions): void {
|
|
if (opts.format) { args.push("--format", opts.format); }
|
|
if (opts.width) { args.push("--width", opts.width); }
|
|
if (opts.height) { args.push("--height", opts.height); }
|
|
if (opts.marginTop) { args.push("--margin-top", opts.marginTop); }
|
|
if (opts.marginRight) { args.push("--margin-right", opts.marginRight); }
|
|
if (opts.marginBottom) { args.push("--margin-bottom", opts.marginBottom); }
|
|
if (opts.marginLeft) { args.push("--margin-left", opts.marginLeft); }
|
|
if (opts.headerTemplate !== undefined) {
|
|
args.push("--header-template", opts.headerTemplate);
|
|
}
|
|
if (opts.footerTemplate !== undefined) {
|
|
args.push("--footer-template", opts.footerTemplate);
|
|
}
|
|
if (opts.pageNumbers === true) args.push("--page-numbers");
|
|
if (opts.tagged === true) args.push("--tagged");
|
|
if (opts.outline === true) args.push("--outline");
|
|
if (opts.printBackground === true) args.push("--print-background");
|
|
if (opts.preferCSSPageSize === true) args.push("--prefer-css-page-size");
|
|
if (opts.toc === true) args.push("--toc");
|
|
}
|