castle-web-cli 0.4.1 → 0.4.3

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 (158) hide show
  1. package/dist/api.d.ts +53 -5
  2. package/dist/api.js +42 -15
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +25 -11
  5. package/dist/get-deck.d.ts +3 -0
  6. package/dist/get-deck.js +64 -0
  7. package/dist/ide-client.d.ts +1 -0
  8. package/dist/ide-client.js +537 -0
  9. package/dist/ide.d.ts +16 -0
  10. package/dist/ide.js +546 -0
  11. package/dist/index.js +36 -41
  12. package/dist/init.d.ts +3 -1
  13. package/dist/init.js +173 -24
  14. package/dist/localPaths.d.ts +6 -0
  15. package/dist/localPaths.js +33 -0
  16. package/dist/login.js +1 -1
  17. package/dist/preview.d.ts +3 -0
  18. package/dist/preview.js +53 -34
  19. package/dist/save-deck.d.ts +2 -0
  20. package/dist/{push.js → save-deck.js} +66 -5
  21. package/dist/serve.d.ts +2 -0
  22. package/dist/serve.js +290 -27
  23. package/kits/basic-2d/.prettierrc +8 -0
  24. package/kits/basic-2d/CLAUDE.md +131 -0
  25. package/kits/basic-2d/behaviors/Camera.jsx +43 -0
  26. package/kits/basic-2d/behaviors/Collider.jsx +71 -0
  27. package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
  28. package/kits/basic-2d/behaviors/Layout.jsx +16 -0
  29. package/kits/basic-2d/drawings/floor.drawing +70 -0
  30. package/kits/basic-2d/editors/App.jsx +152 -0
  31. package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
  32. package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
  33. package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
  34. package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
  35. package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
  36. package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
  37. package/kits/basic-2d/editors/editorHistory.js +52 -0
  38. package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
  39. package/kits/basic-2d/engine/SceneUI.jsx +67 -0
  40. package/kits/basic-2d/engine/TouchControls.jsx +136 -0
  41. package/kits/basic-2d/engine/autoInspector.jsx +51 -0
  42. package/kits/basic-2d/engine/files.js +62 -0
  43. package/kits/basic-2d/engine/scene.js +420 -0
  44. package/kits/basic-2d/engine/ui.jsx +344 -0
  45. package/kits/basic-2d/engine/ui.module.css +928 -0
  46. package/kits/basic-2d/eslint.config.js +50 -0
  47. package/kits/basic-2d/index.html +11 -0
  48. package/kits/basic-2d/main.jsx +10 -0
  49. package/kits/basic-2d/package-lock.json +2706 -0
  50. package/kits/basic-2d/package.json +41 -0
  51. package/kits/basic-2d/scenes/main.scene +108 -0
  52. package/kits/basic-2d/vite.config.js +1 -0
  53. package/kits/basic-2d-frozen/.prettierrc +8 -0
  54. package/kits/basic-2d-frozen/CLAUDE.md +131 -0
  55. package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
  56. package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
  57. package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
  58. package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
  59. package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
  60. package/kits/basic-2d-frozen/editors/App.jsx +152 -0
  61. package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
  62. package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
  63. package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
  64. package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
  65. package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
  66. package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
  67. package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
  68. package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
  69. package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
  70. package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
  71. package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
  72. package/kits/basic-2d-frozen/engine/files.js +62 -0
  73. package/kits/basic-2d-frozen/engine/scene.js +420 -0
  74. package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
  75. package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
  76. package/kits/basic-2d-frozen/eslint.config.js +50 -0
  77. package/kits/basic-2d-frozen/index.html +11 -0
  78. package/kits/basic-2d-frozen/main.jsx +10 -0
  79. package/kits/basic-2d-frozen/package-lock.json +2706 -0
  80. package/kits/basic-2d-frozen/package.json +41 -0
  81. package/kits/basic-2d-frozen/scenes/main.scene +108 -0
  82. package/kits/basic-2d-frozen/vite.config.js +1 -0
  83. package/kits/rpg-2d/.prettierrc +8 -0
  84. package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
  85. package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
  86. package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
  87. package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
  88. package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
  89. package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
  90. package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
  91. package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
  92. package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
  93. package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
  94. package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
  95. package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
  96. package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
  97. package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
  98. package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
  99. package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
  100. package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
  101. package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
  102. package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
  103. package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
  104. package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
  105. package/kits/rpg-2d/drawings/floor.drawing +70 -0
  106. package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
  107. package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
  108. package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
  109. package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
  110. package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
  111. package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
  112. package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
  113. package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
  114. package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
  115. package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
  116. package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
  117. package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
  118. package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
  119. package/kits/rpg-2d/editors/App.tsx +163 -0
  120. package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
  121. package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
  122. package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
  123. package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
  124. package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
  125. package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
  126. package/kits/rpg-2d/editors/editorHistory.ts +75 -0
  127. package/kits/rpg-2d/editors/editorProps.ts +10 -0
  128. package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
  129. package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
  130. package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
  131. package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
  132. package/kits/rpg-2d/engine/drawing.ts +81 -0
  133. package/kits/rpg-2d/engine/files.ts +215 -0
  134. package/kits/rpg-2d/engine/scene.ts +484 -0
  135. package/kits/rpg-2d/engine/ui.module.css +928 -0
  136. package/kits/rpg-2d/engine/ui.tsx +483 -0
  137. package/kits/rpg-2d/eslint.config.js +46 -0
  138. package/kits/rpg-2d/index.html +11 -0
  139. package/kits/rpg-2d/main.tsx +14 -0
  140. package/kits/rpg-2d/package-lock.json +3149 -0
  141. package/kits/rpg-2d/package.json +46 -0
  142. package/kits/rpg-2d/scenes/main.scene +203 -0
  143. package/kits/rpg-2d/tsconfig.json +17 -0
  144. package/kits/rpg-2d/vite-env.d.ts +7 -0
  145. package/kits/rpg-2d/vite.config.js +1 -0
  146. package/package.json +27 -5
  147. package/AGENTS.md +0 -25
  148. package/dist/push.d.ts +0 -1
  149. package/src/api.ts +0 -160
  150. package/src/bundle.ts +0 -28
  151. package/src/config.ts +0 -36
  152. package/src/index.ts +0 -143
  153. package/src/init.ts +0 -71
  154. package/src/login.ts +0 -24
  155. package/src/preview.ts +0 -94
  156. package/src/push.ts +0 -118
  157. package/src/serve.ts +0 -134
  158. package/tsconfig.json +0 -13
package/dist/ide.js ADDED
@@ -0,0 +1,546 @@
1
+ // IDE panel for `castle-web serve --ide`: a desktop split view with the deck
2
+ // in an iframe on the left and an xterm.js terminal on the right. The terminal
3
+ // attaches to a PTY (a login shell) running in the deck directory, so an agent
4
+ // CLI can be driven right next to the live deck.
5
+ //
6
+ // Backend pattern ported from castle-cli's `ide` branch: @lydell/node-pty for
7
+ // the PTY, an @xterm/headless screen + @xterm/addon-serialize so a reconnecting
8
+ // client gets a full replay of the current screen + scrollback.
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { createRequire } from 'module';
12
+ import { fileURLToPath } from 'url';
13
+ import { spawn as spawnPty } from '@lydell/node-pty';
14
+ import headlessPkg from '@xterm/headless';
15
+ import { SerializeAddon } from '@xterm/addon-serialize';
16
+ import { WebSocketServer } from 'ws';
17
+ const HeadlessTerminal = headlessPkg.Terminal;
18
+ const require_ = createRequire(import.meta.url);
19
+ const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
20
+ const PTY_TERM = 'xterm-256color';
21
+ const INITIAL_COLS = 80;
22
+ const INITIAL_ROWS = 24;
23
+ const SCROLLBACK = 4000;
24
+ // The split-view shell is served at the deck root `/` (the deck itself moves
25
+ // into the iframe at `/index.html`); `/__castle/ide/<asset>` are the shell's
26
+ // static assets; `/__castle/pty` is the PTY WebSocket (handled via a direct
27
+ // upgrade handler on Vite's HTTP server).
28
+ export const IDE_ASSET_PREFIX = '/__castle/ide/';
29
+ export const PTY_WS_PATH = '/__castle/pty';
30
+ // Injected into every served deck's <head>. When the deck runs embedded in the
31
+ // IDE shell iframe, Ctrl+T moves keyboard focus out to the terminal -- but
32
+ // while the iframe holds focus the parent sees no keydowns, so the deck page
33
+ // has to forward Ctrl+T to the parent itself. No-op for a standalone deck.
34
+ export const DECK_FOCUS_SCRIPT = `<script>(function(){if(window.parent===window)return;` +
35
+ `document.addEventListener('keydown',function(e){` +
36
+ `if(e.ctrlKey&&!e.metaKey&&!e.altKey&&!e.shiftKey&&(e.key==='t'||e.key==='T')){` +
37
+ `e.preventDefault();try{parent.postMessage({type:'castle-ide-toggle-focus'},'*');}catch(_){}}` +
38
+ `},true);})();</script>`;
39
+ function defaultShell() {
40
+ if (process.platform === 'win32') {
41
+ return { command: process.env.COMSPEC ?? 'cmd.exe', args: [] };
42
+ }
43
+ return { command: process.env.SHELL ?? '/bin/zsh', args: ['-l'] };
44
+ }
45
+ function ptyEnv() {
46
+ const env = {
47
+ ...process.env,
48
+ TERM: PTY_TERM,
49
+ COLORTERM: 'truecolor',
50
+ CLICOLOR: '1',
51
+ CLICOLOR_FORCE: '1',
52
+ FORCE_COLOR: process.env.FORCE_COLOR === '0' ? '3' : process.env.FORCE_COLOR ?? '3',
53
+ TERM_PROGRAM: 'castle-web-ide',
54
+ };
55
+ delete env.NO_COLOR;
56
+ delete env.NODE_DISABLE_COLORS;
57
+ return env;
58
+ }
59
+ function clampSize(value, fallback) {
60
+ const n = Number(value);
61
+ if (!Number.isFinite(n) || n <= 0)
62
+ return fallback;
63
+ return Math.max(2, Math.min(1000, Math.floor(n)));
64
+ }
65
+ function rawDataToString(data) {
66
+ if (Array.isArray(data))
67
+ return Buffer.concat(data).toString('utf8');
68
+ if (Buffer.isBuffer(data))
69
+ return data.toString('utf8');
70
+ return Buffer.from(new Uint8Array(data)).toString('utf8');
71
+ }
72
+ function send(socket, body) {
73
+ if (socket.readyState === socket.OPEN)
74
+ socket.send(JSON.stringify(body));
75
+ }
76
+ // `screen.write` is async; chain writes/resizes through a single queue so the
77
+ // serialized replay never races a partial write. Mirrors castle-cli + threads.
78
+ function queueScreenOp(session, op) {
79
+ session.renderQueue = session.renderQueue
80
+ .catch(() => undefined)
81
+ .then(op)
82
+ .catch((err) => console.error('[ide] screen render failed', err));
83
+ }
84
+ async function waitForStableScreen(session) {
85
+ while (true) {
86
+ const queue = session.renderQueue;
87
+ await queue.catch(() => undefined);
88
+ if (session.renderQueue === queue)
89
+ return;
90
+ }
91
+ }
92
+ function broadcast(session, body) {
93
+ for (const socket of session.clients)
94
+ send(socket, body);
95
+ }
96
+ // Returns a handler for the IDE page + assets + the PTY WebSocket upgrade. The
97
+ // PTY runs a login shell in `deckDir`; it spawns lazily on first connection.
98
+ export function createIdeServer(opts) {
99
+ const { deckDir, deckLabel } = opts;
100
+ let session = null;
101
+ function spawnSession() {
102
+ const { command, args } = defaultShell();
103
+ const screen = new HeadlessTerminal({
104
+ allowProposedApi: true,
105
+ cols: INITIAL_COLS,
106
+ rows: INITIAL_ROWS,
107
+ convertEol: false,
108
+ scrollback: SCROLLBACK,
109
+ });
110
+ const serializeAddon = new SerializeAddon();
111
+ screen.loadAddon(serializeAddon);
112
+ const pty = spawnPty(command, args, {
113
+ name: PTY_TERM,
114
+ cols: INITIAL_COLS,
115
+ rows: INITIAL_ROWS,
116
+ cwd: deckDir,
117
+ env: ptyEnv(),
118
+ });
119
+ const s = {
120
+ pty,
121
+ screen,
122
+ serializeAddon,
123
+ renderQueue: Promise.resolve(),
124
+ clients: new Set(),
125
+ cols: INITIAL_COLS,
126
+ rows: INITIAL_ROWS,
127
+ exited: null,
128
+ };
129
+ pty.onData((data) => {
130
+ queueScreenOp(s, () => new Promise((done) => s.screen.write(data, done)));
131
+ broadcast(s, { type: 'output', data });
132
+ });
133
+ pty.onExit(({ exitCode, signal }) => {
134
+ s.exited = { exitCode, signal };
135
+ broadcast(s, { type: 'exit', exitCode, signal });
136
+ // Clients get the `exit` message above (which disables their reconnect),
137
+ // then we close the sockets so they don't sit half-open.
138
+ for (const socket of s.clients) {
139
+ try {
140
+ socket.close(1000, 'shell exited');
141
+ }
142
+ catch {
143
+ /* ignore */
144
+ }
145
+ }
146
+ });
147
+ return s;
148
+ }
149
+ function ensureSession() {
150
+ if (!session || session.exited)
151
+ session = spawnSession();
152
+ return session;
153
+ }
154
+ function resizeSession(s, cols, rows) {
155
+ const c = clampSize(cols, s.cols);
156
+ const r = clampSize(rows, s.rows);
157
+ if (c === s.cols && r === s.rows)
158
+ return;
159
+ s.cols = c;
160
+ s.rows = r;
161
+ try {
162
+ s.pty.resize(c, r);
163
+ }
164
+ catch {
165
+ /* pty may have exited */
166
+ }
167
+ queueScreenOp(s, () => {
168
+ s.screen.resize(c, r);
169
+ });
170
+ }
171
+ function attachClient(socket) {
172
+ const s = ensureSession();
173
+ void (async () => {
174
+ try {
175
+ await waitForStableScreen(s);
176
+ if (socket.readyState !== socket.OPEN)
177
+ return;
178
+ send(socket, {
179
+ type: 'replay',
180
+ data: s.serializeAddon.serialize({ scrollback: SCROLLBACK }),
181
+ });
182
+ if (s.exited) {
183
+ send(socket, { type: 'exit', exitCode: s.exited.exitCode, signal: s.exited.signal });
184
+ }
185
+ }
186
+ catch (err) {
187
+ send(socket, {
188
+ type: 'error',
189
+ error: err instanceof Error ? err.message : 'could not restore screen',
190
+ });
191
+ }
192
+ finally {
193
+ if (socket.readyState === socket.OPEN)
194
+ s.clients.add(socket);
195
+ }
196
+ })();
197
+ socket.on('message', (raw) => {
198
+ let msg;
199
+ try {
200
+ msg = JSON.parse(rawDataToString(raw));
201
+ }
202
+ catch {
203
+ return;
204
+ }
205
+ if (msg.type === 'input' && typeof msg.data === 'string') {
206
+ if (!s.exited)
207
+ s.pty.write(msg.data);
208
+ }
209
+ else if (msg.type === 'resize') {
210
+ resizeSession(s, Number(msg.cols), Number(msg.rows));
211
+ }
212
+ });
213
+ socket.on('close', () => {
214
+ s.clients.delete(socket);
215
+ });
216
+ }
217
+ const wss = new WebSocketServer({ noServer: true });
218
+ function handleUpgrade(req, socket, head) {
219
+ const url = new URL(req.url ?? '/', 'http://localhost');
220
+ if (url.pathname !== PTY_WS_PATH)
221
+ return false;
222
+ wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
223
+ return true;
224
+ }
225
+ function handleHttpRequest(_req, res, reqPath) {
226
+ if (reqPath === '/') {
227
+ const html = renderIdePage(deckLabel);
228
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
229
+ res.end(html);
230
+ return true;
231
+ }
232
+ if (reqPath.startsWith(IDE_ASSET_PREFIX)) {
233
+ const asset = reqPath.slice(IDE_ASSET_PREFIX.length);
234
+ const resolved = resolveIdeAsset(asset);
235
+ if (!resolved) {
236
+ res.writeHead(404).end();
237
+ return true;
238
+ }
239
+ res.writeHead(200, {
240
+ 'content-type': resolved.contentType,
241
+ 'cache-control': 'no-store',
242
+ });
243
+ fs.createReadStream(resolved.filePath).pipe(res);
244
+ return true;
245
+ }
246
+ return false;
247
+ }
248
+ function shutdown() {
249
+ if (session) {
250
+ try {
251
+ session.pty.kill();
252
+ }
253
+ catch {
254
+ /* already gone */
255
+ }
256
+ for (const socket of session.clients) {
257
+ try {
258
+ socket.close(1001, 'serve shutting down');
259
+ }
260
+ catch {
261
+ /* ignore */
262
+ }
263
+ }
264
+ }
265
+ wss.close();
266
+ }
267
+ return { handleHttpRequest, handleUpgrade, shutdown };
268
+ }
269
+ // Map an IDE asset name to a file on disk. xterm + addon-fit come straight from
270
+ // node_modules (UMD builds); the terminal client is compiled alongside this
271
+ // file by tsc into the same dist directory.
272
+ function resolveIdeAsset(asset) {
273
+ const assets = {
274
+ 'xterm.js': { spec: '@xterm/xterm/lib/xterm.js', contentType: 'text/javascript; charset=utf-8' },
275
+ 'xterm.css': { spec: '@xterm/xterm/css/xterm.css', contentType: 'text/css; charset=utf-8' },
276
+ 'addon-fit.js': {
277
+ spec: '@xterm/addon-fit/lib/addon-fit.js',
278
+ contentType: 'text/javascript; charset=utf-8',
279
+ },
280
+ };
281
+ const fromModule = assets[asset];
282
+ if (fromModule) {
283
+ try {
284
+ return { filePath: require_.resolve(fromModule.spec), contentType: fromModule.contentType };
285
+ }
286
+ catch {
287
+ return null;
288
+ }
289
+ }
290
+ if (asset === 'ide-client.js') {
291
+ const filePath = path.join(DIST_DIR, 'ide-client.js');
292
+ return fs.existsSync(filePath)
293
+ ? { filePath, contentType: 'text/javascript; charset=utf-8' }
294
+ : null;
295
+ }
296
+ return null;
297
+ }
298
+ // Lucide glyphs; currentColor so the button :hover styles drive them.
299
+ const TERMINAL_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg>`;
300
+ const COG_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></svg>`;
301
+ const IDE_STYLES = `
302
+ :root { color-scheme: light; }
303
+ * { box-sizing: border-box; }
304
+ html, body { height: 100%; margin: 0; }
305
+ body {
306
+ /* Header-strip height. The deck's editor header is 48px, but drops to the
307
+ 52px responsive variant once the deck iframe is below 860px wide -- which
308
+ it is whenever the terminal is open at a viewport under 1360px. Track
309
+ that so the terminal header strip and its buttons line up with the deck's
310
+ editor header beside it. */
311
+ --term-header-h: 48px;
312
+ background: #ffffff;
313
+ color: #1f2328;
314
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
315
+ display: flex;
316
+ overflow: hidden;
317
+ }
318
+ @media (max-width: 1360px) {
319
+ body.term-open { --term-header-h: 52px; }
320
+ }
321
+ #deck {
322
+ flex: 1 1 0;
323
+ min-width: 0;
324
+ background: #f5f5f5;
325
+ border-right: 1px solid #e1e4e8;
326
+ }
327
+ #deck iframe { width: 100%; height: 100%; border: 0; display: block; }
328
+ /* The terminal panel is a flex column: a fixed-height header strip on top,
329
+ and the xterm host taking exactly the leftover height below it. */
330
+ #term {
331
+ flex: 1 1 500px;
332
+ max-width: 500px;
333
+ min-width: 320px;
334
+ background: #ffffff;
335
+ display: flex;
336
+ flex-direction: column;
337
+ }
338
+ body.term-dark #term { background: #1a1b26; }
339
+ /* xterm host: takes whatever height is left after the header. min-height:0
340
+ lets it shrink within the flex column instead of overflowing. */
341
+ #term-host {
342
+ flex: 1 1 0;
343
+ min-height: 0;
344
+ width: 100%;
345
+ padding: 6px;
346
+ overscroll-behavior: contain;
347
+ }
348
+
349
+ /* Header strip for the terminal panel -- mirrors the deck's editor header
350
+ (same height, 1px bottom border, white background, 16px title). A real
351
+ flex item at the top of the panel; only shown while the terminal is open
352
+ (when closed the panel is hidden and the deck is full width). */
353
+ #term-header {
354
+ flex: 0 0 var(--term-header-h);
355
+ height: var(--term-header-h);
356
+ display: none;
357
+ align-items: center;
358
+ gap: 8px;
359
+ padding: 10px 12px;
360
+ background: #ffffff;
361
+ border-bottom: 1px solid #cccccc;
362
+ }
363
+ body.term-open #term-header { display: flex; }
364
+ body.term-dark #term-header { background: #1a1b26; border-bottom-color: #2a2c3d; }
365
+ #term-header .term-header-title {
366
+ font-size: 16px;
367
+ line-height: 1;
368
+ letter-spacing: 0.01em;
369
+ color: #222222;
370
+ }
371
+ body.term-dark #term-header .term-header-title { color: #c0caf5; }
372
+
373
+ /* Terminal hidden: the panel stays mounted at its full 500px size (so xterm
374
+ keeps its measured layout) -- just lifted out of flow and made invisible,
375
+ and the deck takes the whole width. */
376
+ body:not(.term-open) #deck { border-right: 0; }
377
+ body:not(.term-open) #term {
378
+ position: absolute;
379
+ top: 0;
380
+ right: 0;
381
+ width: 500px;
382
+ height: 100%;
383
+ visibility: hidden;
384
+ }
385
+
386
+ /* Toggle button: always pinned top-right above both the deck and the
387
+ terminal. Styling is copied from the editor-demo header icon buttons
388
+ (34px square, 1px solid #000, 4px radius, drop shadow) so it reads as a
389
+ header button when it overlays a header bar. Vertically centered in the
390
+ header strip -- (header-h - 34) / 2 -- which tracks the deck's editor
391
+ header height, so it sits centered whether the terminal is open or not. */
392
+ #term-toggle {
393
+ position: fixed;
394
+ top: calc((var(--term-header-h) - 34px) / 2);
395
+ right: 7px;
396
+ z-index: 2147483000;
397
+ width: 34px;
398
+ height: 34px;
399
+ padding: 0;
400
+ display: flex;
401
+ align-items: center;
402
+ justify-content: center;
403
+ background: #ffffff;
404
+ color: #222222;
405
+ border: 1px solid #000000;
406
+ border-radius: 4px;
407
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
408
+ cursor: pointer;
409
+ }
410
+ #term-toggle:hover { background: #eeeeee; }
411
+ #term-toggle svg { width: 16px; height: 16px; display: block; }
412
+
413
+ /* Settings cog: same button style, sits just left of the toggle. Only
414
+ present while the terminal is open. */
415
+ #term-settings {
416
+ position: fixed;
417
+ top: calc((var(--term-header-h) - 34px) / 2);
418
+ right: 47px;
419
+ z-index: 2147483000;
420
+ width: 34px;
421
+ height: 34px;
422
+ padding: 0;
423
+ display: none;
424
+ align-items: center;
425
+ justify-content: center;
426
+ background: #ffffff;
427
+ color: #222222;
428
+ border: 1px solid #000000;
429
+ border-radius: 4px;
430
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
431
+ cursor: pointer;
432
+ }
433
+ body.term-open #term-settings { display: flex; }
434
+ #term-settings:hover { background: #eeeeee; }
435
+ #term-settings svg { width: 16px; height: 16px; display: block; }
436
+
437
+ /* Settings popover: flat, opaque, anchored under the buttons. */
438
+ #term-settings-panel {
439
+ position: fixed;
440
+ top: var(--term-header-h);
441
+ right: 7px;
442
+ z-index: 2147483000;
443
+ display: none;
444
+ width: 190px;
445
+ padding: 8px 10px;
446
+ background: #ffffff;
447
+ color: #222222;
448
+ border: 1px solid #000000;
449
+ border-radius: 4px;
450
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
451
+ font-size: 12px;
452
+ }
453
+ body.term-settings-open #term-settings-panel { display: block; }
454
+ #term-settings-panel .row {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: space-between;
458
+ gap: 10px;
459
+ }
460
+ #term-settings-panel .seg {
461
+ display: flex;
462
+ border: 1px solid #000000;
463
+ border-radius: 4px;
464
+ overflow: hidden;
465
+ }
466
+ #term-settings-panel .seg button {
467
+ padding: 3px 9px;
468
+ font: inherit;
469
+ background: #ffffff;
470
+ color: #222222;
471
+ border: 0;
472
+ cursor: pointer;
473
+ }
474
+ #term-settings-panel .seg button + button { border-left: 1px solid #000000; }
475
+ #term-settings-panel .seg button.active { background: #222222; color: #ffffff; }
476
+
477
+ /* Let the xterm light theme show through, and hide every scrollbar xterm
478
+ can draw -- the native viewport one AND the renderer overlay one. */
479
+ .xterm, .xterm-viewport, .xterm-screen, .xterm-helpers {
480
+ background-color: transparent !important;
481
+ }
482
+ .xterm { overscroll-behavior: contain; }
483
+ .xterm-viewport {
484
+ overscroll-behavior: contain;
485
+ scrollbar-width: none;
486
+ -ms-overflow-style: none;
487
+ }
488
+ .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; }
489
+ .xterm .xterm-scrollable-element > .scrollbar,
490
+ .xterm .xterm-scrollable-element > .scrollbar.visible,
491
+ .xterm .xterm-scrollable-element > .visible.scrollbar,
492
+ .xterm .xterm-scrollable-element > .invisible.scrollbar,
493
+ .xterm .xterm-scrollable-element > .xterm-scrollbar {
494
+ display: none !important;
495
+ width: 0 !important;
496
+ height: 0 !important;
497
+ min-width: 0 !important;
498
+ min-height: 0 !important;
499
+ opacity: 0 !important;
500
+ pointer-events: none !important;
501
+ }
502
+ .xterm .xterm-scrollable-element > .scrollbar > .slider,
503
+ .xterm .xterm-scrollable-element > .scrollbar > .scra,
504
+ .xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {
505
+ display: none !important;
506
+ width: 0 !important;
507
+ height: 0 !important;
508
+ }
509
+ .xterm .xterm-scrollable-element > .shadow { display: none !important; }
510
+ `;
511
+ const IDE_BODY = `
512
+ <button id="term-settings" type="button" tabindex="-1" title="terminal settings" aria-label="terminal settings">${COG_ICON_SVG}</button>
513
+ <button id="term-toggle" type="button" tabindex="-1" title="toggle terminal" aria-label="toggle terminal">${TERMINAL_ICON_SVG}</button>
514
+ <div id="term-settings-panel">
515
+ <div class="row">
516
+ <span>theme</span>
517
+ <div class="seg" id="term-theme-seg">
518
+ <button type="button" tabindex="-1" data-theme="light">light</button>
519
+ <button type="button" tabindex="-1" data-theme="dark">dark</button>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ <div id="deck"><iframe id="deck-frame" src="/index.html" title="deck" allow="autoplay; clipboard-read; clipboard-write; fullscreen; gamepad"></iframe></div>
524
+ <div id="term"><div id="term-header"><span class="term-header-title">Terminal</span></div><div id="term-host"></div></div>
525
+ <script src="${IDE_ASSET_PREFIX}xterm.js"></script>
526
+ <script src="${IDE_ASSET_PREFIX}addon-fit.js"></script>
527
+ <script type="module" src="${IDE_ASSET_PREFIX}ide-client.js"></script>
528
+ `;
529
+ function renderIdePage(deckLabel) {
530
+ const title = `${deckLabel} -- Castle Editor`;
531
+ return `<!doctype html>
532
+ <html lang="en">
533
+ <head>
534
+ <meta charset="utf-8" />
535
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
536
+ <title>${escapeHtml(title)}</title>
537
+ <link rel="stylesheet" href="${IDE_ASSET_PREFIX}xterm.css" />
538
+ <style>${IDE_STYLES}</style>
539
+ </head>
540
+ <body>${IDE_BODY}</body>
541
+ </html>
542
+ `;
543
+ }
544
+ function escapeHtml(value) {
545
+ return value.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
546
+ }
package/dist/index.js CHANGED
@@ -3,12 +3,13 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { login } from './login.js';
5
5
  import { serve } from './serve.js';
6
- import { push } from './push.js';
6
+ import { saveDeck } from './save-deck.js';
7
+ import { getDeck } from './get-deck.js';
7
8
  import { init } from './init.js';
8
- import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
9
+ import { connectWS, savePreviewImage, savePreviewIfNeeded, takeScreenshot } from './preview.js';
9
10
  const args = process.argv.slice(2);
10
11
  const command = args[0];
11
- const FLAGS_WITH_VALUES = new Set(['--port', '--out']);
12
+ const FLAGS_WITH_VALUES = new Set(['--port', '--out', '--host', '--kit', '--deck-id']);
12
13
  function findPositionalDir() {
13
14
  for (let i = 1; i < args.length; i++) {
14
15
  if (args[i].startsWith('--')) {
@@ -44,40 +45,54 @@ function getWsPort(dir) {
44
45
  }
45
46
  function usage() {
46
47
  console.log(`Usage:
47
- castle-web init <dir>
48
- castle-web serve [dir] [--port PORT] [--open]
48
+ castle-web init <dir> [--kit NAME] (kits: basic-2d (default), rpg-2d, none)
49
+ castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
49
50
  castle-web restart [--port PORT]
50
51
  castle-web screenshot [--out FILE] [--port PORT]
51
52
  castle-web save-preview-image [dir] [--port PORT] [--no-restart]
52
- castle-web push [dir]
53
+ castle-web save-deck [dir]
54
+ castle-web get-deck <dir> [--deck-id ID]
53
55
  castle-web login`);
54
56
  process.exit(1);
55
57
  }
56
58
  async function main() {
57
59
  switch (command) {
58
60
  case 'init': {
59
- const dir = args[1];
60
- if (!dir) {
61
- console.error('Usage: castle-web init <dir>');
61
+ const dir = findPositionalDir();
62
+ if (dir === '.') {
63
+ console.error('Usage: castle-web init <dir> [--kit NAME]');
62
64
  process.exit(1);
63
65
  }
64
- init(dir);
66
+ const kit = getFlagValue('--kit');
67
+ init(dir, { kit });
65
68
  break;
66
69
  }
67
70
  case 'serve': {
68
71
  const dir = findPositionalDir();
69
72
  const port = getFlagValue('--port');
73
+ const host = getFlagValue('--host');
70
74
  const open = args.includes('--open');
71
- await serve(dir, { port, open });
75
+ const detach = args.includes('--detach');
76
+ await serve(dir, { port, host, open, detach });
72
77
  break;
73
78
  }
74
- case 'push': {
79
+ case 'save-deck': {
75
80
  const dir = findPositionalDir();
76
- await push(dir);
81
+ await saveDeck(dir);
77
82
  const wsPort = getWsPort(dir);
78
83
  await savePreviewIfNeeded(dir, wsPort);
79
84
  break;
80
85
  }
86
+ case 'get-deck': {
87
+ const dir = findPositionalDir();
88
+ if (dir === '.') {
89
+ console.error('Usage: castle-web get-deck <dir> [--deck-id ID]');
90
+ process.exit(1);
91
+ }
92
+ const deckId = getFlagValue('--deck-id');
93
+ await getDeck(dir, { deckId });
94
+ break;
95
+ }
81
96
  case 'restart': {
82
97
  const dir = findPositionalDir();
83
98
  const wsPort = getWsPort(dir);
@@ -94,39 +109,19 @@ async function main() {
94
109
  const dir = findPositionalDir();
95
110
  const outFile = getFlagValue('--out') ?? 'screenshot.png';
96
111
  const wsPort = getWsPort(dir);
97
- const requestId = Math.random().toString(36).slice(2);
98
- const { default: WS } = await import('ws');
99
- const ws = new WS(`ws://localhost:${wsPort}`);
100
- ws.on('open', () => {
101
- ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
102
- });
103
- const timeout = setTimeout(() => {
104
- console.error('Screenshot timed out. Is castle-web serve running?');
105
- ws.close();
106
- process.exit(1);
107
- }, 3000);
108
- ws.on('message', (raw) => {
109
- try {
110
- const msg = JSON.parse(raw.toString());
111
- if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
112
- clearTimeout(timeout);
113
- const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
114
- fs.writeFileSync(outFile, Buffer.from(base64, 'base64'));
115
- console.log(`Saved ${outFile}`);
116
- ws.close();
117
- process.exit(0);
118
- }
119
- }
120
- catch { }
121
- });
122
- break;
112
+ const ws = await connectWS(wsPort);
113
+ const base64 = await takeScreenshot(ws);
114
+ ws.close();
115
+ fs.writeFileSync(outFile, Buffer.from(base64, 'base64'));
116
+ console.log(`Saved ${outFile}`);
117
+ return;
123
118
  }
124
119
  case 'save-preview-image': {
125
120
  const dir = findPositionalDir();
126
121
  const wsPort = getWsPort(dir);
127
122
  const noRestart = args.includes('--no-restart');
128
123
  await savePreviewImage(dir, wsPort, noRestart);
129
- process.exit(0);
124
+ return;
130
125
  }
131
126
  case 'login':
132
127
  await login();
@@ -136,6 +131,6 @@ async function main() {
136
131
  }
137
132
  }
138
133
  main().catch((e) => {
139
- console.error(e.message ?? e);
134
+ console.error(e instanceof Error ? e.message : String(e));
140
135
  process.exit(1);
141
136
  });
package/dist/init.d.ts CHANGED
@@ -1 +1,3 @@
1
- export declare function init(dir: string): Promise<void>;
1
+ export declare function init(dir: string, opts?: {
2
+ kit?: string;
3
+ }): void;