@zenodinh/pi-render 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.test.ts +170 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/engines.ts and adapters.ts — survives because: the engine
|
|
2
|
+
// pre-warm is what lets a synchronous markdown seam call a rendered artifact, and the per-form adapters
|
|
3
|
+
// are the only place where a fence becomes bytes.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The artifacts render seam (SA §6): one cache-first render per fence form, four warmed engines, and the
|
|
7
|
+
* two binary-backed forms behind the pinned spawn boundary.
|
|
8
|
+
*
|
|
9
|
+
* Why pre-warm: the host's markdown transformer is synchronous while every engine's initialisation is
|
|
10
|
+
* asynchronous, so boot awaits warmup() once and the render pass then calls only synchronous APIs. An
|
|
11
|
+
* engine that failed to warm throws a named error, which the caller turns into the raw-fence fallback.
|
|
12
|
+
*
|
|
13
|
+
* shape: dispatch object — trigger #1, one strategy per fence form, each a stateless function.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
|
21
|
+
import { instance, type Viz } from "@viz-js/viz";
|
|
22
|
+
import type { CanvasKit } from "canvaskit-wasm";
|
|
23
|
+
import type { Logger } from "../../../core/types/log.ts";
|
|
24
|
+
import { type ArtifactCache, type CacheMeta, createArtifactCache, renderCacheKey } from "./cache.ts";
|
|
25
|
+
import { type LocalSpawn, type LocalSpawnResult, plantumlIncludeOffense, spawnLocal } from "./local-binary.ts";
|
|
26
|
+
import { type PrereqProbe, resolveBrowserPinned, resolvePlantuml, resolvePrereq } from "./prereqs.ts";
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const SCOPE = "content.artifacts";
|
|
30
|
+
|
|
31
|
+
/** pi-tui's own default image width; wider fences are clamped to it (P transformer.ts:68). */
|
|
32
|
+
const MAX_IMAGE_WIDTH_CELLS = 60;
|
|
33
|
+
const LOCAL_TIMEOUT_MS = 5_000;
|
|
34
|
+
const BROWSER_TIMEOUT_MS = 15_000;
|
|
35
|
+
const VIEWPORT = "1200,800";
|
|
36
|
+
const VIRTUAL_TIME_BUDGET_MS = 3_000;
|
|
37
|
+
|
|
38
|
+
export type DiagramForm = "plantuml" | "svg" | "dot" | "html";
|
|
39
|
+
|
|
40
|
+
export type DiagramResult =
|
|
41
|
+
| { kind: "image"; base64: string }
|
|
42
|
+
| { kind: "text"; art: string }
|
|
43
|
+
| { kind: "svg"; svg: string };
|
|
44
|
+
|
|
45
|
+
export type EngineId = "plantuml" | "viz" | "resvg" | "canvaskit";
|
|
46
|
+
|
|
47
|
+
/** Ready-or-not per engine; the plantuml row is a file probe, never a spawn. */
|
|
48
|
+
export type EngineStatus = Record<EngineId, boolean>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One render backend: warmed once at boot, then called synchronously per artifact.
|
|
52
|
+
*/
|
|
53
|
+
export interface Engine {
|
|
54
|
+
readonly id: EngineId;
|
|
55
|
+
/** Warms what the backend needs; unavailability is reported by status(), never thrown. */
|
|
56
|
+
warmup(log?: Logger): Promise<void>;
|
|
57
|
+
/** Ready as of the last warm-up — no probe, no spawn. */
|
|
58
|
+
status(): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* One backend call; throws a named Error while not ready. `source` is the form's own text — a diagram
|
|
61
|
+
* source for the engines behind those forms, the raster bytes base64-encoded for canvaskit.
|
|
62
|
+
*/
|
|
63
|
+
render(source: string, opts: DiagramRenderOptions): DiagramResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DiagramRenderOptions {
|
|
67
|
+
workDir?: string;
|
|
68
|
+
browserPath?: string;
|
|
69
|
+
/** Test seam; production resolves through prereqs.ts's absolute candidates. */
|
|
70
|
+
plantumlPath?: string;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
/** PlantUML only: "text" renders `-tutxt` Unicode art instead of SVG. */
|
|
73
|
+
mode?: "image" | "text";
|
|
74
|
+
/** Cache-key component: the transformer's clamped fence width. */
|
|
75
|
+
widthCells?: number;
|
|
76
|
+
/** Injected store and engines (tests and the composition root); production uses the defaults. */
|
|
77
|
+
cache?: ArtifactCache;
|
|
78
|
+
engines?: Partial<Record<EngineId, Engine>>;
|
|
79
|
+
/** Test seams: the prereq probe and the process runner. */
|
|
80
|
+
prereqs?: PrereqProbe;
|
|
81
|
+
spawn?: LocalSpawn;
|
|
82
|
+
log?: Logger;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function message(error: unknown): string {
|
|
86
|
+
return error instanceof Error ? error.message : String(error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* boundary: canvaskit-wasm's CJS default resolves to the module namespace under nodenext, so the
|
|
91
|
+
* callable init is narrowed here rather than trusted from the declaration.
|
|
92
|
+
*/
|
|
93
|
+
function isCanvasKitInit(value: unknown): value is () => Promise<CanvasKit> {
|
|
94
|
+
return typeof value === "function";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** shape: closure returning an object literal — trigger #4, one warmed wasm instance per engine. */
|
|
98
|
+
function resvgEngine(): Engine {
|
|
99
|
+
let warmed = false;
|
|
100
|
+
return {
|
|
101
|
+
id: "resvg",
|
|
102
|
+
async warmup(log) {
|
|
103
|
+
if (warmed) return;
|
|
104
|
+
try {
|
|
105
|
+
await initWasm(readFileSync(require.resolve("@resvg/resvg-wasm/index_bg.wasm")));
|
|
106
|
+
warmed = true;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
log?.logOnce(`content.artifacts:engine:resvg`, SCOPE, `svg engine unavailable: ${message(error)}`);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
status: () => warmed,
|
|
112
|
+
render(source) {
|
|
113
|
+
if (!warmed) throw new Error("svg engine not warmed");
|
|
114
|
+
return { kind: "image", base64: Buffer.from(new Resvg(source).render().asPng()).toString("base64") };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** shape: closure returning an object literal — trigger #4, one warmed wasm instance per engine. */
|
|
120
|
+
function vizEngine(): Engine {
|
|
121
|
+
let viz: Viz | null = null;
|
|
122
|
+
return {
|
|
123
|
+
id: "viz",
|
|
124
|
+
async warmup(log) {
|
|
125
|
+
if (viz) return;
|
|
126
|
+
try {
|
|
127
|
+
viz = await instance();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
log?.logOnce("content.artifacts:engine:viz", SCOPE, `graphviz engine unavailable: ${message(error)}`);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
status: () => viz !== null,
|
|
133
|
+
render(source) {
|
|
134
|
+
if (!viz) throw new Error("dot engine not warmed");
|
|
135
|
+
return { kind: "svg", svg: viz.renderString(source, { format: "svg" }) };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** shape: closure returning an object literal — trigger #4, one warmed wasm instance per engine. */
|
|
141
|
+
function canvasKitEngine(): Engine {
|
|
142
|
+
let kit: CanvasKit | null = null;
|
|
143
|
+
return {
|
|
144
|
+
id: "canvaskit",
|
|
145
|
+
async warmup(log) {
|
|
146
|
+
if (kit) return;
|
|
147
|
+
try {
|
|
148
|
+
const loaded: { default: unknown } = await import("canvaskit-wasm");
|
|
149
|
+
const init = loaded.default;
|
|
150
|
+
if (!isCanvasKitInit(init)) throw new Error("canvaskit-wasm exposes no init function");
|
|
151
|
+
kit = await init();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
log?.logOnce("content.artifacts:engine:canvaskit", SCOPE, `image engine unavailable: ${message(error)}`);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
status: () => kit !== null,
|
|
157
|
+
render(source) {
|
|
158
|
+
if (!kit) throw new Error("image engine not warmed");
|
|
159
|
+
const image = kit.MakeImageFromEncoded(Buffer.from(source, "base64"));
|
|
160
|
+
if (!image) throw new Error("image decode failed");
|
|
161
|
+
try {
|
|
162
|
+
const png = image.encodeToBytes();
|
|
163
|
+
if (!png) throw new Error("image encode failed");
|
|
164
|
+
return { kind: "image", base64: Buffer.from(png).toString("base64") };
|
|
165
|
+
} finally {
|
|
166
|
+
image.delete();
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* An injected probe wins over the warmed candidate so a test is machine-independent, and a missing
|
|
174
|
+
* binary throws its install hint here — before any spawn site is reached.
|
|
175
|
+
*/
|
|
176
|
+
function plantumlPath(warmed: string | undefined, opts: DiagramRenderOptions): string {
|
|
177
|
+
if (opts.plantumlPath) return opts.plantumlPath;
|
|
178
|
+
if (opts.prereqs) return resolvePlantuml(opts.prereqs);
|
|
179
|
+
return warmed ?? resolvePlantuml();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** shape: closure returning an object literal — trigger #4, one resolved binary plus the spawn call. */
|
|
183
|
+
function plantumlEngine(): Engine {
|
|
184
|
+
let warmed: string | undefined;
|
|
185
|
+
return {
|
|
186
|
+
id: "plantuml",
|
|
187
|
+
async warmup() {
|
|
188
|
+
warmed = resolvePrereq("plantuml").path || undefined;
|
|
189
|
+
},
|
|
190
|
+
status: () => warmed !== undefined,
|
|
191
|
+
render(source, opts) {
|
|
192
|
+
const offense = plantumlIncludeOffense(source);
|
|
193
|
+
if (offense) throw new Error(`plantuml: "${offense}" is rejected — includes can read local files`);
|
|
194
|
+
const binary = plantumlPath(warmed, opts);
|
|
195
|
+
return withWorkDir(opts.workDir, (dir) => {
|
|
196
|
+
const file = join(dir, "diagram.puml");
|
|
197
|
+
writeFileSync(file, source);
|
|
198
|
+
const textMode = opts.mode === "text";
|
|
199
|
+
const timeoutMs = opts.timeoutMs ?? LOCAL_TIMEOUT_MS;
|
|
200
|
+
const extension = textMode ? ".utxt" : ".svg";
|
|
201
|
+
// `-o` pins the directory; the FILE name is whatever plantuml chose — it uses
|
|
202
|
+
// the diagram's title when the source has one (observed 2026-10-03), and the
|
|
203
|
+
// stem-swapped input name otherwise. Guessing either name has failed twice,
|
|
204
|
+
// so the output is discovered, not predicted.
|
|
205
|
+
run(opts, "plantuml", binary, [textMode ? "-tutxt" : "-tsvg", "-o", dir, file], timeoutMs);
|
|
206
|
+
const produced = readdirSync(dir).filter((name) => name.toLowerCase().endsWith(extension));
|
|
207
|
+
if (produced.length === 0) throw new Error(`plantuml: no ${extension} output was written`);
|
|
208
|
+
const out = join(dir, produced.sort()[0] as string);
|
|
209
|
+
return textMode
|
|
210
|
+
? { kind: "text", art: readFileSync(out, "utf8") }
|
|
211
|
+
: { kind: "svg", svg: readFileSync(out, "utf8") };
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let defaults: Record<EngineId, Engine> | undefined;
|
|
218
|
+
|
|
219
|
+
/** shape: none — one lazy table; each engine holds its own warmed wasm state. */
|
|
220
|
+
function defaultEngines(): Record<EngineId, Engine> {
|
|
221
|
+
defaults ??= {
|
|
222
|
+
plantuml: plantumlEngine(),
|
|
223
|
+
viz: vizEngine(),
|
|
224
|
+
resvg: resvgEngine(),
|
|
225
|
+
canvaskit: canvasKitEngine(),
|
|
226
|
+
};
|
|
227
|
+
return defaults;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** shape: none — one read of the warmed flags plus one absolute-path probe for plantuml. */
|
|
231
|
+
export function status(probe: PrereqProbe = {}): EngineStatus {
|
|
232
|
+
const engines = defaultEngines();
|
|
233
|
+
return {
|
|
234
|
+
plantuml: resolvePrereq("plantuml", probe).path !== "",
|
|
235
|
+
viz: engines.viz.status(),
|
|
236
|
+
resvg: engines.resvg.status(),
|
|
237
|
+
canvaskit: engines.canvaskit.status(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** shape: none — four independent warm-ups, awaited in turn; a failure is reported, never fatal. */
|
|
242
|
+
export async function warmup(log?: Logger): Promise<EngineStatus> {
|
|
243
|
+
for (const engine of Object.values(defaultEngines())) await engine.warmup(log);
|
|
244
|
+
return status();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function spawnFailure(form: DiagramForm, cmd: string, result: LocalSpawnResult, timeoutMs: number): Error {
|
|
248
|
+
const code = (result.error as NodeJS.ErrnoException | undefined)?.code;
|
|
249
|
+
if (code === "ENOENT") return new Error(`${form}: "${cmd}" not found`);
|
|
250
|
+
if (code === "ETIMEDOUT" || result.signal === "SIGTERM") {
|
|
251
|
+
return new Error(`${form}: "${cmd}" timed out after ${timeoutMs}ms`);
|
|
252
|
+
}
|
|
253
|
+
if (result.error) return new Error(`${form}: "${cmd}" failed: ${result.error.message}`);
|
|
254
|
+
const stderr = result.stderr?.toString("utf8").trim().split("\n")[0] ?? "";
|
|
255
|
+
return new Error(`${form}: "${cmd}" exited ${result.status ?? "on signal"}${stderr ? `: ${stderr}` : ""}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function run(opts: DiagramRenderOptions, form: DiagramForm, cmd: string, args: string[], timeoutMs: number): void {
|
|
259
|
+
const result = (opts.spawn ?? spawnLocal)(cmd, args, { timeoutMs });
|
|
260
|
+
if (result.error || result.status !== 0) throw spawnFailure(form, cmd, result, timeoutMs);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A caller-provided workDir is left alone (tests inspect it); a created one is removed. */
|
|
264
|
+
function withWorkDir<T>(workDir: string | undefined, fn: (dir: string) => T): T {
|
|
265
|
+
const dir = workDir ?? mkdtempSync(join(tmpdir(), "pi-render-canvas-"));
|
|
266
|
+
try {
|
|
267
|
+
return fn(dir);
|
|
268
|
+
} finally {
|
|
269
|
+
if (!workDir) rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A minimal dark shell so a bare fragment screenshots legibly and without a
|
|
275
|
+
* white flash. The CSP is part of the boundary: a model-authored fragment may
|
|
276
|
+
* style itself but cannot load anything or reach the network.
|
|
277
|
+
*/
|
|
278
|
+
function wrapHtmlFragment(fragment: string): string {
|
|
279
|
+
return `<!doctype html>
|
|
280
|
+
<html><head><meta charset="utf-8">
|
|
281
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
|
|
282
|
+
<style>
|
|
283
|
+
:root{color-scheme:dark}
|
|
284
|
+
body{margin:16px;background:#16161e;color:#e6e6ef;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
285
|
+
</style></head><body>${fragment}</body></html>`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** shape: none — one temp-dir screenshot run; the browser is a pinned absolute path, never PATH. */
|
|
289
|
+
function renderHtml(source: string, opts: DiagramRenderOptions): DiagramResult {
|
|
290
|
+
return withWorkDir(opts.workDir, (dir) => {
|
|
291
|
+
const file = join(dir, "fragment.html");
|
|
292
|
+
const out = join(dir, "screenshot.png");
|
|
293
|
+
writeFileSync(file, wrapHtmlFragment(source));
|
|
294
|
+
const browser = resolveBrowserPinned(opts.browserPath, opts.prereqs);
|
|
295
|
+
const timeoutMs = opts.timeoutMs ?? BROWSER_TIMEOUT_MS;
|
|
296
|
+
run(
|
|
297
|
+
opts,
|
|
298
|
+
"html",
|
|
299
|
+
browser,
|
|
300
|
+
[
|
|
301
|
+
"--headless",
|
|
302
|
+
"--disable-gpu",
|
|
303
|
+
// No script flag here: `--blink-settings=scriptEnabled=false` makes the
|
|
304
|
+
// Playwright headless shell exit 0 without writing the screenshot
|
|
305
|
+
// (2026-10-03), and the shell's CSP already blocks scripts — `script-src`
|
|
306
|
+
// inherits `default-src 'none'`, so neither inline nor external scripts run.
|
|
307
|
+
// A throwaway profile inside the temp dir: the user's own profile is
|
|
308
|
+
// never read or locked, and the cleanup is the work dir's.
|
|
309
|
+
`--user-data-dir=${join(dir, "browser-profile")}`,
|
|
310
|
+
"--no-first-run",
|
|
311
|
+
"--disable-background-networking",
|
|
312
|
+
"--hide-scrollbars",
|
|
313
|
+
`--screenshot=${out}`,
|
|
314
|
+
`--window-size=${VIEWPORT}`,
|
|
315
|
+
`--virtual-time-budget=${VIRTUAL_TIME_BUDGET_MS}`,
|
|
316
|
+
`file://${file}`,
|
|
317
|
+
],
|
|
318
|
+
timeoutMs,
|
|
319
|
+
);
|
|
320
|
+
if (!existsSync(out)) throw new Error("html: browser produced no screenshot");
|
|
321
|
+
return { kind: "image", base64: readFileSync(out).toString("base64") };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// shape: dispatch object — trigger #1, one strategy per fence form, each a plain function.
|
|
326
|
+
const FORM_RENDERERS: Record<DiagramForm, (source: string, opts: DiagramRenderOptions) => DiagramResult> = {
|
|
327
|
+
plantuml: (source, opts) => engineOf("plantuml", opts).render(source, opts),
|
|
328
|
+
svg: (source) => ({ kind: "svg", svg: source }),
|
|
329
|
+
dot: (source, opts) => engineOf("viz", opts).render(source, opts),
|
|
330
|
+
html: renderHtml,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
function engineOf(id: EngineId, opts: DiagramRenderOptions): Engine {
|
|
334
|
+
return opts.engines?.[id] ?? defaultEngines()[id];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A cache hit is the artifact the last identical render wrote; an unreadable one is simply a miss. */
|
|
338
|
+
function readCached(form: DiagramForm, key: string, cache: ArtifactCache): DiagramResult | undefined {
|
|
339
|
+
try {
|
|
340
|
+
if (form === "html") {
|
|
341
|
+
const entry = cache.get(key);
|
|
342
|
+
return entry === undefined ? undefined : { kind: "image", base64: entry.png.toString("base64") };
|
|
343
|
+
}
|
|
344
|
+
const path = cache.vectorPath(key);
|
|
345
|
+
return path === undefined ? undefined : { kind: "svg", svg: readFileSync(path, "utf8") };
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A write is an optimization: a full disk or an unusable directory must not cost the render that just
|
|
353
|
+
* succeeded, and eviction runs here for the same reason it exists at all.
|
|
354
|
+
*/
|
|
355
|
+
function writeCached(
|
|
356
|
+
form: DiagramForm,
|
|
357
|
+
key: string,
|
|
358
|
+
mode: string,
|
|
359
|
+
widthCells: number,
|
|
360
|
+
result: DiagramResult,
|
|
361
|
+
cache: ArtifactCache,
|
|
362
|
+
log: Logger | undefined,
|
|
363
|
+
): void {
|
|
364
|
+
// rows belongs to the emission step (T-26); the engine seam never knows the rendered line count.
|
|
365
|
+
const meta: CacheMeta = { source: form, mode, mime: "", rows: 0, widthCells, createdAt: new Date().toISOString() };
|
|
366
|
+
try {
|
|
367
|
+
if (result.kind === "svg") cache.putVector(key, result.svg, { ...meta, mime: "image/svg+xml" });
|
|
368
|
+
else if (result.kind === "image")
|
|
369
|
+
cache.put(key, Buffer.from(result.base64, "base64"), { ...meta, mime: "image/png" });
|
|
370
|
+
else return;
|
|
371
|
+
cache.evict();
|
|
372
|
+
} catch (error) {
|
|
373
|
+
log?.logOnce(`content.artifacts:cache:${key}`, SCOPE, `artifact not cached: ${message(error)}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* shape: none — cache-first dispatch; the dedup branch is the whole contract (a repeated fence must
|
|
379
|
+
* never run an engine twice) and every other branch is one table lookup.
|
|
380
|
+
*/
|
|
381
|
+
export function renderDiagram(form: DiagramForm, source: string, opts: DiagramRenderOptions = {}): DiagramResult {
|
|
382
|
+
const mode = opts.mode === "text" ? "text" : "image";
|
|
383
|
+
const widthCells = Math.max(1, Math.floor(opts.widthCells ?? MAX_IMAGE_WIDTH_CELLS));
|
|
384
|
+
const cache = opts.cache ?? createArtifactCache({ log: opts.log });
|
|
385
|
+
const key = renderCacheKey(form, source, mode, widthCells);
|
|
386
|
+
|
|
387
|
+
// Only artifacts are cached: PlantUML's text art is a view of the render, not a file (P transformer.ts:169-183).
|
|
388
|
+
if (mode === "image") {
|
|
389
|
+
const hit = readCached(form, key, cache);
|
|
390
|
+
if (hit !== undefined) return hit;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const result = FORM_RENDERERS[form](source, opts);
|
|
394
|
+
if (mode === "image") writeCached(form, key, mode, widthCells, result, cache, opts.log);
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-binary.test.ts — the audited spawn boundary (T-25 follow-up): spawnLocal's real argv/cwd/timeout
|
|
3
|
+
* contract, cmux's absolute-candidate resolution, openInCmux's fixed argv and its no-shell guarantee, and
|
|
4
|
+
* the PlantUML include guard.
|
|
5
|
+
*
|
|
6
|
+
* Compatibility tests for the two seams nothing else exercises: the package's only `node:child_process`
|
|
7
|
+
* call site and the include guard in front of the JVM. Where behavior needs a real process it runs the
|
|
8
|
+
* Node binary this test is already running under and `/bin/echo` — never a PATH lookup, never a shell;
|
|
9
|
+
* everywhere else the seam is the injected `spawn`/`resolve`/`exists`. Each test carries a spec +
|
|
10
|
+
* fails_when line.
|
|
11
|
+
*
|
|
12
|
+
* shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdtempSync, realpathSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { isAbsolute, join } from "node:path";
|
|
18
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
type LocalSpawn,
|
|
21
|
+
type LocalSpawnResult,
|
|
22
|
+
openInCmux,
|
|
23
|
+
plantumlIncludeOffense,
|
|
24
|
+
resolveCmux,
|
|
25
|
+
spawnLocal,
|
|
26
|
+
} from "./local-binary.ts";
|
|
27
|
+
|
|
28
|
+
// invented: the canned result an exit-0 process reports; there is no upstream payload to record.
|
|
29
|
+
const OK: LocalSpawnResult = { status: 0, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
|
|
30
|
+
|
|
31
|
+
const CMUX_URL = "http://127.0.0.1:7777/artifact/9f2c";
|
|
32
|
+
|
|
33
|
+
/** boundary: a spawn error is `unknown` until its `code` is narrowed here — no cast, no field read blind. */
|
|
34
|
+
function errorCode(result: LocalSpawnResult): unknown {
|
|
35
|
+
const error: unknown = result.error;
|
|
36
|
+
return error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function only<T>(items: T[]): T {
|
|
40
|
+
const first = items[0];
|
|
41
|
+
if (first === undefined) throw new Error("test fixture: expected exactly one recorded item");
|
|
42
|
+
return first;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SpawnCall {
|
|
46
|
+
cmd: string;
|
|
47
|
+
args: string[];
|
|
48
|
+
timeoutMs: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** invented: the recording spawn is the fixture — it captures the argv/timeout and never runs a process. */
|
|
52
|
+
function recorder() {
|
|
53
|
+
const calls: SpawnCall[] = [];
|
|
54
|
+
const spawn: LocalSpawn = (cmd, args, opts) => {
|
|
55
|
+
calls.push({ cmd, args, timeoutMs: opts.timeoutMs });
|
|
56
|
+
return OK;
|
|
57
|
+
};
|
|
58
|
+
return { calls, spawn };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let dir: string;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
dir = mkdtempSync(join(tmpdir(), "pi-render-local-binary-"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("cmux resolution", () => {
|
|
68
|
+
// spec: resolveCmux scans the two absolute app-bundle candidates in order and returns the first that
|
|
69
|
+
// exists — no PATH lookup, no environment override, undefined when neither is there.
|
|
70
|
+
// fails_when: a relative or PATH-derived candidate is launched in the user's terminal.
|
|
71
|
+
it("returns the first existing absolute candidate and undefined when none exist", () => {
|
|
72
|
+
expect(resolveCmux(() => false)).toBeUndefined();
|
|
73
|
+
expect(resolveCmux(() => true)).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
|
|
74
|
+
|
|
75
|
+
const second = resolveCmux((candidate) => candidate !== "/Applications/cmux.app/Contents/Resources/bin/cmux");
|
|
76
|
+
expect(second).toBeDefined();
|
|
77
|
+
expect(isAbsolute(second as string)).toBe(true);
|
|
78
|
+
expect(second?.endsWith("/Applications/cmux.app/Contents/Resources/bin/cmux")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// spec: with no injected binary, openInCmux resolves cmux itself; on a machine without it the call is
|
|
82
|
+
// a named error, never a spawn.
|
|
83
|
+
// fails_when: the default resolution is skipped, or a missing cmux turns into a failed spawn attempt.
|
|
84
|
+
it("resolves cmux itself when no binary is injected", () => {
|
|
85
|
+
const { calls, spawn } = recorder();
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
openInCmux(CMUX_URL, { spawn });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
expect(error instanceof Error ? error.message : String(error)).toBe("cmux: CLI not found");
|
|
91
|
+
expect(calls).toEqual([]);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const call = only(calls);
|
|
96
|
+
expect(call.cmd).toBe(resolveCmux());
|
|
97
|
+
expect(isAbsolute(call.cmd)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("openInCmux — the fixed cmux argv", () => {
|
|
102
|
+
// spec: openInCmux spawns the resolved binary with exactly
|
|
103
|
+
// `new-pane --type browser --url <url> --focus false` and the 10s default timeout.
|
|
104
|
+
// fails_when: a cmux CLI change (reordered flags, dropped --focus) silently stops opening the card.
|
|
105
|
+
it("sends the fixed argv with the default timeout", () => {
|
|
106
|
+
const binary = "/Applications/cmux.app/Contents/Resources/bin/cmux";
|
|
107
|
+
const { calls, spawn } = recorder();
|
|
108
|
+
|
|
109
|
+
expect(openInCmux(CMUX_URL, { binary, spawn })).toEqual(OK);
|
|
110
|
+
|
|
111
|
+
const call = only(calls);
|
|
112
|
+
expect(call.cmd).toBe(binary);
|
|
113
|
+
expect(call.args).toEqual(["new-pane", "--type", "browser", "--url", CMUX_URL, "--focus", "false"]);
|
|
114
|
+
expect(call.timeoutMs).toBe(10_000);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// spec: an injected resolve() wins over the candidate scan, and a caller timeout overrides the 10s one.
|
|
118
|
+
// fails_when: the seams are ignored — a test or a caller cannot point the op at its own cmux.
|
|
119
|
+
it("honors an injected resolve and a timeout override", () => {
|
|
120
|
+
const { calls, spawn } = recorder();
|
|
121
|
+
|
|
122
|
+
openInCmux(CMUX_URL, { resolve: () => "/opt/pi-render-test/bin/cmux", spawn, timeoutMs: 250 });
|
|
123
|
+
|
|
124
|
+
const call = only(calls);
|
|
125
|
+
expect(call.cmd).toBe("/opt/pi-render-test/bin/cmux");
|
|
126
|
+
expect(call.timeoutMs).toBe(250);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// spec: with no cmux resolvable the call throws the named error and no process is started.
|
|
130
|
+
// fails_when: the caller gets a raw spawn error instead of the actionable message.
|
|
131
|
+
it("throws the named error when no cmux resolves and spawns nothing", () => {
|
|
132
|
+
const { calls, spawn } = recorder();
|
|
133
|
+
|
|
134
|
+
expect(() => openInCmux(CMUX_URL, { resolve: () => undefined, spawn })).toThrow("cmux: CLI not found");
|
|
135
|
+
expect(calls).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// spec: the default spawn goes straight to the binary — no shell — so shell metacharacters in the URL
|
|
139
|
+
// stay literal arguments and execute nothing.
|
|
140
|
+
// fails_when: a shell is introduced at this boundary and a card URL can run a command.
|
|
141
|
+
it("runs argv without a shell, so metacharacters in the URL stay literal", () => {
|
|
142
|
+
const canary = join(dir, "pwned");
|
|
143
|
+
const url = `http://127.0.0.1:7777/a;touch ${canary}`;
|
|
144
|
+
|
|
145
|
+
const result = openInCmux(url, { binary: "/bin/echo", timeoutMs: 2_000 });
|
|
146
|
+
|
|
147
|
+
expect(result.status).toBe(0);
|
|
148
|
+
expect(result.stdout?.toString("utf8")).toBe(`new-pane --type browser --url ${url} --focus false\n`);
|
|
149
|
+
expect(existsSync(canary)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("spawnLocal — the one process runner", () => {
|
|
154
|
+
// spec: spawnLocal runs a fixed argv with a buffer encoding and returns the exit status and stdout.
|
|
155
|
+
// fails_when: the encoding or the argv passthrough changes and every caller reads the wrong bytes.
|
|
156
|
+
it("runs a fixed argv and returns the status with buffered output", () => {
|
|
157
|
+
const result = spawnLocal(process.execPath, ["-e", "process.stdout.write('ok')"], { timeoutMs: 10_000 });
|
|
158
|
+
|
|
159
|
+
expect(result.status).toBe(0);
|
|
160
|
+
expect(result.stdout?.toString("utf8")).toBe("ok");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// spec: spawnLocal runs the process in the cwd it was given.
|
|
164
|
+
// fails_when: a caller's temp-dir cwd is ignored, so a spawn writes into the user's directory.
|
|
165
|
+
it("runs in the cwd it was given", () => {
|
|
166
|
+
const result = spawnLocal(process.execPath, ["-e", "process.stdout.write(process.cwd())"], {
|
|
167
|
+
cwd: dir,
|
|
168
|
+
timeoutMs: 10_000,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const printed = result.stdout?.toString("utf8") ?? "";
|
|
172
|
+
expect(realpathSync(printed)).toBe(realpathSync(dir));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// spec: a process outliving its timeout is killed and reported as ETIMEDOUT with SIGTERM.
|
|
176
|
+
// fails_when: a hung binary pins the render thread forever.
|
|
177
|
+
it("kills a process past its timeout and reports ETIMEDOUT", () => {
|
|
178
|
+
const result = spawnLocal(process.execPath, ["-e", "setTimeout(() => {}, 5_000)"], { timeoutMs: 300 });
|
|
179
|
+
|
|
180
|
+
expect(errorCode(result)).toBe("ETIMEDOUT");
|
|
181
|
+
expect(result.signal).toBe("SIGTERM");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// spec: a missing binary is reported as ENOENT in the result, never thrown.
|
|
185
|
+
// fails_when: the caller crashes on a machine where the binary is absent instead of degrading.
|
|
186
|
+
it("reports a missing binary as ENOENT without throwing", () => {
|
|
187
|
+
const result = spawnLocal("/nonexistent/pi-render-no-such-binary", [], { timeoutMs: 1_000 });
|
|
188
|
+
|
|
189
|
+
expect(result.status).toBeNull();
|
|
190
|
+
expect(errorCode(result)).toBe("ENOENT");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("the PlantUML include guard", () => {
|
|
195
|
+
// spec: plantumlIncludeOffense reports every include-shaped directive by name — case-insensitively,
|
|
196
|
+
// after leading whitespace, on any line — and passes ordinary source through.
|
|
197
|
+
// fails_when: a directive variant slips past the guard and an included local file reaches the image.
|
|
198
|
+
it("reports each include directive variant and passes ordinary source", () => {
|
|
199
|
+
expect(plantumlIncludeOffense("@startuml\n!include /etc/passwd\n@enduml")).toBe("!include");
|
|
200
|
+
expect(plantumlIncludeOffense("!includeurl https://example.invalid/x.puml")).toBe("!includeurl");
|
|
201
|
+
expect(plantumlIncludeOffense(" !includesub other.puml!FOO")).toBe("!includesub");
|
|
202
|
+
expect(plantumlIncludeOffense("!include_many a.puml")).toBe("!include_many");
|
|
203
|
+
expect(plantumlIncludeOffense("IDEA\n!IMPORT x.yaml\n")).toBe("!IMPORT");
|
|
204
|
+
expect(plantumlIncludeOffense("@startuml\n!pragma layout smetana\nAlice -> Bob\n@enduml")).toBeUndefined();
|
|
205
|
+
expect(plantumlIncludeOffense("")).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/local-binary.ts — survives because: a binary cannot be
|
|
2
|
+
// certified from a diff, so what survives is the bounded way to call one — absolute candidates only, no
|
|
3
|
+
// shell, a timeout on every spawn, and the include guard in front of the PlantUML JVM.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The single audited spawn boundary for the artifacts feature, and the package's only
|
|
7
|
+
* `node:child_process` site. It exists for the two forms whose engine has no Node-native replacement:
|
|
8
|
+
* the PlantUML JVM and a headless browser for HTML fences.
|
|
9
|
+
*
|
|
10
|
+
* What is bounded here, since a binary cannot be certified from a diff:
|
|
11
|
+
* - provenance: absolute paths only, from `prereqs.ts`'s candidate lists —
|
|
12
|
+
* no PATH lookup, no environment override;
|
|
13
|
+
* - no shell: fixed argv straight into spawnSync, caller-supplied temp dirs;
|
|
14
|
+
* - timeouts on every spawn;
|
|
15
|
+
* - PlantUML sources carrying an include directive are rejected before the
|
|
16
|
+
* spawn (includes can read local files into the rendered image);
|
|
17
|
+
* - the HTML wrapper pins `default-src 'none'` (script-src inherits it, so
|
|
18
|
+
* scripts cannot run, and the fragment cannot fetch), and the browser gets
|
|
19
|
+
* a throwaway profile inside the caller's temp dir.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
export interface LocalSpawnResult {
|
|
28
|
+
status: number | null;
|
|
29
|
+
signal?: NodeJS.Signals | null;
|
|
30
|
+
stdout?: Buffer;
|
|
31
|
+
stderr?: Buffer;
|
|
32
|
+
error?: Error | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The one process-runner signature: fixed argv, caller-supplied temp dirs, always a timeout. */
|
|
36
|
+
export type LocalSpawn = (cmd: string, args: string[], opts: { cwd?: string; timeoutMs: number }) => LocalSpawnResult;
|
|
37
|
+
|
|
38
|
+
/** shape: none — the one process runner; a seam elsewhere lets tests avoid it. */
|
|
39
|
+
export function spawnLocal(cmd: string, args: string[], opts: { cwd?: string; timeoutMs: number }): LocalSpawnResult {
|
|
40
|
+
return spawnSync(cmd, args, { cwd: opts.cwd, timeout: opts.timeoutMs, encoding: "buffer" });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** cmux ships its agent CLI inside the app bundle; PATH is never consulted. */
|
|
44
|
+
const CMUX_CANDIDATES = [
|
|
45
|
+
"/Applications/cmux.app/Contents/Resources/bin/cmux",
|
|
46
|
+
join(homedir(), "Applications/cmux.app/Contents/Resources/bin/cmux"),
|
|
47
|
+
];
|
|
48
|
+
const CMUX_TIMEOUT_MS = 10_000;
|
|
49
|
+
|
|
50
|
+
/** shape: none — one candidate scan; undefined means "no cmux on this machine". */
|
|
51
|
+
export function resolveCmux(exists: (path: string) => boolean = existsSync): string | undefined {
|
|
52
|
+
return CMUX_CANDIDATES.find((candidate) => exists(candidate));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Opens a URL as a browser surface beside the caller's terminal (a right
|
|
57
|
+
* split; `--placement dock` exists but is disabled in practice — observed
|
|
58
|
+
* 2026-10-03). cmux routes only http(s) into its embedded browser and file://
|
|
59
|
+
* links fall to the OS handlers, which is why the card's click uses the
|
|
60
|
+
* artifact server's http URL and this CLI call is the keyboard fallback.
|
|
61
|
+
*/
|
|
62
|
+
export function openInCmux(
|
|
63
|
+
url: string,
|
|
64
|
+
opts: { binary?: string; resolve?: () => string | undefined; timeoutMs?: number; spawn?: LocalSpawn } = {},
|
|
65
|
+
): LocalSpawnResult {
|
|
66
|
+
const binary = opts.binary ?? (opts.resolve ?? resolveCmux)();
|
|
67
|
+
if (binary === undefined) throw new Error("cmux: CLI not found");
|
|
68
|
+
const spawn = opts.spawn ?? spawnLocal;
|
|
69
|
+
return spawn(binary, ["new-pane", "--type", "browser", "--url", url, "--focus", "false"], {
|
|
70
|
+
timeoutMs: opts.timeoutMs ?? CMUX_TIMEOUT_MS,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PLANTUML_INCLUDE = /^\s*!(?:include|includeurl|includesub|include_many|import)\b/im;
|
|
75
|
+
|
|
76
|
+
/** shape: none — one directive scan; returns the offending token, or undefined. */
|
|
77
|
+
export function plantumlIncludeOffense(source: string): string | undefined {
|
|
78
|
+
const match = source.match(PLANTUML_INCLUDE);
|
|
79
|
+
return match ? match[0].trim() : undefined;
|
|
80
|
+
}
|