castle-web-cli 0.4.75 → 0.4.77

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 (74) hide show
  1. package/dist/agent-prompts.d.ts +13 -0
  2. package/dist/agent-prompts.js +43 -13
  3. package/dist/agent.d.ts +29 -0
  4. package/dist/agent.js +1004 -257
  5. package/dist/castle-host/host.js +59 -5
  6. package/dist/commonInstructions.d.ts +1 -1
  7. package/dist/commonInstructions.js +11 -1
  8. package/dist/filesChanged.d.ts +25 -0
  9. package/dist/filesChanged.js +140 -0
  10. package/dist/ide.d.ts +1 -0
  11. package/dist/ide.js +250 -1
  12. package/dist/init.js +46 -4
  13. package/dist/save-deck.js +8 -1
  14. package/dist/serve.js +45 -3
  15. package/dist/shell/assets/index-CvHiGhAV.js +141 -0
  16. package/dist/shell/assets/{index-WNbOHPBj.css → index-QteLRDnK.css} +1 -1
  17. package/dist/shell/index.html +2 -2
  18. package/kits/basic-2d/CLAUDE.md +13 -5
  19. package/kits/basic-2d/castle.json +15 -0
  20. package/kits/basic-2d/drawings/pig.pxart +22 -55
  21. package/kits/basic-2d/editors/PlayOnly.jsx +8 -3
  22. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  23. package/kits/basic-2d/editors/SingleEditor.jsx +30 -59
  24. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  25. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  26. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  27. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  28. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
  29. package/kits/basic-2d/engine/liveReload.js +88 -0
  30. package/kits/basic-2d/engine/ui.jsx +15 -0
  31. package/kits/basic-2d/engine/ui.module.css +44 -117
  32. package/kits/basic-2d/main.jsx +6 -10
  33. package/package.json +10 -1
  34. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  35. package/kits/basic-2d/editors/App.jsx +0 -226
  36. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  37. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  38. package/kits/basic-2d/editors/codeTheme.js +0 -135
  39. package/kits/basic-2d/engine/playConsole.js +0 -66
  40. package/kits/basic-2d/pnpm-workspace.yaml +0 -3
  41. package/kits/basic-3d/.prettierrc +0 -8
  42. package/kits/basic-3d/CLAUDE.md +0 -162
  43. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  44. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  45. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  46. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  47. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  48. package/kits/basic-3d/editors/App.jsx +0 -147
  49. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  50. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  51. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  52. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  53. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
  54. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  55. package/kits/basic-3d/editors/editorHistory.js +0 -52
  56. package/kits/basic-3d/editors/viewportRig.js +0 -90
  57. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
  58. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  59. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  60. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  61. package/kits/basic-3d/engine/files.js +0 -73
  62. package/kits/basic-3d/engine/scene.js +0 -502
  63. package/kits/basic-3d/engine/threeUtil.js +0 -260
  64. package/kits/basic-3d/engine/ui.jsx +0 -352
  65. package/kits/basic-3d/engine/ui.module.css +0 -944
  66. package/kits/basic-3d/eslint.config.js +0 -51
  67. package/kits/basic-3d/index.html +0 -11
  68. package/kits/basic-3d/main.jsx +0 -10
  69. package/kits/basic-3d/models/block.model +0 -14
  70. package/kits/basic-3d/package-lock.json +0 -2713
  71. package/kits/basic-3d/package.json +0 -41
  72. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  73. package/kits/basic-3d/scenes/main.scene +0 -76
  74. package/kits/basic-3d/vite.config.js +0 -1
@@ -26,13 +26,19 @@ const COMMAND_NAMES = [
26
26
  "time.getServerTime",
27
27
  "pass.has",
28
28
  "pass.offer",
29
+ "portal.open",
30
+ "portal.prefetch",
29
31
  ];
30
32
  // Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
31
33
  // to the host's optional platformHandler (mobile renders native UI; web shows an
32
34
  // upsell). A host with no platformHandler returns the command's normalized
33
35
  // "unavailable" outcome rather than an error — capability divergence is the
34
36
  // host's concern, never a deck-facing gate.
35
- const PLATFORM_COMMAND_NAMES = ["pass.offer"];
37
+ const PLATFORM_COMMAND_NAMES = [
38
+ "pass.offer",
39
+ "portal.open",
40
+ "portal.prefetch",
41
+ ];
36
42
  function isCommandName(value) {
37
43
  return (typeof value === "string" &&
38
44
  COMMAND_NAMES.includes(value));
@@ -98,6 +104,8 @@ function runCommand(ctx, command, params, caps) {
98
104
  // Platform commands are handled above; listed here to keep the switch
99
105
  // exhaustive over CommandName.
100
106
  case "pass.offer":
107
+ case "portal.open":
108
+ case "portal.prefetch":
101
109
  return runPlatformCommand(ctx, command, params, caps);
102
110
  }
103
111
  }
@@ -110,8 +118,12 @@ async function runPlatformCommand(ctx, command, params, caps) {
110
118
  switch (command) {
111
119
  case "pass.offer":
112
120
  return passesOffer(ctx, params, caps);
121
+ case "portal.open":
122
+ return portalOpen(ctx, params, caps);
123
+ case "portal.prefetch":
124
+ return portalPrefetch(ctx, params, caps);
113
125
  default:
114
- return unavailableOutcome(command);
126
+ return unavailableOutcome();
115
127
  }
116
128
  }
117
129
  async function passHas(ctx, params, caps) {
@@ -154,13 +166,55 @@ function normalizePassOutcome(value) {
154
166
  }
155
167
  return { status: "cancelled" };
156
168
  }
157
- function unavailableOutcome(command) {
158
- // Only passes exists today; keep this total over future platform commands.
159
- if (command === "pass.offer") {
169
+ async function portalOpen(ctx, params, caps) {
170
+ const targetDeckId = asString(params.targetDeckId, "targetDeckId", "portal.open");
171
+ // Hosts that can't navigate the feed (dev CLI — no handler; editor) get a
172
+ // normalized unavailable, never an error.
173
+ if (!caps.platformHandler)
160
174
  return { status: "unavailable" };
175
+ // Pass the source deckId from trusted context (like pass.offer), never one
176
+ // the deck supplied, so a deck can't spoof navigating from another deck.
177
+ const deckId = requireDeckId(ctx, "portal.open");
178
+ const outcome = await caps.platformHandler("portal.open", { targetDeckId, deckId }, ctx);
179
+ return normalizePortalOutcome(outcome);
180
+ }
181
+ function normalizePortalOutcome(value) {
182
+ const record = typeof value === "object" && value !== null
183
+ ? value
184
+ : {};
185
+ const status = record.status;
186
+ const valid = ["navigating", "unavailable"];
187
+ if (typeof status === "string" && valid.includes(status)) {
188
+ return { status: status };
161
189
  }
162
190
  return { status: "unavailable" };
163
191
  }
192
+ async function portalPrefetch(ctx, params, caps) {
193
+ const targetDeckId = asString(params.targetDeckId, "targetDeckId", "portal.prefetch");
194
+ if (!caps.platformHandler)
195
+ return { status: "unavailable" };
196
+ const deckId = requireDeckId(ctx, "portal.prefetch");
197
+ const outcome = await caps.platformHandler("portal.prefetch", { targetDeckId, deckId }, ctx);
198
+ return normalizePortalPrefetchOutcome(outcome);
199
+ }
200
+ function normalizePortalPrefetchOutcome(value) {
201
+ const record = typeof value === "object" && value !== null
202
+ ? value
203
+ : {};
204
+ const status = record.status;
205
+ const valid = [
206
+ "prefetching",
207
+ "rejected",
208
+ "unavailable",
209
+ ];
210
+ if (typeof status === "string" && valid.includes(status)) {
211
+ return { status: status };
212
+ }
213
+ return { status: "unavailable" };
214
+ }
215
+ function unavailableOutcome() {
216
+ return { status: "unavailable" };
217
+ }
164
218
  async function deckStorageLoad(ctx, gql) {
165
219
  const deckId = requireDeckId(ctx, "deckStorage.load");
166
220
  const data = await graphql(gql, DECK_STORAGE_QUERY, { deckId, sessionId: ctx.sessionId }, "deckStorage.load");
@@ -1 +1 @@
1
- export declare const COMMON_INSTRUCTIONS = "## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n";
1
+ export declare const COMMON_INSTRUCTIONS = "## Assets (every deck)\n\n- **Load static assets (drawings, audio, etc.) through the bundler \u2014 never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally \u2014 but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.\n\n## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n- **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units \u2014 re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.\n - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead \u2014 don't DPR-scale that; the crisp chunky look is the point.\n";
@@ -2,7 +2,17 @@
2
2
  // deck's CLAUDE.md, regardless of kit (or no kit). Single source of truth —
3
3
  // edit here, not in the kits. Keep it truly kit-agnostic; kit-specific rules
4
4
  // (e.g. Space being reserved for play/stop) live in each kit's own CLAUDE.md.
5
- export const COMMON_INSTRUCTIONS = `## Touch controls (every deck)
5
+ export const COMMON_INSTRUCTIONS = `## Assets (every deck)
6
+
7
+ - **Load static assets (drawings, audio, etc.) through the bundler — never runtime-\`fetch\` a loose file path.** Use a static \`import\`, \`import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })\`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so \`fetch('drawings/qb.svg')\` looks like it works locally — but \`save-deck\` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw \`fetch\`.
8
+
9
+ ## Touch controls (every deck)
6
10
 
7
11
  - **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch — direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics — a game with no directional movement should have no movement controls at all.
12
+
13
+ ## Fit the card (every deck)
14
+
15
+ - **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, \`min()\`, \`clamp()\`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports \`CARD_RATIO\` (= 5 / 7) if you need the exact ratio.
16
+ - **Hand-rolled \`<canvas>\` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times \`devicePixelRatio\` (e.g. \`canvas.width = rect.width * dpr\`), keep the CSS width/height as the layout size, and scale the 2D context (\`ctx.scale(dpr, dpr)\`) so drawing code stays in CSS units — re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.
17
+ - Exception: deliberate pixel art wants a fixed low-resolution backing store with \`image-rendering: pixelated\` CSS instead — don't DPR-scale that; the crisp chunky look is the point.
8
18
  `;
@@ -0,0 +1,25 @@
1
+ import type { ViteDevServer } from 'vite';
2
+ export interface FileChangeEntry {
3
+ /** Deck-relative POSIX path. */
4
+ path: string;
5
+ event: 'add' | 'change' | 'delete';
6
+ /**
7
+ * Deck-relative paths of modules affected via the Vite import graph: the
8
+ * changed file itself (when it's part of the module graph) plus every
9
+ * transitive importer. Empty when the file isn't imported by anything.
10
+ */
11
+ affected: string[];
12
+ }
13
+ export interface FilesChangedMessage {
14
+ type: 'files_changed';
15
+ changes: FileChangeEntry[];
16
+ /** Union of every change's path + affected set, deduped. */
17
+ affected: string[];
18
+ }
19
+ export declare function installFilesChangedWatcher(opts: {
20
+ vite: ViteDevServer;
21
+ projectDir: string;
22
+ broadcast: (msg: FilesChangedMessage) => void;
23
+ /** Drop Vite transform caches (so `import.meta.glob` rescans new files). */
24
+ invalidate: () => void;
25
+ }): void;
@@ -0,0 +1,140 @@
1
+ // Serve-side files-changed broadcast. Watches the deck dir through Vite's own
2
+ // chokidar watcher (one watcher for everything), coalesces bursts of fs events
3
+ // into a single `files_changed` message, and folds in the Vite module graph:
4
+ // each change carries the transitive importer closure of the changed file, so
5
+ // consumers can tell "code my bundle depends on changed" apart from "a loose
6
+ // file changed". Vite's own HMR stays fully OFF (`server.hmr: false` in
7
+ // serve.ts) — reload decisions belong to consumers (the kit engine, editors,
8
+ // the shell), not to Vite.
9
+ import * as path from 'path';
10
+ const IGNORED_SEGMENTS = new Set(['node_modules', '.castle', '.git']);
11
+ // Coalescing: a burst of writes (agent editing several files, an editor's
12
+ // debounced saves landing together) becomes one message. DEBOUNCE resets per
13
+ // event; MAX bounds total latency under a sustained stream.
14
+ const DEBOUNCE_MS = 100;
15
+ const MAX_WAIT_MS = 500;
16
+ export function installFilesChangedWatcher(opts) {
17
+ const { vite, projectDir, broadcast, invalidate } = opts;
18
+ const pending = new Map();
19
+ let debounceTimer = null;
20
+ let maxTimer = null;
21
+ const flush = () => {
22
+ if (debounceTimer)
23
+ clearTimeout(debounceTimer);
24
+ if (maxTimer)
25
+ clearTimeout(maxTimer);
26
+ debounceTimer = null;
27
+ maxTimer = null;
28
+ if (pending.size === 0)
29
+ return;
30
+ const changes = [...pending.entries()]
31
+ .map(([relPath, change]) => ({
32
+ path: relPath,
33
+ event: change.event,
34
+ affected: [...change.affected].sort(),
35
+ }))
36
+ .sort((a, b) => a.path.localeCompare(b.path));
37
+ pending.clear();
38
+ // New/deleted files can be picked up by `import.meta.glob` importers whose
39
+ // cached transforms wouldn't rescan; drop caches like `restart` does.
40
+ if (changes.some((c) => c.event !== 'change'))
41
+ invalidate();
42
+ const affected = new Set();
43
+ for (const change of changes) {
44
+ affected.add(change.path);
45
+ for (const p of change.affected)
46
+ affected.add(p);
47
+ }
48
+ broadcast({ type: 'files_changed', changes, affected: [...affected].sort() });
49
+ };
50
+ const schedule = () => {
51
+ if (debounceTimer)
52
+ clearTimeout(debounceTimer);
53
+ debounceTimer = setTimeout(flush, DEBOUNCE_MS);
54
+ if (!maxTimer)
55
+ maxTimer = setTimeout(flush, MAX_WAIT_MS);
56
+ };
57
+ vite.watcher.on('all', (watchEvent, absFile) => {
58
+ const event = watchEvent === 'add' || watchEvent === 'change'
59
+ ? watchEvent
60
+ : watchEvent === 'unlink'
61
+ ? 'delete'
62
+ : null;
63
+ if (!event)
64
+ return; // addDir / unlinkDir
65
+ const relPath = deckRelative(projectDir, absFile);
66
+ if (!relPath)
67
+ return;
68
+ // Compute the importer closure at event time: for deletes, Vite prunes the
69
+ // file's graph entry shortly after the event, so waiting until flush would
70
+ // lose the edges.
71
+ const affected = importerClosure(vite, projectDir, absFile);
72
+ const existing = pending.get(relPath);
73
+ if (existing) {
74
+ // Merge events: a file created then edited within the window is still an
75
+ // 'add' for consumers; otherwise the newest event wins.
76
+ if (!(existing.event === 'add' && event === 'change'))
77
+ existing.event = event;
78
+ for (const p of affected)
79
+ existing.affected.add(p);
80
+ }
81
+ else {
82
+ pending.set(relPath, { event, affected: new Set(affected) });
83
+ }
84
+ schedule();
85
+ });
86
+ }
87
+ // Deck-relative POSIX path, or null when the file is outside the deck or under
88
+ // an ignored directory.
89
+ function deckRelative(projectDir, absFile) {
90
+ const rel = path.relative(projectDir, absFile);
91
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
92
+ return null;
93
+ const posix = rel.split(path.sep).join('/');
94
+ if (posix.split('/').some((segment) => IGNORED_SEGMENTS.has(segment)))
95
+ return null;
96
+ return posix;
97
+ }
98
+ function clientModuleGraph(vite) {
99
+ try {
100
+ const graph = vite
101
+ .environments?.client?.moduleGraph ?? vite.moduleGraph;
102
+ return graph ?? null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ // Deck-relative paths of the changed file's modules plus all transitive
109
+ // importers. Empty when the file isn't part of the module graph.
110
+ function importerClosure(vite, projectDir, absFile) {
111
+ const graph = clientModuleGraph(vite);
112
+ if (!graph)
113
+ return [];
114
+ let start;
115
+ try {
116
+ start = graph.getModulesByFile(absFile);
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ if (!start || start.size === 0)
122
+ return [];
123
+ const seen = new Set();
124
+ const files = new Set();
125
+ const queue = [...start];
126
+ while (queue.length > 0) {
127
+ const mod = queue.pop();
128
+ if (seen.has(mod))
129
+ continue;
130
+ seen.add(mod);
131
+ if (mod.file) {
132
+ const rel = deckRelative(projectDir, mod.file);
133
+ if (rel)
134
+ files.add(rel);
135
+ }
136
+ for (const importer of mod.importers ?? [])
137
+ queue.push(importer);
138
+ }
139
+ return [...files];
140
+ }
package/dist/ide.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Duplex } from "stream";
3
3
  import { type RawData } from "ws";
4
4
  export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
5
5
  export declare const PTY_WS_PATH = "/__castle/pty";
6
+ export declare const FILES_API_PREFIX = "/__castle/files/";
6
7
  export declare function rawDataToString(data: RawData): string;
7
8
  export interface IdeServer {
8
9
  /** Serve the IDE page + its static assets. Returns true if it handled the request. */
package/dist/ide.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // client gets a full replay of the current screen + scrollback.
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
+ import picomatch from "picomatch";
11
12
  import { fileURLToPath } from "url";
12
13
  import { spawn as spawnPty } from "@lydell/node-pty";
13
14
  import headlessPkg from "@xterm/headless";
@@ -62,6 +63,251 @@ function serveShellFile(res, asset) {
62
63
  // upgrade handler on Vite's HTTP server).
63
64
  export const IDE_ASSET_PREFIX = "/__castle/ide/";
64
65
  export const PTY_WS_PATH = "/__castle/pty";
66
+ // Builtin Files + code-editor panels talk to the deck through these endpoints
67
+ // (the shell no longer routes file browsing / code editing through the kit
68
+ // iframe). `list`/`read`/`write` operate on files within the deck dir;
69
+ // `info` reports which extensions the kit owns a rich editor for (so the shell
70
+ // hands those files to the kit iframe and keeps the builtin editor as the
71
+ // default for everything else).
72
+ export const FILES_API_PREFIX = "/__castle/files/";
73
+ // Directories never surfaced in the file list / never read or written through
74
+ // the builtin editor: VCS, deck-private state, and dependency trees.
75
+ const FILES_IGNORE_DIRS = new Set([
76
+ ".git",
77
+ ".castle",
78
+ "node_modules",
79
+ "dist",
80
+ ]);
81
+ // Fallback set of kit-owned rich-editor extensions: the known castle rich
82
+ // content types across kit variants (.pxart in basic-2d, .drawing in older
83
+ // kits). Only used when a kit is present but its getFileKind couldn't be parsed.
84
+ // The builtin code editor renders everything else (and everything on a kitless
85
+ // deck). Kept here -- one place -- rather than baked into the shell, so a future
86
+ // kit manifest can supply it without a shell change.
87
+ const FALLBACK_KIT_EDITOR_EXTS = [".scene", ".pxart", ".drawing"];
88
+ // Extensions that are always code/text, even if a kit's getFileKind mentions
89
+ // them -- these get the builtin editor, never a rich kit editor.
90
+ const CODE_TEXT_KINDS = new Set(["code", "text"]);
91
+ // Parse the kit's `engine/files.js` getFileKind for extensions it maps to a
92
+ // rich (non code/text) kind -- the authoritative, per-deck source of which
93
+ // files the kit owns an editor for. Returns null if the file is absent so the
94
+ // caller can fall back. Matches lines like:
95
+ // if (path.endsWith('.scene')) return 'scene';
96
+ function parseKitEditorExtensions(deckDir) {
97
+ let src;
98
+ try {
99
+ src = fs.readFileSync(path.join(deckDir, "engine", "files.js"), "utf8");
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ const exts = [];
105
+ const re = /endsWith\(\s*['"](\.[^'"]+)['"]\s*\)\s*\)\s*return\s*['"]([^'"]+)['"]/g;
106
+ for (let m = re.exec(src); m; m = re.exec(src)) {
107
+ const [, ext, kind] = m;
108
+ if (!CODE_TEXT_KINDS.has(kind) && !exts.includes(ext))
109
+ exts.push(ext);
110
+ }
111
+ return exts;
112
+ }
113
+ // Resolve a client-supplied deck-relative path to an absolute path inside
114
+ // `deckDir`, rejecting traversal / protected dirs. Mirrors serve.ts's
115
+ // writeProjectFile guards so the builtin editor can't escape the deck.
116
+ function resolveDeckPath(deckDir, requestedPath) {
117
+ if (typeof requestedPath !== "string" || requestedPath.trim() === "") {
118
+ return { ok: false, error: "Missing file path." };
119
+ }
120
+ const normalized = path.normalize(requestedPath.replace(/\\/g, "/"));
121
+ if (normalized === "." ||
122
+ normalized === ".." ||
123
+ path.isAbsolute(normalized) ||
124
+ normalized.startsWith(`..${path.sep}`)) {
125
+ return { ok: false, error: `Path outside the deck: ${requestedPath}` };
126
+ }
127
+ const parts = normalized.split(path.sep);
128
+ if (parts.some((p) => FILES_IGNORE_DIRS.has(p))) {
129
+ return { ok: false, error: `Protected deck path: ${requestedPath}` };
130
+ }
131
+ const abs = path.resolve(deckDir, normalized);
132
+ const rel = path.relative(deckDir, abs);
133
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
134
+ return { ok: false, error: `Path outside the deck: ${requestedPath}` };
135
+ }
136
+ return { ok: true, abs, rel: rel.split(path.sep).join("/") };
137
+ }
138
+ // Recursively list deck files (relative POSIX paths, sorted), skipping the
139
+ // ignore dirs above and dotfiles like .DS_Store.
140
+ function listDeckFiles(deckDir) {
141
+ const out = [];
142
+ function walk(dir, prefix) {
143
+ let entries;
144
+ try {
145
+ entries = fs.readdirSync(dir, { withFileTypes: true });
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ for (const entry of entries) {
151
+ if (entry.name === ".DS_Store")
152
+ continue;
153
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
154
+ if (entry.isDirectory()) {
155
+ if (FILES_IGNORE_DIRS.has(entry.name))
156
+ continue;
157
+ walk(path.join(dir, entry.name), rel);
158
+ }
159
+ else if (entry.isFile()) {
160
+ out.push(rel);
161
+ }
162
+ }
163
+ }
164
+ walk(deckDir, "");
165
+ out.sort((a, b) => a.localeCompare(b));
166
+ return out;
167
+ }
168
+ function asStringArray(value) {
169
+ if (Array.isArray(value) && value.every((e) => typeof e === "string")) {
170
+ return value;
171
+ }
172
+ return undefined;
173
+ }
174
+ // Read the deck's `castle.json` `editor` block. Tolerates an absent /
175
+ // unparseable file (returns {}). Folds the legacy top-level `editorExtensions`
176
+ // into `editor.extensions` for decks saved before the `editor` block existed.
177
+ function readEditorConfig(deckDir) {
178
+ let data;
179
+ try {
180
+ data = JSON.parse(fs.readFileSync(path.join(deckDir, "castle.json"), "utf8"));
181
+ }
182
+ catch {
183
+ return {};
184
+ }
185
+ const editor = data.editor ?? {};
186
+ const config = {
187
+ initialPanels: Array.isArray(editor.initialPanels)
188
+ ? editor.initialPanels
189
+ : undefined,
190
+ hiddenPaths: asStringArray(editor.hiddenPaths),
191
+ visiblePaths: asStringArray(editor.visiblePaths),
192
+ extensions: asStringArray(editor.extensions) ?? asStringArray(data.editorExtensions),
193
+ };
194
+ return config;
195
+ }
196
+ // Extensions the kit owns a rich editor for. A kitless (bare) deck has no kit
197
+ // entry, so this is empty and the builtin editor renders everything. A deck may
198
+ // override the default list via `castle.json` `editor.extensions`.
199
+ function kitEditorExtensions(deckDir) {
200
+ const configured = readEditorConfig(deckDir).extensions;
201
+ if (configured)
202
+ return configured;
203
+ // `main.jsx` is the kit entry point; bare decks scaffold game.js + index.html
204
+ // with no main.jsx, so its absence means "no kit, builtin editor for all".
205
+ if (!fs.existsSync(path.join(deckDir, "main.jsx")))
206
+ return [];
207
+ // Prefer the kit's own getFileKind (authoritative per deck); fall back to the
208
+ // known rich content types if it couldn't be read/parsed.
209
+ const parsed = parseKitEditorExtensions(deckDir);
210
+ if (parsed && parsed.length > 0)
211
+ return parsed;
212
+ return FALLBACK_KIT_EDITOR_EXTS;
213
+ }
214
+ // Apply the deck's `editor.hiddenPaths` / `editor.visiblePaths` globs to a file
215
+ // list (deck-relative POSIX paths). Only hidden -> drop matches; only visible ->
216
+ // keep only matches; both -> keep visible, then subtract hidden. No config ->
217
+ // the list passes through unchanged.
218
+ function filterDeckFiles(files, config) {
219
+ let result = files;
220
+ if (config.visiblePaths && config.visiblePaths.length > 0) {
221
+ const isVisible = picomatch(config.visiblePaths);
222
+ result = result.filter((f) => isVisible(f));
223
+ }
224
+ if (config.hiddenPaths && config.hiddenPaths.length > 0) {
225
+ const isHidden = picomatch(config.hiddenPaths);
226
+ result = result.filter((f) => !isHidden(f));
227
+ }
228
+ return result;
229
+ }
230
+ function sendJson(res, status, body) {
231
+ res.writeHead(status, {
232
+ "content-type": "application/json; charset=utf-8",
233
+ "cache-control": "no-store",
234
+ });
235
+ res.end(JSON.stringify(body));
236
+ }
237
+ function readRequestBody(req) {
238
+ return new Promise((resolve, reject) => {
239
+ const chunks = [];
240
+ req.on("data", (c) => chunks.push(c));
241
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
242
+ req.on("error", reject);
243
+ });
244
+ }
245
+ function handleFilesWrite(deckDir, req, res) {
246
+ void (async () => {
247
+ let body;
248
+ try {
249
+ body = JSON.parse(await readRequestBody(req));
250
+ }
251
+ catch {
252
+ return sendJson(res, 400, { error: "Invalid JSON body." });
253
+ }
254
+ const resolved = resolveDeckPath(deckDir, body.path);
255
+ if (!resolved.ok)
256
+ return sendJson(res, 400, { error: resolved.error });
257
+ if (typeof body.contents !== "string") {
258
+ return sendJson(res, 400, { error: "File contents must be a string." });
259
+ }
260
+ try {
261
+ fs.mkdirSync(path.dirname(resolved.abs), { recursive: true });
262
+ fs.writeFileSync(resolved.abs, body.contents, "utf8");
263
+ sendJson(res, 200, { ok: true, path: resolved.rel });
264
+ }
265
+ catch (err) {
266
+ const message = err instanceof Error ? err.message : String(err);
267
+ sendJson(res, 500, { error: `Could not write ${resolved.rel}: ${message}` });
268
+ }
269
+ })();
270
+ }
271
+ // The builtin Files + code-editor backend: list / read / write deck files and
272
+ // report kit-owned editor extensions. Paths are deck-relative; resolveDeckPath
273
+ // rejects traversal and protected dirs.
274
+ function handleFilesApi(deckDir, req, res, reqPath) {
275
+ const action = reqPath.slice(FILES_API_PREFIX.length);
276
+ if (action === "info") {
277
+ const config = readEditorConfig(deckDir);
278
+ sendJson(res, 200, {
279
+ kitEditorExtensions: kitEditorExtensions(deckDir),
280
+ initialPanels: config.initialPanels ?? null,
281
+ hiddenPaths: config.hiddenPaths ?? [],
282
+ visiblePaths: config.visiblePaths ?? [],
283
+ });
284
+ return true;
285
+ }
286
+ if (action === "list") {
287
+ const files = filterDeckFiles(listDeckFiles(deckDir), readEditorConfig(deckDir));
288
+ sendJson(res, 200, { files });
289
+ return true;
290
+ }
291
+ if (action === "read") {
292
+ const url = new URL(req.url ?? "/", "http://localhost");
293
+ const resolved = resolveDeckPath(deckDir, url.searchParams.get("path"));
294
+ if (!resolved.ok)
295
+ return sendJson(res, 400, { error: resolved.error }), true;
296
+ try {
297
+ const contents = fs.readFileSync(resolved.abs, "utf8");
298
+ sendJson(res, 200, { path: resolved.rel, contents });
299
+ }
300
+ catch {
301
+ sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
302
+ }
303
+ return true;
304
+ }
305
+ if (action === "write") {
306
+ handleFilesWrite(deckDir, req, res);
307
+ return true;
308
+ }
309
+ return sendJson(res, 404, { error: `Unknown files action: ${action}` }), true;
310
+ }
65
311
  function defaultShell() {
66
312
  if (process.platform === "win32") {
67
313
  return { command: process.env.COMSPEC ?? "cmd.exe", args: [] };
@@ -252,13 +498,16 @@ export function createIdeServer(opts) {
252
498
  wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
253
499
  return true;
254
500
  }
255
- function handleHttpRequest(_req, res, reqPath) {
501
+ function handleHttpRequest(req, res, reqPath) {
256
502
  // `/` -> the shell's index.html; `/__castle/ide/<asset>` -> bundle assets.
257
503
  if (reqPath === "/")
258
504
  return serveShellFile(res, "index.html");
259
505
  if (reqPath.startsWith(IDE_ASSET_PREFIX)) {
260
506
  return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) || "index.html");
261
507
  }
508
+ if (reqPath.startsWith(FILES_API_PREFIX)) {
509
+ return handleFilesApi(deckDir, req, res, reqPath);
510
+ }
262
511
  return false;
263
512
  }
264
513
  function shutdown() {
package/dist/init.js CHANGED
@@ -35,16 +35,53 @@ const DEFAULT_KIT = "basic-2d";
35
35
  // Registry version of castle-web-sdk to inject when scaffolding from a
36
36
  // globally-installed castle-web (not from inside the workspace). Bumped
37
37
  // alongside cli/sdk version bumps.
38
- const PUBLISHED_SDK_VERSION = "0.4.7";
39
- // Never copied into a fresh deck: build/dependency junk, and castle.json (a
40
- // fresh deck has no deckId until its first save-deck).
38
+ const PUBLISHED_SDK_VERSION = "0.4.9";
39
+ // Never copied into a fresh deck: build/dependency junk. castle.json IS copied
40
+ // (the kit ships a config-only one with the editor layout / file filters), but
41
+ // `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
42
+ // no deckId until its first save-deck, which owns those fields.
41
43
  const KIT_COPY_EXCLUDE = new Set([
42
44
  "node_modules",
43
45
  ".castle",
44
46
  "dist",
45
47
  ".git",
46
- "castle.json",
47
48
  ]);
49
+ // Identity fields owned by save-deck, never shipped in a kit's config-only
50
+ // castle.json. Stripped from a copied castle.json (and absent from the bare
51
+ // default) so a fresh deck has no deckId until its first save.
52
+ const CASTLE_JSON_IDENTITY_FIELDS = [
53
+ "deckId",
54
+ "cardId",
55
+ "title",
56
+ "caption",
57
+ "visibility",
58
+ ];
59
+ // The bare (`--kit none`) deck's default editor config: just the Files panel and
60
+ // a Play panel. No file filtering -- a bare deck is hand-rolled, so the author
61
+ // knows what's there; show everything for now.
62
+ const BARE_CASTLE_JSON = {
63
+ editor: {
64
+ initialPanels: [{ type: "files" }, { type: "playtest" }],
65
+ hiddenPaths: [],
66
+ visiblePaths: [],
67
+ },
68
+ };
69
+ // Strip save-deck-owned identity fields from a copied kit castle.json, leaving
70
+ // only its config (the editor block). No-op if the kit shipped no castle.json.
71
+ function stripCastleJsonIdentity(projectDir) {
72
+ const castlePath = path.join(projectDir, "castle.json");
73
+ if (!fs.existsSync(castlePath))
74
+ return;
75
+ try {
76
+ const data = JSON.parse(fs.readFileSync(castlePath, "utf8"));
77
+ for (const field of CASTLE_JSON_IDENTITY_FIELDS)
78
+ delete data[field];
79
+ fs.writeFileSync(castlePath, JSON.stringify(data, null, 2) + "\n");
80
+ }
81
+ catch {
82
+ // kit shipped an unparseable castle.json -- leave it for the user to fix
83
+ }
84
+ }
48
85
  // Resolve how a scaffolded deck should reference the sdk + cli. Both the bare
49
86
  // and kit scaffold paths go through here so they stay in sync.
50
87
  // workspace mode (sdk/ sits next to cli/, i.e. running from a checkout):
@@ -136,6 +173,7 @@ function scaffoldBare(projectDir) {
136
173
  fs.mkdirSync(projectDir, { recursive: true });
137
174
  fs.writeFileSync(path.join(projectDir, "index.html"), INDEX_HTML);
138
175
  fs.writeFileSync(path.join(projectDir, "game.js"), GAME_JS);
176
+ fs.writeFileSync(path.join(projectDir, "castle.json"), JSON.stringify(BARE_CASTLE_JSON, null, 2) + "\n");
139
177
  fs.writeFileSync(path.join(projectDir, "CLAUDE.md"), makeClaudeMd());
140
178
  appendCommonInstructions(projectDir);
141
179
  ensureAgentsSymlink(projectDir);
@@ -170,6 +208,10 @@ function scaffoldFromKit(kit, projectDir) {
170
208
  verbatimSymlinks: true,
171
209
  filter: (src) => src === kitDir || !KIT_COPY_EXCLUDE.has(path.basename(src)),
172
210
  });
211
+ // The kit ships a config-only castle.json (editor layout / file filters); keep
212
+ // its config but drop any identity fields so this fresh deck has no deckId
213
+ // until its first save-deck.
214
+ stripCastleJsonIdentity(projectDir);
173
215
  // The kit's package.json carries the kit's name; rename it to the deck dir.
174
216
  // Kit-relative refs to `../../sdk` and `../../cli/dist` only resolve when the
175
217
  // deck lives at castle-experimental-web/decks/<name>/. Rewrite both to