castle-web-cli 0.4.75 → 0.4.76

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 (68) hide show
  1. package/dist/agent-prompts.d.ts +4 -0
  2. package/dist/agent-prompts.js +20 -8
  3. package/dist/agent.js +301 -28
  4. package/dist/castle-host/host.js +59 -5
  5. package/dist/commonInstructions.d.ts +1 -1
  6. package/dist/commonInstructions.js +4 -0
  7. package/dist/ide.d.ts +1 -0
  8. package/dist/ide.js +250 -1
  9. package/dist/init.js +46 -4
  10. package/dist/save-deck.js +8 -1
  11. package/dist/serve.js +27 -1
  12. package/dist/shell/assets/index-DNWEQd4R.js +141 -0
  13. package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/basic-2d/CLAUDE.md +13 -5
  16. package/kits/basic-2d/castle.json +15 -0
  17. package/kits/basic-2d/drawings/pig.pxart +22 -55
  18. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  19. package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
  20. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  21. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  22. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  23. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  24. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
  25. package/kits/basic-2d/engine/ui.jsx +15 -0
  26. package/kits/basic-2d/engine/ui.module.css +44 -117
  27. package/kits/basic-2d/main.jsx +6 -10
  28. package/package.json +10 -1
  29. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  30. package/kits/basic-2d/editors/App.jsx +0 -226
  31. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  32. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  33. package/kits/basic-2d/editors/codeTheme.js +0 -135
  34. package/kits/basic-2d/engine/playConsole.js +0 -66
  35. package/kits/basic-3d/.prettierrc +0 -8
  36. package/kits/basic-3d/CLAUDE.md +0 -162
  37. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  38. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  39. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  40. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  41. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  42. package/kits/basic-3d/editors/App.jsx +0 -147
  43. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  44. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  45. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  46. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  47. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
  48. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  49. package/kits/basic-3d/editors/editorHistory.js +0 -52
  50. package/kits/basic-3d/editors/viewportRig.js +0 -90
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
  52. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  53. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  54. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  55. package/kits/basic-3d/engine/files.js +0 -73
  56. package/kits/basic-3d/engine/scene.js +0 -502
  57. package/kits/basic-3d/engine/threeUtil.js +0 -260
  58. package/kits/basic-3d/engine/ui.jsx +0 -352
  59. package/kits/basic-3d/engine/ui.module.css +0 -944
  60. package/kits/basic-3d/eslint.config.js +0 -51
  61. package/kits/basic-3d/index.html +0 -11
  62. package/kits/basic-3d/main.jsx +0 -10
  63. package/kits/basic-3d/models/block.model +0 -14
  64. package/kits/basic-3d/package-lock.json +0 -2713
  65. package/kits/basic-3d/package.json +0 -41
  66. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  67. package/kits/basic-3d/scenes/main.scene +0 -76
  68. 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 = "## 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";
@@ -5,4 +5,8 @@
5
5
  export const COMMON_INSTRUCTIONS = `## Touch controls (every deck)
6
6
 
7
7
  - **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.
8
+
9
+ ## Fit the card (every deck)
10
+
11
+ - **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.
8
12
  `;
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.8";
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
package/dist/save-deck.js CHANGED
@@ -144,7 +144,14 @@ export async function saveDeck(dir, opts = {}) {
144
144
  uploadId: uploadConfig.uploadId,
145
145
  makeInitialCard: true,
146
146
  });
147
- const newCastleJson = { deckId: result.deckId, cardId: result.cardId, title };
147
+ // Preserve any deck-owned config already in castle.json (notably the
148
+ // `editor` block); save-deck only owns the identity fields below.
149
+ const newCastleJson = {
150
+ ...(castleJson ?? {}),
151
+ deckId: result.deckId,
152
+ cardId: result.cardId,
153
+ title,
154
+ };
148
155
  fs.writeFileSync(path.join(projectDir, 'castle.json'), JSON.stringify(newCastleJson, null, 2) + '\n', 'utf-8');
149
156
  if (isNew) {
150
157
  console.log(`Created deck "${title}" (${result.deckId}). Saved castle.json.`);
package/dist/serve.js CHANGED
@@ -25,13 +25,39 @@ async function findFreePorts(startPort) {
25
25
  }
26
26
  throw new Error(`No free port pair found starting from ${startPort}`);
27
27
  }
28
+ // Injected into the deck iframe (only when it carries `?logs=1`, i.e. the
29
+ // editor's builtin Play panel) to forward the deck's console output to the
30
+ // shell via postMessage, where the builtin Play panel renders a logs drawer.
31
+ // Kit-agnostic: works on a bare `--kit none` deck with no editor framework.
32
+ // Never injected for the published feed (that path doesn't go through dev serve).
33
+ const CONSOLE_CAPTURE = `<script>(function(){
34
+ try { if (new URLSearchParams(location.search).get('logs') !== '1') return; } catch (e) { return; }
35
+ if (window.parent === window) return;
36
+ var fmt = function (a) {
37
+ if (typeof a === 'string') return a;
38
+ if (a instanceof Error) return a.stack || a.message;
39
+ try { return JSON.stringify(a); } catch (e) { return String(a); }
40
+ };
41
+ ['log', 'warn', 'error'].forEach(function (level) {
42
+ var orig = typeof console[level] === 'function' ? console[level].bind(console) : null;
43
+ console[level] = function () {
44
+ if (orig) orig.apply(null, arguments);
45
+ try {
46
+ window.parent.postMessage(
47
+ { type: 'castle-console', level: level, text: Array.prototype.map.call(arguments, fmt).join(' ') },
48
+ '*'
49
+ );
50
+ } catch (e) {}
51
+ };
52
+ });
53
+ })();</script>`;
28
54
  function castlePlugin(wsPort, ideServer, agentServer) {
29
55
  return {
30
56
  name: 'castle-dev',
31
57
  transformIndexHtml: {
32
58
  order: 'pre',
33
59
  handler(html) {
34
- return html.replace(/<head(\s[^>]*)?>/i, (match) => `${match}\n <script>window.CastleEmbed={edit:true,host:'dev'};</script>`);
60
+ return html.replace(/<head(\s[^>]*)?>/i, (match) => `${match}\n <script>window.CastleEmbed={edit:true,host:'dev'};</script>\n ${CONSOLE_CAPTURE}`);
35
61
  },
36
62
  },
37
63
  configureServer(server) {