castle-web-cli 0.4.117 → 0.4.118

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/ide.js CHANGED
@@ -6,56 +6,55 @@
6
6
  // Backend pattern ported from castle-cli's `ide` branch: @lydell/node-pty for
7
7
  // the PTY, an @xterm/headless screen + @xterm/addon-serialize so a reconnecting
8
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 picomatch from "picomatch";
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
- import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
18
- import { readEditorConfig, resolveFileTypes, } from "./editorConfig.js";
19
- import { UNSUPPORTED_MEDIA } from "./unsupportedMedia.js";
20
- import { IMPORT_API_PREFIX, handleImportApi } from "./importBrowse.js";
21
- import { readRequestBody, sendJson } from "./httpJson.js";
22
- import { envForUserShell, installCliShims } from "./byo-auth.js";
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import picomatch from 'picomatch';
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
+ import { IMPORTS_DIR, importStatuses, updateImport } from './imports.js';
18
+ import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
19
+ import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
20
+ import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
21
+ import { readRequestBody, sendJson } from './httpJson.js';
22
+ import { envForUserShell, installCliShims } from './byo-auth.js';
23
23
  const HeadlessTerminal = headlessPkg.Terminal;
24
24
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
25
25
  // The bundled shell app (vite build output). `/` serves its index.html and
26
26
  // `/__castle/ide/<asset>` serves its hashed bundle assets (the vite `base`).
27
- const SHELL_DIR = path.join(DIST_DIR, "shell");
28
- const PTY_TERM = "xterm-256color";
27
+ const SHELL_DIR = path.join(DIST_DIR, 'shell');
28
+ const PTY_TERM = 'xterm-256color';
29
29
  const INITIAL_COLS = 80;
30
30
  const INITIAL_ROWS = 24;
31
31
  const SCROLLBACK = 4000;
32
32
  const SHELL_MIME = {
33
- ".html": "text/html; charset=utf-8",
34
- ".js": "text/javascript; charset=utf-8",
35
- ".mjs": "text/javascript; charset=utf-8",
36
- ".css": "text/css; charset=utf-8",
37
- ".json": "application/json; charset=utf-8",
38
- ".svg": "image/svg+xml",
39
- ".png": "image/png",
40
- ".ico": "image/x-icon",
41
- ".jpg": "image/jpeg",
42
- ".woff": "font/woff",
43
- ".woff2": "font/woff2",
44
- ".ttf": "font/ttf",
45
- ".map": "application/json; charset=utf-8",
33
+ '.html': 'text/html; charset=utf-8',
34
+ '.js': 'text/javascript; charset=utf-8',
35
+ '.mjs': 'text/javascript; charset=utf-8',
36
+ '.css': 'text/css; charset=utf-8',
37
+ '.json': 'application/json; charset=utf-8',
38
+ '.svg': 'image/svg+xml',
39
+ '.png': 'image/png',
40
+ '.ico': 'image/x-icon',
41
+ '.jpg': 'image/jpeg',
42
+ '.woff': 'font/woff',
43
+ '.woff2': 'font/woff2',
44
+ '.ttf': 'font/ttf',
45
+ '.map': 'application/json; charset=utf-8',
46
46
  };
47
47
  // Does the deck serve this root-level file itself? Vite serves both the deck
48
48
  // root and its `public/` dir at `/`, so either location counts.
49
49
  function deckHasFile(deckDir, name) {
50
- return (fs.existsSync(path.join(deckDir, name)) ||
51
- fs.existsSync(path.join(deckDir, "public", name)));
50
+ return (fs.existsSync(path.join(deckDir, name)) || fs.existsSync(path.join(deckDir, 'public', name)));
52
51
  }
53
52
  // Serve a file from the bundled shell dir, guarding against path traversal.
54
53
  function serveShellFile(res, asset) {
55
- const rel = path.normalize(asset).replace(/^(\.\.[/\\])+/, "");
54
+ const rel = path.normalize(asset).replace(/^(\.\.[/\\])+/, '');
56
55
  const filePath = path.join(SHELL_DIR, rel);
57
56
  if (!filePath.startsWith(SHELL_DIR + path.sep) &&
58
- filePath !== path.join(SHELL_DIR, "index.html")) {
57
+ filePath !== path.join(SHELL_DIR, 'index.html')) {
59
58
  res.writeHead(404).end();
60
59
  return true;
61
60
  }
@@ -64,8 +63,8 @@ function serveShellFile(res, asset) {
64
63
  return true;
65
64
  }
66
65
  res.writeHead(200, {
67
- "content-type": SHELL_MIME[path.extname(filePath)] ?? "application/octet-stream",
68
- "cache-control": "no-store",
66
+ 'content-type': SHELL_MIME[path.extname(filePath)] ?? 'application/octet-stream',
67
+ 'cache-control': 'no-store',
69
68
  });
70
69
  fs.createReadStream(filePath).pipe(res);
71
70
  return true;
@@ -74,34 +73,30 @@ function serveShellFile(res, asset) {
74
73
  // into the iframe at `/index.html`); `/__castle/ide/<asset>` are the shell's
75
74
  // static assets; `/__castle/pty` is the PTY WebSocket (handled via a direct
76
75
  // upgrade handler on Vite's HTTP server).
77
- export const IDE_ASSET_PREFIX = "/__castle/ide/";
78
- export const PTY_WS_PATH = "/__castle/pty";
76
+ export const IDE_ASSET_PREFIX = '/__castle/ide/';
77
+ export const PTY_WS_PATH = '/__castle/pty';
79
78
  // Castle's favicon (the same files castle.xyz serves), shipped in the shell
80
79
  // bundle and also served from the origin root so every page under the serve
81
80
  // gets it -- the shell at `/`, the deck page at `/index.html`, and the
82
81
  // browser's implicit `/favicon.ico` probe. The deck wins if it ships its own.
83
- export const FAVICON_FILES = [
84
- "favicon.ico",
85
- "favicon-16x16.png",
86
- "favicon-32x32.png",
87
- ];
82
+ export const FAVICON_FILES = ['favicon.ico', 'favicon-16x16.png', 'favicon-32x32.png'];
88
83
  // `<link rel="icon">` tags for the root-served favicons, injected into the deck
89
84
  // page (see serve.ts). The shell's own tags live in `src/shell/index.html`.
90
85
  export const FAVICON_LINK_TAGS = [
91
86
  '<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
92
87
  '<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
93
88
  '<link rel="icon" href="/favicon.ico" sizes="any" />',
94
- ].join("\n ");
89
+ ].join('\n ');
95
90
  // Builtin Files + code-editor panels talk to the deck through these endpoints
96
91
  // (the shell no longer routes file browsing / code editing through the kit
97
92
  // iframe). `list`/`read`/`write` operate on files within the deck dir;
98
93
  // `info` reports which extensions the kit owns a rich editor for (so the shell
99
94
  // hands those files to the kit iframe and keeps the builtin editor as the
100
95
  // default for everything else).
101
- export const FILES_API_PREFIX = "/__castle/files/";
96
+ export const FILES_API_PREFIX = '/__castle/files/';
102
97
  // Directories never surfaced in the file list / never read or written through
103
98
  // the builtin editor: VCS, deck-private state, and dependency trees.
104
- const FILES_IGNORE_DIRS = new Set([".git", ".castle", "node_modules", "dist"]);
99
+ const FILES_IGNORE_DIRS = new Set(['.git', '.castle', 'node_modules', 'dist']);
105
100
  // Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
106
101
  // a huge asset is a problem for the deck long before it is a problem here --
107
102
  // refuse it at the door with a message rather than let it land and break `save`.
@@ -111,24 +106,24 @@ const MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
111
106
  // which is text-only). Anything unlisted is served as opaque bytes -- a viewer
112
107
  // that asked for it knows what it is.
113
108
  const RAW_MIME = {
114
- ".png": "image/png",
115
- ".jpg": "image/jpeg",
116
- ".jpeg": "image/jpeg",
117
- ".gif": "image/gif",
118
- ".webp": "image/webp",
119
- ".avif": "image/avif",
120
- ".bmp": "image/bmp",
121
- ".svg": "image/svg+xml",
122
- ".mp3": "audio/mpeg",
123
- ".wav": "audio/wav",
124
- ".ogg": "audio/ogg",
125
- ".m4a": "audio/mp4",
126
- ".aac": "audio/aac",
127
- ".flac": "audio/flac",
128
- ".webm": "video/webm",
129
- ".mp4": "video/mp4",
130
- ".m4v": "video/x-m4v",
131
- ".mov": "video/quicktime",
109
+ '.png': 'image/png',
110
+ '.jpg': 'image/jpeg',
111
+ '.jpeg': 'image/jpeg',
112
+ '.gif': 'image/gif',
113
+ '.webp': 'image/webp',
114
+ '.avif': 'image/avif',
115
+ '.bmp': 'image/bmp',
116
+ '.svg': 'image/svg+xml',
117
+ '.mp3': 'audio/mpeg',
118
+ '.wav': 'audio/wav',
119
+ '.ogg': 'audio/ogg',
120
+ '.m4a': 'audio/mp4',
121
+ '.aac': 'audio/aac',
122
+ '.flac': 'audio/flac',
123
+ '.webm': 'video/webm',
124
+ '.mp4': 'video/mp4',
125
+ '.m4v': 'video/x-m4v',
126
+ '.mov': 'video/quicktime',
132
127
  };
133
128
  // Files under `imports/` came from another deck. They are readable like any
134
129
  // other deck file -- the engine, editors and pickers all reference them -- but a
@@ -144,12 +139,12 @@ function isImportPath(rel) {
144
139
  // that are about to CHANGE the file pass `mutation`, which additionally refuses
145
140
  // anything a dependency owns.
146
141
  function resolveDeckPath(deckDir, requestedPath, opts = {}) {
147
- if (typeof requestedPath !== "string" || requestedPath.trim() === "") {
148
- return { ok: false, error: "Missing file path." };
142
+ if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
143
+ return { ok: false, error: 'Missing file path.' };
149
144
  }
150
- const normalized = path.normalize(requestedPath.replace(/\\/g, "/"));
151
- if (normalized === "." ||
152
- normalized === ".." ||
145
+ const normalized = path.normalize(requestedPath.replace(/\\/g, '/'));
146
+ if (normalized === '.' ||
147
+ normalized === '..' ||
153
148
  path.isAbsolute(normalized) ||
154
149
  normalized.startsWith(`..${path.sep}`)) {
155
150
  return { ok: false, error: `Path outside the deck: ${requestedPath}` };
@@ -160,10 +155,10 @@ function resolveDeckPath(deckDir, requestedPath, opts = {}) {
160
155
  }
161
156
  const abs = path.resolve(deckDir, normalized);
162
157
  const rel = path.relative(deckDir, abs);
163
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
158
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
164
159
  return { ok: false, error: `Path outside the deck: ${requestedPath}` };
165
160
  }
166
- const relPosix = rel.split(path.sep).join("/");
161
+ const relPosix = rel.split(path.sep).join('/');
167
162
  if (opts.mutation && isImportPath(relPosix)) {
168
163
  return {
169
164
  ok: false,
@@ -185,7 +180,7 @@ function listDeckFiles(deckDir) {
185
180
  return;
186
181
  }
187
182
  for (const entry of entries) {
188
- if (entry.name === ".DS_Store")
183
+ if (entry.name === '.DS_Store')
189
184
  continue;
190
185
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
191
186
  if (entry.isDirectory()) {
@@ -198,7 +193,7 @@ function listDeckFiles(deckDir) {
198
193
  }
199
194
  }
200
195
  }
201
- walk(deckDir, "");
196
+ walk(deckDir, '');
202
197
  out.sort((a, b) => a.localeCompare(b));
203
198
  return out;
204
199
  }
@@ -208,7 +203,7 @@ function listDeckFiles(deckDir) {
208
203
  // editors, and the builtin code editor renders everything.
209
204
  function kitEditorExtensions(config, fileTypes) {
210
205
  if (fileTypes) {
211
- return fileTypes.filter((t) => t.editor === "kit").map((t) => t.ext);
206
+ return fileTypes.filter((t) => t.editor === 'kit').map((t) => t.ext);
212
207
  }
213
208
  // A deck saved before file types existed named its kit extensions directly.
214
209
  return config.extensions ?? [];
@@ -235,14 +230,14 @@ function filterDeckFiles(files, config) {
235
230
  function filterImportedFiles(deckDir, imported) {
236
231
  const byAlias = new Map();
237
232
  for (const file of imported) {
238
- const [, alias, ...rest] = file.split("/");
233
+ const [, alias, ...rest] = file.split('/');
239
234
  if (!alias || rest.length === 0)
240
235
  continue;
241
236
  const list = byAlias.get(alias);
242
237
  if (list)
243
- list.push(rest.join("/"));
238
+ list.push(rest.join('/'));
244
239
  else
245
- byAlias.set(alias, [rest.join("/")]);
240
+ byAlias.set(alias, [rest.join('/')]);
246
241
  }
247
242
  const out = [];
248
243
  for (const [alias, relFiles] of byAlias) {
@@ -261,7 +256,7 @@ function readRequestBuffer(req, limit) {
261
256
  const chunks = [];
262
257
  let size = 0;
263
258
  let over = false;
264
- req.on("data", (c) => {
259
+ req.on('data', (c) => {
265
260
  size += c.length;
266
261
  // Past the cap, keep draining but stop KEEPING the bytes. Destroying the
267
262
  // request here would reset the connection and the client would see a
@@ -273,8 +268,8 @@ function readRequestBuffer(req, limit) {
273
268
  }
274
269
  chunks.push(c);
275
270
  });
276
- req.on("end", () => over ? reject(new RangeError("too large")) : resolve(Buffer.concat(chunks)));
277
- req.on("error", reject);
271
+ req.on('end', () => over ? reject(new RangeError('too large')) : resolve(Buffer.concat(chunks)));
272
+ req.on('error', reject);
278
273
  });
279
274
  }
280
275
  function withJsonBody(req, res, handler) {
@@ -284,7 +279,7 @@ function withJsonBody(req, res, handler) {
284
279
  body = JSON.parse(await readRequestBody(req));
285
280
  }
286
281
  catch {
287
- return sendJson(res, 400, { error: "Invalid JSON body." });
282
+ return sendJson(res, 400, { error: 'Invalid JSON body.' });
288
283
  }
289
284
  handler(body);
290
285
  })();
@@ -305,16 +300,16 @@ function sendFailure(res, action, rel, err) {
305
300
  }
306
301
  function handleFilesWrite(deckDir, req, res) {
307
302
  withMutationPath(deckDir, req, res, (target, body) => {
308
- if (typeof body.contents !== "string") {
309
- return sendJson(res, 400, { error: "File contents must be a string." });
303
+ if (typeof body.contents !== 'string') {
304
+ return sendJson(res, 400, { error: 'File contents must be a string.' });
310
305
  }
311
306
  try {
312
307
  fs.mkdirSync(path.dirname(target.abs), { recursive: true });
313
- fs.writeFileSync(target.abs, body.contents, "utf8");
308
+ fs.writeFileSync(target.abs, body.contents, 'utf8');
314
309
  sendJson(res, 200, { ok: true, path: target.rel });
315
310
  }
316
311
  catch (err) {
317
- sendFailure(res, "write", target.rel, err);
312
+ sendFailure(res, 'write', target.rel, err);
318
313
  }
319
314
  });
320
315
  }
@@ -329,16 +324,16 @@ function ensureVisiblePath(deckDir, rel) {
329
324
  // No-op if an existing glob already covers `probe`, a path the new glob would
330
325
  // match. Returns whether it wrote.
331
326
  function ensureVisibleGlob(deckDir, glob, probe) {
332
- const file = path.join(deckDir, "castle.json");
327
+ const file = path.join(deckDir, 'castle.json');
333
328
  let data;
334
329
  try {
335
- data = JSON.parse(fs.readFileSync(file, "utf8"));
330
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
336
331
  }
337
332
  catch {
338
333
  return false; // no castle.json yet (deck never saved) -> treat as not curated
339
334
  }
340
335
  const visible = data.editor && Array.isArray(data.editor.visiblePaths)
341
- ? data.editor.visiblePaths.filter((v) => typeof v === "string")
336
+ ? data.editor.visiblePaths.filter((v) => typeof v === 'string')
342
337
  : null;
343
338
  if (!visible || visible.length === 0)
344
339
  return false; // not curated -> all visible
@@ -349,7 +344,7 @@ function ensureVisibleGlob(deckDir, glob, probe) {
349
344
  visible.push(glob);
350
345
  data.editor.visiblePaths = visible;
351
346
  try {
352
- fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
347
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
353
348
  return true;
354
349
  }
355
350
  catch {
@@ -362,7 +357,7 @@ function handleFilesMkdir(deckDir, req, res) {
362
357
  fs.mkdirSync(target.abs, { recursive: true });
363
358
  }
364
359
  catch (err) {
365
- return sendFailure(res, "create folder", target.rel, err);
360
+ return sendFailure(res, 'create folder', target.rel, err);
366
361
  }
367
362
  const visiblePathAdded = ensureVisiblePath(deckDir, target.rel);
368
363
  sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
@@ -378,8 +373,8 @@ function handleFilesMkdir(deckDir, req, res) {
378
373
  // the same way mkdir does -- otherwise an upload lands on disk and is invisible
379
374
  // in the very panel that put it there.
380
375
  function handleFilesUpload(deckDir, req, res) {
381
- const url = new URL(req.url ?? "/", "http://localhost");
382
- const target = resolveDeckPath(deckDir, url.searchParams.get("path"), {
376
+ const url = new URL(req.url ?? '/', 'http://localhost');
377
+ const target = resolveDeckPath(deckDir, url.searchParams.get('path'), {
383
378
  mutation: true,
384
379
  });
385
380
  if (!target.ok)
@@ -402,24 +397,24 @@ function handleFilesUpload(deckDir, req, res) {
402
397
  fs.writeFileSync(target.abs, bytes);
403
398
  }
404
399
  catch (err) {
405
- return sendFailure(res, "upload", target.rel, err);
400
+ return sendFailure(res, 'upload', target.rel, err);
406
401
  }
407
402
  const dir = path.posix.dirname(target.rel);
408
- const visiblePathAdded = dir === "."
403
+ const visiblePathAdded = dir === '.'
409
404
  ? ensureVisibleGlob(deckDir, target.rel, target.rel)
410
405
  : ensureVisiblePath(deckDir, dir);
411
406
  sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
412
407
  }, (err) => {
413
408
  if (err instanceof RangeError)
414
409
  return tooBig();
415
- sendFailure(res, "upload", target.rel, err);
410
+ sendFailure(res, 'upload', target.rel, err);
416
411
  });
417
412
  }
418
413
  // Serve a deck file as bytes. `read` returns UTF-8 text in JSON, which mangles
419
414
  // anything binary; the shell's image/audio viewers read through this instead.
420
415
  function handleFilesRaw(deckDir, req, res) {
421
- const url = new URL(req.url ?? "/", "http://localhost");
422
- const resolved = resolveDeckPath(deckDir, url.searchParams.get("path"));
416
+ const url = new URL(req.url ?? '/', 'http://localhost');
417
+ const resolved = resolveDeckPath(deckDir, url.searchParams.get('path'));
423
418
  if (!resolved.ok)
424
419
  return sendJson(res, 400, { error: resolved.error });
425
420
  let stat;
@@ -433,44 +428,43 @@ function handleFilesRaw(deckDir, req, res) {
433
428
  return sendJson(res, 404, { error: `Not a file: ${resolved.rel}` });
434
429
  }
435
430
  const headers = {
436
- "content-type": RAW_MIME[path.extname(resolved.abs).toLowerCase()] ??
437
- "application/octet-stream",
438
- "cache-control": "no-store",
431
+ 'content-type': RAW_MIME[path.extname(resolved.abs).toLowerCase()] ?? 'application/octet-stream',
432
+ 'cache-control': 'no-store',
439
433
  // Seeking an <audio>/<video> is a range request; without this the browser
440
434
  // won't scrub (it re-requests from 0 and the playhead snaps back).
441
- "accept-ranges": "bytes",
435
+ 'accept-ranges': 'bytes',
442
436
  };
443
437
  const range = parseByteRange(req.headers.range, stat.size);
444
- if (range === "unsatisfiable") {
445
- res.writeHead(416, { "content-range": `bytes */${stat.size}` }).end();
438
+ if (range === 'unsatisfiable') {
439
+ res.writeHead(416, { 'content-range': `bytes */${stat.size}` }).end();
446
440
  return;
447
441
  }
448
442
  if (!range) {
449
- res.writeHead(200, { ...headers, "content-length": String(stat.size) });
443
+ res.writeHead(200, { ...headers, 'content-length': String(stat.size) });
450
444
  fs.createReadStream(resolved.abs).pipe(res);
451
445
  return;
452
446
  }
453
447
  res.writeHead(206, {
454
448
  ...headers,
455
- "content-length": String(range.end - range.start + 1),
456
- "content-range": `bytes ${range.start}-${range.end}/${stat.size}`,
449
+ 'content-length': String(range.end - range.start + 1),
450
+ 'content-range': `bytes ${range.start}-${range.end}/${stat.size}`,
457
451
  });
458
452
  fs.createReadStream(resolved.abs, { start: range.start, end: range.end }).pipe(res);
459
453
  }
460
454
  // A single `Range: bytes=<start>-<end>` (the only form a media element sends).
461
455
  // null = serve the whole file; 'unsatisfiable' = a range past the end.
462
456
  function parseByteRange(header, size) {
463
- const match = /^bytes=(\d*)-(\d*)$/.exec((header ?? "").trim());
457
+ const match = /^bytes=(\d*)-(\d*)$/.exec((header ?? '').trim());
464
458
  if (!match || size === 0)
465
459
  return null;
466
460
  const [, rawStart, rawEnd] = match;
467
- if (rawStart === "" && rawEnd === "")
461
+ if (rawStart === '' && rawEnd === '')
468
462
  return null;
469
463
  // `bytes=-N`: the last N bytes.
470
- const start = rawStart === "" ? Math.max(0, size - Number(rawEnd)) : Number(rawStart);
471
- const end = rawStart === "" || rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
464
+ const start = rawStart === '' ? Math.max(0, size - Number(rawEnd)) : Number(rawStart);
465
+ const end = rawStart === '' || rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
472
466
  if (!Number.isFinite(start) || start > end || start >= size)
473
- return "unsatisfiable";
467
+ return 'unsatisfiable';
474
468
  return { start, end };
475
469
  }
476
470
  // A NUL byte near the start is the practical test for "not text": no text file
@@ -500,7 +494,7 @@ function handleFilesCopy(deckDir, req, res) {
500
494
  sendJson(res, 200, { ok: true, path: to.rel });
501
495
  }
502
496
  catch (err) {
503
- sendFailure(res, "copy", from.rel, err);
497
+ sendFailure(res, 'copy', from.rel, err);
504
498
  }
505
499
  });
506
500
  }
@@ -531,9 +525,7 @@ function handleFilesRename(deckDir, req, res) {
531
525
  // filesystem (default on macOS/Windows) `to` can "exist" only because it is
532
526
  // `from` under a different case -- a case-only rename like bounce.jsx ->
533
527
  // Bounce.jsx. Allow that by treating same-inode as not-a-collision.
534
- if (from.abs !== to.abs &&
535
- fs.existsSync(to.abs) &&
536
- !isSameFile(from.abs, to.abs)) {
528
+ if (from.abs !== to.abs && fs.existsSync(to.abs) && !isSameFile(from.abs, to.abs)) {
537
529
  return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
538
530
  }
539
531
  try {
@@ -542,7 +534,7 @@ function handleFilesRename(deckDir, req, res) {
542
534
  sendJson(res, 200, { ok: true, path: to.rel });
543
535
  }
544
536
  catch (err) {
545
- sendFailure(res, "rename", from.rel, err);
537
+ sendFailure(res, 'rename', from.rel, err);
546
538
  }
547
539
  });
548
540
  }
@@ -556,7 +548,7 @@ function handleFilesDelete(deckDir, req, res) {
556
548
  sendJson(res, 200, { ok: true, path: target.rel });
557
549
  }
558
550
  catch (err) {
559
- sendFailure(res, "delete", target.rel, err);
551
+ sendFailure(res, 'delete', target.rel, err);
560
552
  }
561
553
  });
562
554
  }
@@ -565,7 +557,7 @@ function handleFilesDelete(deckDir, req, res) {
565
557
  // rejects traversal and protected dirs.
566
558
  function handleFilesApi(deckDir, req, res, reqPath) {
567
559
  const action = reqPath.slice(FILES_API_PREFIX.length);
568
- if (action === "info") {
560
+ if (action === 'info') {
569
561
  const config = readEditorConfig(deckDir);
570
562
  // File types come from the deck AND its imports (a kit is an imported deck,
571
563
  // so importing one is what gives a deck scenes and drawings); everything
@@ -587,7 +579,7 @@ function handleFilesApi(deckDir, req, res, reqPath) {
587
579
  });
588
580
  return true;
589
581
  }
590
- if (action === "imports") {
582
+ if (action === 'imports') {
591
583
  // Reports only: whether each import is behind the version its deck has now.
592
584
  // Taking the update is a deliberate act (`castle-web update-import`), never
593
585
  // something that happens to a deck while it is open.
@@ -596,7 +588,7 @@ function handleFilesApi(deckDir, req, res, reqPath) {
596
588
  .catch(() => sendJson(res, 200, { imports: [] }));
597
589
  return true;
598
590
  }
599
- if (action === "update-import") {
591
+ if (action === 'update-import') {
600
592
  // Takes the update for one import. Deliberate by construction: the editor
601
593
  // asks first, and this is the only path from the panel that changes what a
602
594
  // dependency contains.
@@ -606,10 +598,10 @@ function handleFilesApi(deckDir, req, res, reqPath) {
606
598
  body = JSON.parse(await readRequestBody(req));
607
599
  }
608
600
  catch {
609
- return sendJson(res, 400, { error: "Invalid JSON body." });
601
+ return sendJson(res, 400, { error: 'Invalid JSON body.' });
610
602
  }
611
- if (typeof body.alias !== "string" || !body.alias) {
612
- return sendJson(res, 400, { error: "Missing import name." });
603
+ if (typeof body.alias !== 'string' || !body.alias) {
604
+ return sendJson(res, 400, { error: 'Missing import name.' });
613
605
  }
614
606
  try {
615
607
  await updateImport(deckDir, { alias: body.alias });
@@ -621,14 +613,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
621
613
  })();
622
614
  return true;
623
615
  }
624
- if (action === "list") {
616
+ if (action === 'list') {
625
617
  // `?all=1` returns the unfiltered listing (the "show hidden files & folders"
626
618
  // toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
627
619
  // just without the deck's visible/hidden path curation.
628
- const url = new URL(req.url ?? "/", "http://localhost");
620
+ const url = new URL(req.url ?? '/', 'http://localhost');
629
621
  const listed = listDeckFiles(deckDir);
630
622
  let files = listed;
631
- if (url.searchParams.get("all") !== "1") {
623
+ if (url.searchParams.get('all') !== '1') {
632
624
  // Each deck curates its own files. The importing deck's visible/hidden
633
625
  // paths name its own dirs (a kit's name scenes/, drawings/ ...), so
634
626
  // applying them to imports would hide every import by omission -- an
@@ -643,9 +635,9 @@ function handleFilesApi(deckDir, req, res, reqPath) {
643
635
  sendJson(res, 200, { files });
644
636
  return true;
645
637
  }
646
- if (action === "read") {
647
- const url = new URL(req.url ?? "/", "http://localhost");
648
- const resolved = resolveDeckPath(deckDir, url.searchParams.get("path"));
638
+ if (action === 'read') {
639
+ const url = new URL(req.url ?? '/', 'http://localhost');
640
+ const resolved = resolveDeckPath(deckDir, url.searchParams.get('path'));
649
641
  if (!resolved.ok)
650
642
  return (sendJson(res, 400, { error: resolved.error }), true);
651
643
  try {
@@ -656,13 +648,13 @@ function handleFilesApi(deckDir, req, res, reqPath) {
656
648
  if (isBinary(bytes)) {
657
649
  sendJson(res, 200, {
658
650
  path: resolved.rel,
659
- contents: "",
651
+ contents: '',
660
652
  binary: true,
661
653
  size: bytes.length,
662
654
  });
663
655
  }
664
656
  else {
665
- sendJson(res, 200, { path: resolved.rel, contents: bytes.toString("utf8") });
657
+ sendJson(res, 200, { path: resolved.rel, contents: bytes.toString('utf8') });
666
658
  }
667
659
  }
668
660
  catch {
@@ -670,51 +662,45 @@ function handleFilesApi(deckDir, req, res, reqPath) {
670
662
  }
671
663
  return true;
672
664
  }
673
- if (action === "copy") {
665
+ if (action === 'copy') {
674
666
  handleFilesCopy(deckDir, req, res);
675
667
  return true;
676
668
  }
677
- if (action === "write") {
669
+ if (action === 'write') {
678
670
  handleFilesWrite(deckDir, req, res);
679
671
  return true;
680
672
  }
681
- if (action === "rename") {
673
+ if (action === 'rename') {
682
674
  handleFilesRename(deckDir, req, res);
683
675
  return true;
684
676
  }
685
- if (action === "delete") {
677
+ if (action === 'delete') {
686
678
  handleFilesDelete(deckDir, req, res);
687
679
  return true;
688
680
  }
689
- if (action === "mkdir") {
681
+ if (action === 'mkdir') {
690
682
  handleFilesMkdir(deckDir, req, res);
691
683
  return true;
692
684
  }
693
- if (action === "upload") {
685
+ if (action === 'upload') {
694
686
  handleFilesUpload(deckDir, req, res);
695
687
  return true;
696
688
  }
697
- if (action === "raw") {
689
+ if (action === 'raw') {
698
690
  handleFilesRaw(deckDir, req, res);
699
691
  return true;
700
692
  }
701
- return (sendJson(res, 404, { error: `Unknown files action: ${action}` }),
702
- true);
693
+ return (sendJson(res, 404, { error: `Unknown files action: ${action}` }), true);
703
694
  }
704
695
  function defaultShell() {
705
- if (process.platform === "win32") {
706
- return { command: process.env.COMSPEC ?? "cmd.exe", args: [] };
696
+ if (process.platform === 'win32') {
697
+ return { command: process.env.COMSPEC ?? 'cmd.exe', args: [] };
707
698
  }
708
699
  // Pick the first shell that actually exists: $SHELL (dev), then zsh (macOS),
709
700
  // then bash / sh. The cloud sandbox image is Debian-based (node:22) with no
710
701
  // zsh and no $SHELL set, so a bare `/bin/zsh` fallback fails with execvp
711
702
  // ENOENT and the terminal never gets a shell.
712
- const candidates = [
713
- process.env.SHELL,
714
- "/bin/zsh",
715
- "/bin/bash",
716
- "/bin/sh",
717
- ].filter((c) => Boolean(c));
703
+ const candidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter((c) => Boolean(c));
718
704
  const command = candidates.find((c) => {
719
705
  try {
720
706
  return fs.existsSync(c);
@@ -722,18 +708,18 @@ function defaultShell() {
722
708
  catch {
723
709
  return false;
724
710
  }
725
- }) ?? "/bin/sh";
726
- return { command, args: ["-l"] };
711
+ }) ?? '/bin/sh';
712
+ return { command, args: ['-l'] };
727
713
  }
728
714
  function ptyEnv(shimDir) {
729
715
  const env = {
730
716
  ...envForUserShell(process.env),
731
717
  TERM: PTY_TERM,
732
- COLORTERM: "truecolor",
733
- CLICOLOR: "1",
734
- CLICOLOR_FORCE: "1",
735
- FORCE_COLOR: process.env.FORCE_COLOR === "0" ? "3" : (process.env.FORCE_COLOR ?? "3"),
736
- TERM_PROGRAM: "castle-web-ide",
718
+ COLORTERM: 'truecolor',
719
+ CLICOLOR: '1',
720
+ CLICOLOR_FORCE: '1',
721
+ FORCE_COLOR: process.env.FORCE_COLOR === '0' ? '3' : (process.env.FORCE_COLOR ?? '3'),
722
+ TERM_PROGRAM: 'castle-web-ide',
737
723
  };
738
724
  delete env.NO_COLOR;
739
725
  delete env.NODE_DISABLE_COLORS;
@@ -750,10 +736,10 @@ function clampSize(value, fallback) {
750
736
  }
751
737
  export function rawDataToString(data) {
752
738
  if (Array.isArray(data))
753
- return Buffer.concat(data).toString("utf8");
739
+ return Buffer.concat(data).toString('utf8');
754
740
  if (Buffer.isBuffer(data))
755
- return data.toString("utf8");
756
- return Buffer.from(new Uint8Array(data)).toString("utf8");
741
+ return data.toString('utf8');
742
+ return Buffer.from(new Uint8Array(data)).toString('utf8');
757
743
  }
758
744
  function send(socket, body) {
759
745
  if (socket.readyState === socket.OPEN)
@@ -765,7 +751,7 @@ function queueScreenOp(session, op) {
765
751
  session.renderQueue = session.renderQueue
766
752
  .catch(() => undefined)
767
753
  .then(op)
768
- .catch((err) => console.error("[ide] screen render failed", err));
754
+ .catch((err) => console.error('[ide] screen render failed', err));
769
755
  }
770
756
  async function waitForStableScreen(session) {
771
757
  while (true) {
@@ -815,16 +801,16 @@ export function createIdeServer(opts) {
815
801
  };
816
802
  pty.onData((data) => {
817
803
  queueScreenOp(s, () => new Promise((done) => s.screen.write(data, done)));
818
- broadcast(s, { type: "output", data });
804
+ broadcast(s, { type: 'output', data });
819
805
  });
820
806
  pty.onExit(({ exitCode, signal }) => {
821
807
  s.exited = { exitCode, signal };
822
- broadcast(s, { type: "exit", exitCode, signal });
808
+ broadcast(s, { type: 'exit', exitCode, signal });
823
809
  // Clients get the `exit` message above (which disables their reconnect),
824
810
  // then we close the sockets so they don't sit half-open.
825
811
  for (const socket of s.clients) {
826
812
  try {
827
- socket.close(1000, "shell exited");
813
+ socket.close(1000, 'shell exited');
828
814
  }
829
815
  catch {
830
816
  /* ignore */
@@ -863,12 +849,12 @@ export function createIdeServer(opts) {
863
849
  if (socket.readyState !== socket.OPEN)
864
850
  return;
865
851
  send(socket, {
866
- type: "replay",
852
+ type: 'replay',
867
853
  data: s.serializeAddon.serialize({ scrollback: SCROLLBACK }),
868
854
  });
869
855
  if (s.exited) {
870
856
  send(socket, {
871
- type: "exit",
857
+ type: 'exit',
872
858
  exitCode: s.exited.exitCode,
873
859
  signal: s.exited.signal,
874
860
  });
@@ -876,8 +862,8 @@ export function createIdeServer(opts) {
876
862
  }
877
863
  catch (err) {
878
864
  send(socket, {
879
- type: "error",
880
- error: err instanceof Error ? err.message : "could not restore screen",
865
+ type: 'error',
866
+ error: err instanceof Error ? err.message : 'could not restore screen',
881
867
  });
882
868
  }
883
869
  finally {
@@ -885,7 +871,7 @@ export function createIdeServer(opts) {
885
871
  s.clients.add(socket);
886
872
  }
887
873
  })();
888
- socket.on("message", (raw) => {
874
+ socket.on('message', (raw) => {
889
875
  let msg;
890
876
  try {
891
877
  msg = JSON.parse(rawDataToString(raw));
@@ -893,21 +879,21 @@ export function createIdeServer(opts) {
893
879
  catch {
894
880
  return;
895
881
  }
896
- if (msg.type === "input" && typeof msg.data === "string") {
882
+ if (msg.type === 'input' && typeof msg.data === 'string') {
897
883
  if (!s.exited)
898
884
  s.pty.write(msg.data);
899
885
  }
900
- else if (msg.type === "resize") {
886
+ else if (msg.type === 'resize') {
901
887
  resizeSession(s, Number(msg.cols), Number(msg.rows));
902
888
  }
903
889
  });
904
- socket.on("close", () => {
890
+ socket.on('close', () => {
905
891
  s.clients.delete(socket);
906
892
  });
907
893
  }
908
894
  const wss = new WebSocketServer({ noServer: true });
909
895
  function handleUpgrade(req, socket, head) {
910
- const url = new URL(req.url ?? "/", "http://localhost");
896
+ const url = new URL(req.url ?? '/', 'http://localhost');
911
897
  if (url.pathname !== PTY_WS_PATH)
912
898
  return false;
913
899
  wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
@@ -915,8 +901,8 @@ export function createIdeServer(opts) {
915
901
  }
916
902
  function handleHttpRequest(req, res, reqPath) {
917
903
  // `/` -> the shell's index.html; `/__castle/ide/<asset>` -> bundle assets.
918
- if (reqPath === "/")
919
- return serveShellFile(res, "index.html");
904
+ if (reqPath === '/')
905
+ return serveShellFile(res, 'index.html');
920
906
  // Root-served favicons, unless the deck ships its own (then fall through
921
907
  // to Vite, which serves the deck's file).
922
908
  const favicon = FAVICON_FILES.find((name) => reqPath === `/${name}`);
@@ -924,13 +910,13 @@ export function createIdeServer(opts) {
924
910
  return serveShellFile(res, favicon);
925
911
  }
926
912
  if (reqPath.startsWith(IDE_ASSET_PREFIX)) {
927
- return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) || "index.html");
913
+ return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) || 'index.html');
928
914
  }
929
915
  if (reqPath.startsWith(FILES_API_PREFIX)) {
930
916
  return handleFilesApi(deckDir, req, res, reqPath);
931
917
  }
932
918
  if (reqPath.startsWith(IMPORT_API_PREFIX)) {
933
- return handleImportApi(deckDir, req, res, reqPath);
919
+ return handleImportApi(deckDir, req, res, reqPath, opts.restart);
934
920
  }
935
921
  return false;
936
922
  }
@@ -944,7 +930,7 @@ export function createIdeServer(opts) {
944
930
  }
945
931
  for (const socket of session.clients) {
946
932
  try {
947
- socket.close(1001, "serve shutting down");
933
+ socket.close(1001, 'serve shutting down');
948
934
  }
949
935
  catch {
950
936
  /* ignore */