castle-web-cli 0.4.122 → 0.4.124

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.
Files changed (60) hide show
  1. package/dist/agent-prompts.js +6 -2
  2. package/dist/castleJson.d.ts +2 -0
  3. package/dist/castleJson.js +39 -0
  4. package/dist/diffText.d.ts +14 -0
  5. package/dist/diffText.js +138 -0
  6. package/dist/headlessCover.d.ts +13 -0
  7. package/dist/headlessCover.js +99 -0
  8. package/dist/ide.d.ts +2 -1
  9. package/dist/ide.js +100 -43
  10. package/dist/imports.js +42 -2
  11. package/dist/index.js +49 -11
  12. package/dist/init.d.ts +1 -0
  13. package/dist/init.js +45 -1
  14. package/dist/localPaths.d.ts +1 -0
  15. package/dist/localPaths.js +5 -0
  16. package/dist/preview.d.ts +0 -1
  17. package/dist/preview.js +0 -58
  18. package/dist/save-deck.js +52 -1
  19. package/dist/serve.js +16 -0
  20. package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
  21. package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
  22. package/dist/shell/assets/index-BfOPkSej.css +1 -0
  23. package/dist/shell/assets/index-u0nYFqbF.js +434 -0
  24. package/dist/shell/index.html +2 -2
  25. package/dist/versionStore.d.ts +54 -0
  26. package/dist/versionStore.js +281 -0
  27. package/dist/versions.d.ts +82 -0
  28. package/dist/versions.js +446 -0
  29. package/kits/physics-2d/CLAUDE.md +2 -2
  30. package/kits/physics-2d/castle.json +10 -2
  31. package/kits/physics-2d/docs/pxart-format.md +33 -26
  32. package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
  33. package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
  34. package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
  35. package/kits/physics-2d/editors/pathOverlay.js +1 -1
  36. package/kits/physics-2d/editors/pathTools.js +9 -1
  37. package/kits/physics-2d/editors/pixelGeometry.js +14 -13
  38. package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
  39. package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
  40. package/kits/physics-2d/editors/pxArtTools.js +3 -43
  41. package/kits/physics-2d/editors/styleEditor.module.css +105 -0
  42. package/kits/physics-2d/editors/styleTheme.js +16 -0
  43. package/kits/physics-2d/engine/files.js +2 -1
  44. package/kits/physics-2d/engine/liveReload.js +4 -3
  45. package/kits/physics-2d/engine/palettes.js +636 -0
  46. package/kits/physics-2d/engine/pxart.js +6 -6
  47. package/kits/physics-2d/engine/svgImport.js +1056 -0
  48. package/kits/physics-2d/engine/ui.jsx +2 -0
  49. package/kits/physics-2d/engine/ui.module.css +54 -9
  50. package/kits/physics-2d/package-lock.json +1 -1
  51. package/kits/physics-2d/package.json +1 -0
  52. package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
  53. package/kits/physics-2d/scripts/draw.mjs +5 -3
  54. package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
  55. package/kits/physics-2d/scripts/palette.mjs +10 -0
  56. package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
  57. package/kits/physics-2d/theme.style +3 -0
  58. package/package.json +1 -1
  59. package/dist/shell/assets/index-BkJ87APM.css +0 -1
  60. package/dist/shell/assets/index-DXBpj3-y.js +0 -434
@@ -63,6 +63,7 @@ comma-separated active-task titles or ids, or \`all\`
63
63
  - Never claim the board is cleared without actually emitting the fence.
64
64
  - Tasks are one-and-done -- when the user gives feedback on a finished task, spawn a new fix task (and \`castle-done\` the old row) rather than reopening it.
65
65
  - Task agents are capable coding agents working in this same deck directory, but they know nothing about this conversation beyond your prompt.
66
+ - The deck keeps its own version history, and you may READ it: \`castle-web list-versions\` shows the versions newest first, and \`castle-web show-version <id>\` shows what one changed, as diffs of just those files. Use them to answer "what changed" or to ground a claim about recent work. You NEVER run \`save-version\` -- you did not make the changes, so a version of yours would not correspond to a unit of work -- and you NEVER run \`restore-version\`: going back is the user's own decision, not something you do on their behalf.
66
67
 
67
68
  Asking with options (the \`\`\`ask block). When you need the user to settle a few choices at once, emit ONE fenced block tagged \`ask\` containing JSON -- it renders inline in the chat as grouped options they tap and submit together (far better than stacking questions they can only half-answer). Reach for it to pin a direction fast when their ask is vague ("make me a game" -> ask what kind), NOT to interrogate. Options only, no free text.
68
69
 
@@ -233,7 +234,7 @@ export function buildTaskPrompt(opts) {
233
234
  // -- blind whole-file rewrites made parallel agents clobber each other's
234
235
  // edits. Read-then-edit is slower but safe; that is the right tradeoff.)
235
236
  const wrapUp = opts.backend === "claude" || opts.backend === "smith"
236
- ? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine the 90-progress write, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
237
+ ? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine the 90-progress write, the \`castle-web save-version\` for your paths, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
237
238
  : "";
238
239
  return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`).${quickReference}${layout}${deckSource}
239
240
 
@@ -247,6 +248,9 @@ Operating rules:
247
248
  - The USER is the verifier -- the whole tasks system exists so the user playtests every change themselves. Your first priority is to finish as soon as possible with the change genuinely in place and reachable in the running deck, so the user can test it right away. Do NOT run verification (screenshots especially) unless you are really sure it will catch something a re-read of your own change cannot -- and even then at most one cheap check, never a retry loop. Time spent verifying is time the user is left waiting.
248
249
  - The moment implementation is complete and you switch to verifying, write 90 to the progress file -- verification time must not read as stalled progress.
249
250
  - Do this one task completely, then stop. Do not expand scope.
251
+ - SAVE A VERSION when your change is done, so the user can see and undo it: \`castle-web save-version -m "<short description of what you changed>" <each path you touched>\`. Name those paths explicitly -- a bare \`save-version\` with no paths records the WHOLE deck, including files a sibling agent is still working on. One version for the whole task, at the end; not one per file.
252
+ - Overlap with the other agents working in this deck is fine and expected -- do not try to avoid it or wait for them. Saving only your own paths is all that is asked of you; nothing anyone has written is lost by your save.
253
+ - NEVER run \`restore-version\`. Going back to an earlier version is the user's decision alone -- not yours, not even to undo your own work. If your change is wrong, fix it forward.
250
254
  - Other task agents may be editing this same deck IN PARALLEL. When you change an existing file, READ it first and make a targeted edit to just the part you need -- never overwrite a whole file you have not read. A blind full-file rewrite clobbers other agents' in-flight changes. Being a bit slower and careful here is the right tradeoff.
251
255
  - Favor real, editable assets: for game objects, characters, and scenery, make pixel-art drawings and place them as real actors in the scene rather than code-drawn shapes -- it keeps the deck editable in the editor and remixable. (Data-driven UI like health bars, score/text, and HUD gauges, plus dynamic things like bullets/particles/effects, stay procedural -- don't force those into drawings.)
252
256
  - Respect art ownership: if YOUR task is to create art, make it as real, editable drawing files (NOT hand-written pixel grids or code-drawn shapes), following the deck's CLAUDE.md / AGENTS.md for the drawing format and exact command. But if your prompt only REFERENCES drawing names (a sibling task is creating them in parallel), point the scene at those names and do NOT create the drawing files yourself -- two agents drawing the same sprites clobber each other. The deck's CLAUDE.md / AGENTS.md is the source of truth for how to make art. Reference the intended drawing name from the scene right away (the actor renders a plain-block fallback until the file exists), and remember newly written drawing files need \`npm run restart\` before the kit picks them up.
@@ -264,7 +268,7 @@ phase: wiring the paddle to touch
264
268
  - \`phase\` -- a SHORT, plain-language line for this moment ("wiring the paddle to touch", "drawing the flag"). No file or code names. Update \`avatar\` and \`phase\` together each time your stage changes -- a few times across the task, not every turn. (You may also include \`progress: NN\` here, but the progress file above is the primary progress channel.)
265
269
  - Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, NO file/behavior names or implementation notes -- those are for you, not the player. Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
266
270
  - If you are truly blocked, write the blocker to the notes file and stop rather than guessing wildly.
267
- - Never touch files under .castle/ other than those two paths.`;
271
+ - Never touch files under .castle/ other than those two paths. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
268
272
  }
269
273
  // Appended to claude task agents' system prompt (portable replacement for the
270
274
  // machine-specific /goal slash command): commit to autonomous completion.
@@ -20,3 +20,5 @@ export declare const DEFAULT_MAIN = "main.jsx";
20
20
  export declare function deckMainFile(dir: string): string;
21
21
  export declare function readCastleJson(dir: string): CastleJson | null;
22
22
  export declare function readCastleJsonOrThrow(dir: string): CastleJson | null;
23
+ export declare function ensureVisiblePath(deckDir: string, rel: string): boolean;
24
+ export declare function ensureVisibleGlob(deckDir: string, glob: string, probe: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import picomatch from 'picomatch';
3
4
  export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
4
5
  export function deckStarterScene(dir) {
5
6
  const declared = readCastleJson(dir)?.starterScene;
@@ -32,3 +33,41 @@ export function readCastleJsonOrThrow(dir) {
32
33
  throw new Error(`Could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
33
34
  });
34
35
  }
36
+ // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
37
+ // newly-made folder show in the curated Files tree.
38
+ export function ensureVisiblePath(deckDir, rel) {
39
+ return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
40
+ }
41
+ // Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
42
+ // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
43
+ // everything is visible, and adding a glob would wrongly start hiding things.
44
+ // No-op if an existing glob already covers `probe`, a path the new glob would
45
+ // match. Returns whether it wrote.
46
+ export function ensureVisibleGlob(deckDir, glob, probe) {
47
+ const file = path.join(deckDir, 'castle.json');
48
+ let data;
49
+ try {
50
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ }
52
+ catch {
53
+ return false; // no castle.json yet (deck never saved) -> treat as not curated
54
+ }
55
+ const visible = data.editor && Array.isArray(data.editor.visiblePaths)
56
+ ? data.editor.visiblePaths.filter((v) => typeof v === 'string')
57
+ : null;
58
+ if (!visible || visible.length === 0)
59
+ return false; // not curated -> all visible
60
+ if (visible.includes(glob))
61
+ return false;
62
+ if (picomatch(visible)(probe))
63
+ return false; // already covered
64
+ visible.push(glob);
65
+ data.editor.visiblePaths = visible;
66
+ try {
67
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
@@ -0,0 +1,14 @@
1
+ export type DiffOp = {
2
+ kind: 'same' | 'add' | 'remove';
3
+ text: string;
4
+ };
5
+ /** True for content that is not text: a NUL byte, or bytes that aren't UTF-8. */
6
+ export declare function looksBinary(content: Buffer): boolean;
7
+ /** Every line of both sides, in order, marked same / add / remove. */
8
+ export declare function diffOps(beforeText: string, afterText: string): DiffOp[] | null;
9
+ /**
10
+ * A unified diff of two texts, or null when the change is too large to be worth
11
+ * rendering as one. Header lines are the caller's business -- this is only the
12
+ * hunks.
13
+ */
14
+ export declare function unifiedDiff(beforeText: string, afterText: string): string[] | null;
@@ -0,0 +1,138 @@
1
+ // A small unified-diff generator, for `show-version`.
2
+ //
3
+ // Deliberately not a dependency: the only diff this CLI needs is between two
4
+ // blobs it already has, and a hand-rolled line diff is a few dozen lines with
5
+ // no supply chain attached.
6
+ //
7
+ // Shape of the algorithm: strip the common head and tail first -- a real edit
8
+ // usually touches a few lines in a long file, so this leaves a small middle --
9
+ // then run a longest-common-subsequence over what remains. The LCS table is
10
+ // quadratic, so a middle that stays large after trimming reports a summary
11
+ // instead of a patch rather than eating memory.
12
+ const CONTEXT_LINES = 3;
13
+ const MAX_DIFF_LINES = 2000;
14
+ /** True for content that is not text: a NUL byte, or bytes that aren't UTF-8. */
15
+ export function looksBinary(content) {
16
+ const sample = content.subarray(0, 8000);
17
+ if (sample.includes(0))
18
+ return true;
19
+ // A lone replacement char means the decoder hit a byte sequence that isn't
20
+ // valid UTF-8 -- unless the file genuinely contains one, which is rare enough
21
+ // to accept and harmless to treat as binary.
22
+ return sample.toString('utf8').includes('�');
23
+ }
24
+ function splitLines(text) {
25
+ const lines = text.split('\n');
26
+ // A trailing newline yields a final empty element that is not a line.
27
+ if (lines.length > 0 && lines[lines.length - 1] === '')
28
+ lines.pop();
29
+ return lines;
30
+ }
31
+ // Ops for the middle section, by LCS backtracking.
32
+ function diffMiddle(before, after) {
33
+ const rows = before.length;
34
+ const cols = after.length;
35
+ const table = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0));
36
+ for (let i = rows - 1; i >= 0; i--) {
37
+ for (let j = cols - 1; j >= 0; j--) {
38
+ table[i][j] =
39
+ before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
40
+ }
41
+ }
42
+ const ops = [];
43
+ let i = 0;
44
+ let j = 0;
45
+ while (i < rows && j < cols) {
46
+ if (before[i] === after[j]) {
47
+ ops.push({ kind: 'same', text: before[i] });
48
+ i++;
49
+ j++;
50
+ }
51
+ else if (table[i + 1][j] >= table[i][j + 1]) {
52
+ ops.push({ kind: 'remove', text: before[i] });
53
+ i++;
54
+ }
55
+ else {
56
+ ops.push({ kind: 'add', text: after[j] });
57
+ j++;
58
+ }
59
+ }
60
+ while (i < rows)
61
+ ops.push({ kind: 'remove', text: before[i++] });
62
+ while (j < cols)
63
+ ops.push({ kind: 'add', text: after[j++] });
64
+ return ops;
65
+ }
66
+ /** Every line of both sides, in order, marked same / add / remove. */
67
+ export function diffOps(beforeText, afterText) {
68
+ const before = splitLines(beforeText);
69
+ const after = splitLines(afterText);
70
+ let head = 0;
71
+ while (head < before.length && head < after.length && before[head] === after[head])
72
+ head++;
73
+ let tail = 0;
74
+ while (tail < before.length - head &&
75
+ tail < after.length - head &&
76
+ before[before.length - 1 - tail] === after[after.length - 1 - tail]) {
77
+ tail++;
78
+ }
79
+ const beforeMiddle = before.slice(head, before.length - tail);
80
+ const afterMiddle = after.slice(head, after.length - tail);
81
+ if (beforeMiddle.length > MAX_DIFF_LINES || afterMiddle.length > MAX_DIFF_LINES)
82
+ return null;
83
+ return [
84
+ ...before.slice(0, head).map((text) => ({ kind: 'same', text })),
85
+ ...diffMiddle(beforeMiddle, afterMiddle),
86
+ ...before.slice(before.length - tail).map((text) => ({ kind: 'same', text })),
87
+ ];
88
+ }
89
+ function opsToHunks(ops) {
90
+ const changedAt = ops.map((op) => op.kind !== 'same');
91
+ const hunks = [];
92
+ let beforeLine = 1;
93
+ let afterLine = 1;
94
+ let current = null;
95
+ for (let index = 0; index < ops.length; index++) {
96
+ const op = ops[index];
97
+ // A same-line belongs to a hunk when a change is within CONTEXT_LINES of it
98
+ // on either side; that is what turns scattered edits into readable blocks.
99
+ const near = op.kind !== 'same' ||
100
+ changedAt.slice(Math.max(0, index - CONTEXT_LINES), index + CONTEXT_LINES + 1).some(Boolean);
101
+ if (near) {
102
+ if (!current)
103
+ current = { beforeStart: beforeLine, afterStart: afterLine, lines: [], beforeCount: 0, afterCount: 0 };
104
+ current.lines.push(`${op.kind === 'add' ? '+' : op.kind === 'remove' ? '-' : ' '}${op.text}`);
105
+ if (op.kind !== 'add')
106
+ current.beforeCount++;
107
+ if (op.kind !== 'remove')
108
+ current.afterCount++;
109
+ }
110
+ else if (current) {
111
+ hunks.push(current);
112
+ current = null;
113
+ }
114
+ if (op.kind !== 'add')
115
+ beforeLine++;
116
+ if (op.kind !== 'remove')
117
+ afterLine++;
118
+ }
119
+ if (current)
120
+ hunks.push(current);
121
+ return hunks;
122
+ }
123
+ /**
124
+ * A unified diff of two texts, or null when the change is too large to be worth
125
+ * rendering as one. Header lines are the caller's business -- this is only the
126
+ * hunks.
127
+ */
128
+ export function unifiedDiff(beforeText, afterText) {
129
+ const ops = diffOps(beforeText, afterText);
130
+ if (!ops)
131
+ return null;
132
+ const out = [];
133
+ for (const hunk of opsToHunks(ops)) {
134
+ out.push(`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`);
135
+ out.push(...hunk.lines);
136
+ }
137
+ return out;
138
+ }
@@ -0,0 +1,13 @@
1
+ import { type PlaytestBrowserManager } from "./native/playtest-browser.js";
2
+ export interface HeadlessCoverResult {
3
+ ok: boolean;
4
+ outPath?: string;
5
+ error?: string;
6
+ installedMs?: number;
7
+ }
8
+ export declare function captureCoverHeadless(opts: {
9
+ serveUrl: string;
10
+ outPath: string;
11
+ manager?: PlaytestBrowserManager;
12
+ onProgress?: (message: string) => void;
13
+ }): Promise<HeadlessCoverResult>;
@@ -0,0 +1,99 @@
1
+ // Capture a deck's cover with a real browser instead of asking the page to
2
+ // draw itself.
3
+ //
4
+ // The in-page path (the SDK's html2canvas compositing) re-implements CSS
5
+ // rendering in JS, so it inherits a support list: a WebGL canvas is blank
6
+ // unless the deck happened to pass `preserveDrawingBuffer`, and any element
7
+ // using `filter` / `clip-path` / `mask-image` / a blend mode is dropped. Both
8
+ // fail SILENTLY -- the capture succeeds and returns a plausible image that is
9
+ // missing part of the game -- and both put the burden on the deck to know a
10
+ // platform quirk.
11
+ //
12
+ // Screenshotting through the browser removes that whole class: the engine
13
+ // composites the frame it was already going to composite, so whatever the deck
14
+ // legitimately renders is what lands in the cover. This is the same move the
15
+ // mobile app made for its exp-web snapshots (WKWebView takeSnapshot / PixelCopy)
16
+ // -- ask the platform for its pixels rather than re-deriving them.
17
+ //
18
+ // It buys correctness, not judgment: a cold headless load sees the deck's FIRST
19
+ // moments, where the in-page path saw whatever the creator had on screen. That
20
+ // is why this is the fallback for an unattended save rather than a replacement
21
+ // for the editor's capture button -- see `SETTLE_MS`.
22
+ import fs from "fs";
23
+ import path from "path";
24
+ import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
25
+ // The card is 5:7; this is the same fixed viewport the playtest tool uses, so a
26
+ // deck laid out for one is laid out for the other.
27
+ const VIEWPORT = { width: 500, height: 700 };
28
+ // Retina-ish, so a cover holds up scaled down in a feed and blown up on a deck
29
+ // page. 2x of a 500x700 card is 1000x1400.
30
+ const SCALE = 2;
31
+ const NAV_TIMEOUT_MS = 20_000;
32
+ // Long enough for a deck to boot, load assets and render real frames -- a cover
33
+ // of frame zero is a loading screen. Deliberately generous: this runs once per
34
+ // save, never in a player's path.
35
+ const SETTLE_MS = 3_500;
36
+ // The card the SDK mounts (`initCard`) or a deck marks itself. Falling back to
37
+ // the whole viewport keeps a deck that does neither from getting no cover at
38
+ // all -- the viewport IS the card in play mode.
39
+ const CARD_SELECTOR = "#castle-card, [data-castle-card]";
40
+ // Grab the deck's cover from `serveUrl` and write it to `<projectDir>/.castle`.
41
+ // Never throws; a failure is reported, not raised.
42
+ export async function captureCoverHeadless(opts) {
43
+ const manager = opts.manager ?? createPlaytestBrowserManager();
44
+ const outcome = await manager.withBrowser((browser) => shoot(browser, opts.serveUrl), { onProgress: opts.onProgress });
45
+ if (!outcome.ok)
46
+ return { ok: false, error: outcome.error };
47
+ if (!outcome.value.png)
48
+ return { ok: false, error: outcome.value.error ?? "capture produced no image" };
49
+ try {
50
+ fs.mkdirSync(path.dirname(opts.outPath), { recursive: true });
51
+ fs.writeFileSync(opts.outPath, outcome.value.png);
52
+ }
53
+ catch (e) {
54
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
55
+ }
56
+ return { ok: true, outPath: opts.outPath, installedMs: outcome.installedMs };
57
+ }
58
+ async function shoot(browser, serveUrl) {
59
+ const context = await browser.newContext({
60
+ viewport: { ...VIEWPORT },
61
+ deviceScaleFactor: SCALE,
62
+ // A cover is a still of a game: give it a touch-shaped context, since decks
63
+ // are phone-first and some branch their controls on it.
64
+ hasTouch: true,
65
+ isMobile: true,
66
+ });
67
+ try {
68
+ const page = await context.newPage();
69
+ // `/index.html`, NOT `/`: the serve's root is the editor shell, and the deck
70
+ // itself is what the shell loads into its iframe (see serve.ts). Pointing at
71
+ // `/` photographs the editor. `?edit=0` then asks for the play surface, so
72
+ // the deck renders in its card with no editor UI -- which is what a cover is.
73
+ const url = `${serveUrl.replace(/\/$/, "")}/index.html?edit=0`;
74
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
75
+ // Let it boot and actually draw. `waitForTimeout` rather than a load event:
76
+ // a deck is a game, and "loaded" says nothing about whether it has rendered
77
+ // anything worth showing.
78
+ await page.waitForTimeout(SETTLE_MS);
79
+ // A deck that failed to build renders vite's error overlay, and a browser
80
+ // screenshots that just as faithfully as it screenshots a game -- so without
81
+ // this, a broken deck publishes a cover of its own stack trace. The in-page
82
+ // path could not do this (nothing renders, so nothing is captured), so it is
83
+ // a failure mode this approach introduces and has to close itself.
84
+ const broken = await page.$("vite-error-overlay");
85
+ if (broken)
86
+ return { error: "the deck is showing a build error, so there is nothing to photograph" };
87
+ const card = await page.$(CARD_SELECTOR);
88
+ // Element screenshot clips to the card for us. Without a card, the viewport
89
+ // IS the card in play mode, so shoot that.
90
+ const png = card ? await card.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
91
+ return { png };
92
+ }
93
+ catch (e) {
94
+ return { error: e instanceof Error ? e.message : String(e) };
95
+ }
96
+ finally {
97
+ await context.close().catch(() => undefined);
98
+ }
99
+ }
package/dist/ide.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as http from 'http';
2
2
  import { Duplex } from 'stream';
3
3
  import { type RawData } from 'ws';
4
+ import { COVER_FILE } from './localPaths.js';
4
5
  export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
5
6
  export declare const PTY_WS_PATH = "/__castle/pty";
6
7
  export declare const VENDOR_PREFIX = "/__castle/vendor/";
@@ -8,7 +9,7 @@ export declare const FAVICON_FILES: string[];
8
9
  export declare const FAVICON_LINK_TAGS: string;
9
10
  export declare const FILES_API_PREFIX = "/__castle/files/";
10
11
  export declare const COVER_API_PATH = "/__castle/cover";
11
- export declare const COVER_FILE = "preview.png";
12
+ export { COVER_FILE };
12
13
  export declare function rawDataToString(data: RawData): string;
13
14
  export interface IdeServer {
14
15
  /** Serve the IDE page + its static assets. Returns true if it handled the request. */
package/dist/ide.js CHANGED
@@ -16,10 +16,13 @@ import headlessPkg from '@xterm/headless';
16
16
  import { SerializeAddon } from '@xterm/addon-serialize';
17
17
  import { WebSocketServer } from 'ws';
18
18
  import { IMPORTS_DIR, importStatuses, updateImport } from './imports.js';
19
+ import { ensureVisibleGlob, ensureVisiblePath } from './castleJson.js';
19
20
  import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
20
21
  import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
21
22
  import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
22
23
  import { readRequestBody, sendJson } from './httpJson.js';
24
+ import { COVER_FILE } from './localPaths.js';
25
+ import { applyVersionRestore, createVersion, NotOnThisLine, pendingChanges, UnsavedChanges, versionSummaries, } from './versions.js';
23
26
  import { envForUserShell, installCliShims } from './byo-auth.js';
24
27
  const HeadlessTerminal = headlessPkg.Terminal;
25
28
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -135,15 +138,34 @@ export const FAVICON_LINK_TAGS = [
135
138
  // hands those files to the kit iframe and keeps the builtin editor as the
136
139
  // default for everything else).
137
140
  export const FILES_API_PREFIX = '/__castle/files/';
138
- // The deck's local cover image. Its own endpoint rather than a files/upload,
139
- // because this file is written over and over (once per automatic capture) and
140
- // upload deliberately refuses to clobber. A cover is platform vocabulary, not a
141
- // kit's -- `save-deck` is what publishes preview.png -- so it belongs here.
141
+ // The deck's local cover image. Nothing in THIS shell posts here any more --
142
+ // covers are captured headlessly by `save-deck` (see headlessCover.ts), which
143
+ // photographs the deck with a real browser instead of asking the page to
144
+ // rasterize itself. The endpoint stays because the shell is baked into the
145
+ // sandbox image: a warm sandbox keeps running an older shell that still posts
146
+ // its automatic captures here, and accepting them is cheaper than making those
147
+ // requests 404. `save-deck` still reads preview.png as a last-resort fallback.
148
+ //
149
+ // Its own endpoint rather than a files/upload because this file is written over
150
+ // and over and upload deliberately refuses to clobber. A cover is platform
151
+ // vocabulary, not a kit's, so it belongs here.
142
152
  export const COVER_API_PATH = '/__castle/cover';
143
- export const COVER_FILE = 'preview.png';
153
+ // Re-exported so `save-deck` and anything else that already asks the serve for
154
+ // the cover's name keeps working.
155
+ export { COVER_FILE };
144
156
  // Directories never surfaced in the file list / never read or written through
145
157
  // the builtin editor: VCS, deck-private state, and dependency trees.
146
158
  const FILES_IGNORE_DIRS = new Set(['.git', '.castle', 'node_modules', 'dist']);
159
+ // Never writable through the files API, whatever the tree shows. `.castle` holds
160
+ // the deck's private state, and since build 1 that includes the version store --
161
+ // the one place where losing bytes loses history rather than a file. The list
162
+ // above is about what the tree SHOWS and could reasonably change; this is about
163
+ // what a write may touch, so the store stays safe if it ever does.
164
+ //
165
+ // A guard, not a lock: the directory keeps its normal permissions. Making it
166
+ // read-only and unlocking around writes races with concurrent processes, gets
167
+ // clobbered by tools, and still would not stop a raw shell write.
168
+ const PROTECTED_WRITE_DIRS = new Set(['.castle']);
147
169
  // Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
148
170
  // a huge asset is a problem for the deck long before it is a problem here --
149
171
  // refuse it at the door with a message rather than let it land and break `save`.
@@ -197,6 +219,12 @@ function resolveDeckPath(deckDir, requestedPath, opts = {}) {
197
219
  return { ok: false, error: `Path outside the deck: ${requestedPath}` };
198
220
  }
199
221
  const parts = normalized.split(path.sep);
222
+ if (opts.mutation && parts.some((p) => PROTECTED_WRITE_DIRS.has(p))) {
223
+ return {
224
+ ok: false,
225
+ error: `${requestedPath} is the deck's own state (the version store lives here) and is not writable.`,
226
+ };
227
+ }
200
228
  if (parts.some((p) => FILES_IGNORE_DIRS.has(p))) {
201
229
  return { ok: false, error: `Protected deck path: ${requestedPath}` };
202
230
  }
@@ -347,6 +375,61 @@ function sendFailure(res, action, rel, err) {
347
375
  const message = err instanceof Error ? err.message : String(err);
348
376
  sendJson(res, 500, { error: `Could not ${action} ${rel}: ${message}` });
349
377
  }
378
+ // The version panel's three calls, on the same store the `save-version` /
379
+ // `restore-version` commands use -- the editor is another way to run them, not
380
+ // a second history.
381
+ function handleVersionsList(deckDir, res) {
382
+ try {
383
+ // The history and what is outstanding against it, together: the panel shows
384
+ // them as one picture, and two calls could disagree about the moment.
385
+ sendJson(res, 200, { versions: versionSummaries(deckDir), pending: pendingChanges(deckDir) });
386
+ }
387
+ catch (e) {
388
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
389
+ }
390
+ }
391
+ // Saves everything dirty: the panel has no path selection, so partial saves
392
+ // stay a CLI and agent thing.
393
+ function handleVersionSave(deckDir, req, res) {
394
+ withJsonBody(req, res, (body) => {
395
+ const message = typeof body.message === 'string' ? body.message : '';
396
+ try {
397
+ const { version } = createVersion(deckDir, { message });
398
+ sendJson(res, 200, { ok: true, id: version?.id ?? null });
399
+ }
400
+ catch (e) {
401
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
402
+ }
403
+ });
404
+ }
405
+ // Never forced. Unsaved work that a restore would overwrite comes back as a 409
406
+ // naming the files, which the panel shows -- saving a version first is the fix,
407
+ // and it is one click away in the same panel.
408
+ function handleVersionRestore(deckDir, req, res) {
409
+ withJsonBody(req, res, (body) => {
410
+ if (typeof body.id !== 'string' || !body.id) {
411
+ return sendJson(res, 400, { error: 'Missing version id.' });
412
+ }
413
+ try {
414
+ const outcome = applyVersionRestore(deckDir, body.id);
415
+ sendJson(res, 200, {
416
+ ok: true,
417
+ restored: outcome.applied.length,
418
+ offChain: outcome.offChain,
419
+ alreadyThere: outcome.alreadyThere,
420
+ });
421
+ }
422
+ catch (e) {
423
+ // Both refusals are answers, not faults: the caller asked for something
424
+ // this history can't do, and the message says which.
425
+ const refused = e instanceof UnsavedChanges || e instanceof NotOnThisLine;
426
+ const message = e instanceof Error ? e.message : String(e);
427
+ sendJson(res, refused ? 409 : 500, {
428
+ error: e instanceof UnsavedChanges ? `${message}\nSave a version first.` : message,
429
+ });
430
+ }
431
+ });
432
+ }
350
433
  function handleFilesWrite(deckDir, req, res) {
351
434
  withMutationPath(deckDir, req, res, (target, body) => {
352
435
  if (typeof body.contents !== 'string') {
@@ -362,44 +445,6 @@ function handleFilesWrite(deckDir, req, res) {
362
445
  }
363
446
  });
364
447
  }
365
- // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
366
- // newly-made folder show in the curated Files tree.
367
- function ensureVisiblePath(deckDir, rel) {
368
- return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
369
- }
370
- // Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
371
- // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
372
- // everything is visible, and adding a glob would wrongly start hiding things.
373
- // No-op if an existing glob already covers `probe`, a path the new glob would
374
- // match. Returns whether it wrote.
375
- function ensureVisibleGlob(deckDir, glob, probe) {
376
- const file = path.join(deckDir, 'castle.json');
377
- let data;
378
- try {
379
- data = JSON.parse(fs.readFileSync(file, 'utf8'));
380
- }
381
- catch {
382
- return false; // no castle.json yet (deck never saved) -> treat as not curated
383
- }
384
- const visible = data.editor && Array.isArray(data.editor.visiblePaths)
385
- ? data.editor.visiblePaths.filter((v) => typeof v === 'string')
386
- : null;
387
- if (!visible || visible.length === 0)
388
- return false; // not curated -> all visible
389
- if (visible.includes(glob))
390
- return false;
391
- if (picomatch(visible)(probe))
392
- return false; // already covered
393
- visible.push(glob);
394
- data.editor.visiblePaths = visible;
395
- try {
396
- fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
397
- return true;
398
- }
399
- catch {
400
- return false;
401
- }
402
- }
403
448
  function handleFilesMkdir(deckDir, req, res) {
404
449
  withMutationPath(deckDir, req, res, (target) => {
405
450
  try {
@@ -688,6 +733,18 @@ function handleFilesApi(deckDir, req, res, reqPath) {
688
733
  })();
689
734
  return true;
690
735
  }
736
+ if (action === 'versions') {
737
+ handleVersionsList(deckDir, res);
738
+ return true;
739
+ }
740
+ if (action === 'save-version') {
741
+ handleVersionSave(deckDir, req, res);
742
+ return true;
743
+ }
744
+ if (action === 'restore-version') {
745
+ handleVersionRestore(deckDir, req, res);
746
+ return true;
747
+ }
691
748
  if (action === 'list') {
692
749
  // `?all=1` returns the unfiltered listing (the "show hidden files & folders"
693
750
  // toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
package/dist/imports.js CHANGED
@@ -5,7 +5,7 @@ import * as api from './api.js';
5
5
  import { runTar } from './save-deck.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
7
  import { getKitsDir } from './localPaths.js';
8
- import { readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
8
+ import { ensureVisibleGlob, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
9
9
  // Adding another deck as a dependency (`castle-web add-import`; removing one is a
10
10
  // later command). Deliberately NOT `get-deck`: that one
11
11
  // replaces THIS deck's own source from the server (and carries guards for the
@@ -485,7 +485,7 @@ export async function addImportTo(dir, options = {}) {
485
485
  const declaredMain = tryReadCastleJson(importDir)?.main;
486
486
  let adoptedMain = null;
487
487
  if (typeof declaredMain === 'string' && declaredMain.trim()) {
488
- const { isPristineBareDeck, adoptImportEntry, writeStarterScene } = await import('./init.js');
488
+ const { isPristineBareDeck, adoptImportEntry, writeStarterScene, writeStarterTheme } = await import('./init.js');
489
489
  if (isPristineBareDeck(targetDir)) {
490
490
  adoptImportEntry(targetDir, alias, declaredMain.trim());
491
491
  adoptedMain = declaredMain.trim();
@@ -500,6 +500,14 @@ export async function addImportTo(dir, options = {}) {
500
500
  console.warn(`Adopted ${alias} but could not write its starter scene: ${e instanceof Error ? e.message : String(e)}`);
501
501
  }
502
502
  }
503
+ // Same for the kit's starter theme.style (skips on its own if the deck
504
+ // already has one or the kit ships none).
505
+ try {
506
+ writeStarterTheme(targetDir, importDir);
507
+ }
508
+ catch (e) {
509
+ console.warn(`Adopted ${alias} but could not write its starter theme.style: ${e instanceof Error ? e.message : String(e)}`);
510
+ }
503
511
  }
504
512
  }
505
513
  // An import's packages have to be ON DISK, not just named in package.json:
@@ -557,9 +565,41 @@ export async function addImport(dir, options = {}) {
557
565
  // `update-import` and the serve-start auto update come through here, so there
558
566
  // is one path that replaces an import's files rather than two that have to be
559
567
  // kept in step. `log` is how the caller labels the lines it produces.
568
+ // A kit update can introduce deck-level starter content that an existing deck
569
+ // predates. Same additive spirit as syncImportDependencies (which adds a kit's
570
+ // new package deps to the deck's own package.json on update): seed the kit's
571
+ // `theme.style` at the deck root when the deck has none. The engine reads it
572
+ // from the deck root only -- it is the creator's file, like scenes/ -- so
573
+ // without this, decks scaffolded before the kit shipped one would never get
574
+ // it. Never overwrites: a deck that has the file keeps it, whatever the kit
575
+ // ships. Also registers the file in a curated deck's editor.visiblePaths so it
576
+ // shows in the Files tree (the same courtesy mkdir/upload extend).
577
+ function seedKitTheme(deckDir, alias, log) {
578
+ const importDir = path.join(deckDir, IMPORTS_DIR, alias);
579
+ // Only kits (imports that declare an entry in castle.json `main`) seed deck
580
+ // content -- a plain deck import that happens to carry a theme.style of its
581
+ // own must not set this deck's palette.
582
+ const main = tryReadCastleJson(importDir)?.main;
583
+ if (typeof main !== 'string' || !main.trim())
584
+ return;
585
+ const src = path.join(importDir, 'theme.style');
586
+ const dest = path.join(deckDir, 'theme.style');
587
+ if (!fs.existsSync(src) || fs.existsSync(dest))
588
+ return;
589
+ try {
590
+ fs.copyFileSync(src, dest);
591
+ }
592
+ catch (e) {
593
+ console.warn(`Could not seed theme.style from ${alias}: ${e instanceof Error ? e.message : String(e)}`);
594
+ return;
595
+ }
596
+ ensureVisibleGlob(deckDir, 'theme.style', 'theme.style');
597
+ log(`Seeded theme.style from ${alias} (new in this version; the deck's to edit)`);
598
+ }
560
599
  async function applyImportUpdate(deckDir, alias, update, source, log) {
561
600
  await placeImport(deckDir, alias, update.deckId, source, update.via);
562
601
  log(`Updated ${alias} (${update.from} -> ${source.updatedAt})`);
602
+ seedKitTheme(deckDir, alias, log);
563
603
  // Its own dependencies may have moved too.
564
604
  const transitive = await addTransitiveImports(deckDir, alias, importedDeckIds(deckDir));
565
605
  for (const t of transitive)