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.
- package/dist/agent-prompts.d.ts +4 -0
- package/dist/agent-prompts.js +20 -8
- package/dist/agent.js +301 -28
- package/dist/castle-host/host.js +59 -5
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +4 -0
- package/dist/ide.d.ts +1 -0
- package/dist/ide.js +250 -1
- package/dist/init.js +46 -4
- package/dist/save-deck.js +8 -1
- package/dist/serve.js +27 -1
- package/dist/shell/assets/index-DNWEQd4R.js +141 -0
- package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +13 -5
- package/kits/basic-2d/castle.json +15 -0
- package/kits/basic-2d/drawings/pig.pxart +22 -55
- package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
- package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
- package/kits/basic-2d/editors/pixelGeometry.js +95 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
- package/kits/basic-2d/editors/pxArtTools.js +109 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
- package/kits/basic-2d/engine/ui.jsx +15 -0
- package/kits/basic-2d/engine/ui.module.css +44 -117
- package/kits/basic-2d/main.jsx +6 -10
- package/package.json +10 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
- package/kits/basic-2d/editors/App.jsx +0 -226
- package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
- package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
- package/kits/basic-2d/editors/codeTheme.js +0 -135
- package/kits/basic-2d/engine/playConsole.js +0 -66
- package/kits/basic-3d/.prettierrc +0 -8
- package/kits/basic-3d/CLAUDE.md +0 -162
- package/kits/basic-3d/behaviors/Camera.jsx +0 -56
- package/kits/basic-3d/behaviors/Collider.jsx +0 -78
- package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
- package/kits/basic-3d/behaviors/Model.jsx +0 -61
- package/kits/basic-3d/behaviors/Transform.jsx +0 -35
- package/kits/basic-3d/editors/App.jsx +0 -147
- package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
- package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
- package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
- package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
- package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
- package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
- package/kits/basic-3d/editors/editorHistory.js +0 -52
- package/kits/basic-3d/editors/viewportRig.js +0 -90
- package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
- package/kits/basic-3d/engine/SceneUI.jsx +0 -67
- package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
- package/kits/basic-3d/engine/autoInspector.jsx +0 -51
- package/kits/basic-3d/engine/files.js +0 -73
- package/kits/basic-3d/engine/scene.js +0 -502
- package/kits/basic-3d/engine/threeUtil.js +0 -260
- package/kits/basic-3d/engine/ui.jsx +0 -352
- package/kits/basic-3d/engine/ui.module.css +0 -944
- package/kits/basic-3d/eslint.config.js +0 -51
- package/kits/basic-3d/index.html +0 -11
- package/kits/basic-3d/main.jsx +0 -10
- package/kits/basic-3d/models/block.model +0 -14
- package/kits/basic-3d/package-lock.json +0 -2713
- package/kits/basic-3d/package.json +0 -41
- package/kits/basic-3d/pnpm-lock.yaml +0 -1769
- package/kits/basic-3d/scenes/main.scene +0 -76
- package/kits/basic-3d/vite.config.js +0 -1
package/dist/castle-host/host.js
CHANGED
|
@@ -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 = [
|
|
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(
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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.
|
|
39
|
-
// Never copied into a fresh deck: build/dependency junk
|
|
40
|
-
//
|
|
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
|
-
|
|
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) {
|