castle-web-cli 0.4.113 → 0.4.115

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 (57) hide show
  1. package/dist/castleJson.d.ts +1 -0
  2. package/dist/editorConfig.d.ts +34 -0
  3. package/dist/editorConfig.js +101 -0
  4. package/dist/ide.js +245 -120
  5. package/dist/imports.d.ts +6 -0
  6. package/dist/imports.js +90 -7
  7. package/dist/init.js +15 -2
  8. package/dist/serve.js +16 -0
  9. package/dist/shell/assets/index-CUamb8rK.js +144 -0
  10. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  11. package/dist/shell/index.html +2 -2
  12. package/dist/unsupportedMedia.d.ts +1 -0
  13. package/dist/unsupportedMedia.js +54 -0
  14. package/dist/vitePlugins.js +12 -19
  15. package/kits/basic-2d/castle.json +1 -1
  16. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  17. package/kits/physics-2d/CLAUDE.md +42 -23
  18. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  20. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  21. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +4 -4
  22. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  23. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  24. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  25. package/kits/physics-2d/behaviors/Sprite.jsx +98 -33
  26. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  27. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  28. package/kits/physics-2d/behaviors/tint.js +24 -9
  29. package/kits/physics-2d/castle.json +64 -2
  30. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  31. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  32. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  33. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  34. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  35. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  36. package/kits/physics-2d/editors/mediaFile.js +20 -0
  37. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  38. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  39. package/kits/physics-2d/engine/art.js +105 -0
  40. package/kits/physics-2d/engine/assets.js +11 -3
  41. package/kits/physics-2d/engine/audioContext.js +34 -0
  42. package/kits/physics-2d/engine/collider.js +28 -17
  43. package/kits/physics-2d/engine/files.js +74 -0
  44. package/kits/physics-2d/engine/media.js +212 -0
  45. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  46. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +1 -2
  47. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  48. package/kits/physics-2d/engine/scene.js +12 -0
  49. package/kits/physics-2d/engine/tone.js +112 -0
  50. package/kits/physics-2d/systems/media.js +34 -0
  51. package/kits/physics-2d/systems/physics.js +12 -3
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BjOuaDJM.js +0 -144
  54. package/dist/shell/assets/index-CdXEpv_P.css +0 -1
  55. package/kits/physics-2d/physics/index.js +0 -26
  56. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  57. /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
@@ -9,6 +9,7 @@ export interface CastleJson {
9
9
  deckId?: string;
10
10
  cardId?: string;
11
11
  imports?: Record<string, DeckImport>;
12
+ autoUpdateWhenImported?: boolean;
12
13
  [key: string]: unknown;
13
14
  }
14
15
  export declare function readCastleJson(dir: string): CastleJson | null;
@@ -0,0 +1,34 @@
1
+ interface EditorPanelSpec {
2
+ type?: unknown;
3
+ file?: unknown;
4
+ }
5
+ interface EditorPanelGroup {
6
+ tabs?: unknown;
7
+ }
8
+ interface EditorPanelColumn {
9
+ column?: unknown;
10
+ }
11
+ export type InitialPanel = EditorPanelSpec | EditorPanelGroup | EditorPanelColumn;
12
+ export interface FileTypeConfig {
13
+ ext: string;
14
+ label?: string;
15
+ new?: string;
16
+ icon?: string;
17
+ editor?: string;
18
+ data?: boolean;
19
+ unsupported?: string;
20
+ }
21
+ export interface ResolvedFileType extends FileTypeConfig {
22
+ from?: string;
23
+ }
24
+ export interface EditorConfig {
25
+ initialPanels?: InitialPanel[];
26
+ hiddenPaths?: string[];
27
+ visiblePaths?: string[];
28
+ fileTypes?: FileTypeConfig[];
29
+ extensions?: string[];
30
+ defaultPlayFile?: string;
31
+ }
32
+ export declare function readEditorConfig(deckDir: string): EditorConfig;
33
+ export declare function resolveFileTypes(deckDir: string): ResolvedFileType[] | null;
34
+ export {};
@@ -0,0 +1,101 @@
1
+ // A deck's `castle.json` `editor` block, and the resolution of the one part of
2
+ // it that is not the deck's alone: its file types.
3
+ //
4
+ // A deck's vocabulary -- which extensions exist, what they are called, which
5
+ // ones a kit editor opens, which ones are data -- is declared, never built in.
6
+ // The declarations come from the deck itself AND from every deck it imports,
7
+ // because a kit is just an imported deck: importing one is what gives a deck
8
+ // scenes and drawings, so the import has to be able to say so. Imports are
9
+ // flattened into one `imports/` dir at the deck root (a transitively-pulled kit
10
+ // sits there like any other), so resolving is a read of each import's own
11
+ // castle.json -- there is no graph to walk.
12
+ //
13
+ // Everything else in the block (`initialPanels`, `visiblePaths`/`hiddenPaths`,
14
+ // `defaultPlayFile`) stays the deck's own, read from the deck root only.
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { readCastleJson } from './castleJson.js';
18
+ import { IMPORTS_DIR } from './imports.js';
19
+ // An array with no usable entries stays an (empty) array rather than becoming
20
+ // undefined: "I declare no file types" is a real answer, distinct from "I say
21
+ // nothing about file types", and only the second one defers to the imports.
22
+ function asFileTypes(value) {
23
+ if (!Array.isArray(value))
24
+ return undefined;
25
+ return value.filter((e) => Boolean(e) && typeof e === 'object' && typeof e.ext === 'string');
26
+ }
27
+ function asStringArray(value) {
28
+ if (Array.isArray(value) && value.every((e) => typeof e === 'string')) {
29
+ return value;
30
+ }
31
+ return undefined;
32
+ }
33
+ // Read one deck's `castle.json` `editor` block. Tolerates an absent /
34
+ // unparseable file (returns {}). Folds the legacy top-level `editorExtensions`
35
+ // into `editor.extensions` for decks saved before the `editor` block existed.
36
+ export function readEditorConfig(deckDir) {
37
+ let data;
38
+ try {
39
+ data = JSON.parse(fs.readFileSync(path.join(deckDir, 'castle.json'), 'utf8'));
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ const editor = data.editor ?? {};
45
+ return {
46
+ initialPanels: Array.isArray(editor.initialPanels) ? editor.initialPanels : undefined,
47
+ hiddenPaths: asStringArray(editor.hiddenPaths),
48
+ visiblePaths: asStringArray(editor.visiblePaths),
49
+ fileTypes: asFileTypes(editor.fileTypes),
50
+ extensions: asStringArray(editor.extensions) ?? asStringArray(data.editorExtensions),
51
+ defaultPlayFile: typeof editor.defaultPlayFile === 'string' ? editor.defaultPlayFile : undefined,
52
+ };
53
+ }
54
+ // The deck's imports, in the order the deck pinned them (its castle.json
55
+ // `imports` map), then anything on disk that isn't pinned. Pin order puts an
56
+ // import the deck asked for ahead of one that came in behind it, which is the
57
+ // order the deck itself would expect to be read in.
58
+ function importAliases(deckDir) {
59
+ let onDisk;
60
+ try {
61
+ onDisk = fs
62
+ .readdirSync(path.join(deckDir, IMPORTS_DIR), { withFileTypes: true })
63
+ .filter((entry) => entry.isDirectory())
64
+ .map((entry) => entry.name)
65
+ .sort();
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ const pinned = Object.keys(readCastleJson(deckDir)?.imports ?? {}).filter((alias) => onDisk.includes(alias));
71
+ return [...pinned, ...onDisk.filter((alias) => !pinned.includes(alias))];
72
+ }
73
+ // Every file type this deck understands: its own, plus every import's. Keyed by
74
+ // extension, first declaration wins -- so the deck overrides what it imports,
75
+ // and among imports the one it pinned first does (the same order
76
+ // syncImportDependencies merges dependencies in).
77
+ //
78
+ // Returns null when NOTHING anywhere declares file types, which is a different
79
+ // answer from an empty list: a deck that declares none has none, while a deck
80
+ // that says nothing is one written before file types existed, and callers with a
81
+ // legacy fallback use it only for that one.
82
+ export function resolveFileTypes(deckDir) {
83
+ const byExt = new Map();
84
+ let anyDeclared = false;
85
+ const add = (types, from) => {
86
+ anyDeclared = true;
87
+ for (const type of types) {
88
+ if (!byExt.has(type.ext))
89
+ byExt.set(type.ext, from ? { ...type, from } : { ...type });
90
+ }
91
+ };
92
+ const own = readEditorConfig(deckDir).fileTypes;
93
+ if (own)
94
+ add(own);
95
+ for (const alias of importAliases(deckDir)) {
96
+ const imported = readEditorConfig(path.join(deckDir, IMPORTS_DIR, alias)).fileTypes;
97
+ if (imported)
98
+ add(imported, alias);
99
+ }
100
+ return anyDeclared ? [...byExt.values()] : null;
101
+ }
package/dist/ide.js CHANGED
@@ -15,6 +15,8 @@ import headlessPkg from "@xterm/headless";
15
15
  import { SerializeAddon } from "@xterm/addon-serialize";
16
16
  import { WebSocketServer } from "ws";
17
17
  import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
18
+ import { readEditorConfig, resolveFileTypes, } from "./editorConfig.js";
19
+ import { UNSUPPORTED_MEDIA } from "./unsupportedMedia.js";
18
20
  import { IMPORT_API_PREFIX, handleImportApi } from "./importBrowse.js";
19
21
  import { readRequestBody, sendJson } from "./httpJson.js";
20
22
  import { envForUserShell, installCliShims } from "./byo-auth.js";
@@ -100,38 +102,34 @@ export const FILES_API_PREFIX = "/__castle/files/";
100
102
  // Directories never surfaced in the file list / never read or written through
101
103
  // the builtin editor: VCS, deck-private state, and dependency trees.
102
104
  const FILES_IGNORE_DIRS = new Set([".git", ".castle", "node_modules", "dist"]);
103
- // Fallback set of kit-owned rich-editor extensions: the known castle rich
104
- // content types across kit variants (.pxart in basic-2d, .drawing in older
105
- // kits). Only used when a kit is present but its getFileKind couldn't be parsed.
106
- // The builtin code editor renders everything else (and everything on a kitless
107
- // deck). Kept here -- one place -- rather than baked into the shell, so a future
108
- // kit manifest can supply it without a shell change.
109
- const FALLBACK_KIT_EDITOR_EXTS = [".scene", ".pxart", ".drawing"];
110
- // Extensions that are always code/text, even if a kit's getFileKind mentions
111
- // them -- these get the builtin editor, never a rich kit editor.
112
- const CODE_TEXT_KINDS = new Set(["code", "text"]);
113
- // Parse the kit's `engine/files.js` getFileKind for extensions it maps to a
114
- // rich (non code/text) kind -- the authoritative, per-deck source of which
115
- // files the kit owns an editor for. Returns null if the file is absent so the
116
- // caller can fall back. Matches lines like:
117
- // if (path.endsWith('.scene')) return 'scene';
118
- function parseKitEditorExtensions(deckDir) {
119
- let src;
120
- try {
121
- src = fs.readFileSync(path.join(deckDir, "engine", "files.js"), "utf8");
122
- }
123
- catch {
124
- return null;
125
- }
126
- const exts = [];
127
- const re = /endsWith\(\s*['"](\.[^'"]+)['"]\s*\)\s*\)\s*return\s*['"]([^'"]+)['"]/g;
128
- for (let m = re.exec(src); m; m = re.exec(src)) {
129
- const [, ext, kind] = m;
130
- if (!CODE_TEXT_KINDS.has(kind) && !exts.includes(ext))
131
- exts.push(ext);
132
- }
133
- return exts;
134
- }
105
+ // Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
106
+ // a huge asset is a problem for the deck long before it is a problem here --
107
+ // refuse it at the door with a message rather than let it land and break `save`.
108
+ const MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
109
+ // Content types for `files/raw`, which serves a deck file as bytes (the shell's
110
+ // image/audio viewers read their file through it rather than through `read`,
111
+ // which is text-only). Anything unlisted is served as opaque bytes -- a viewer
112
+ // that asked for it knows what it is.
113
+ 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",
132
+ };
135
133
  // Files under `imports/` came from another deck. They are readable like any
136
134
  // other deck file -- the engine, editors and pickers all reference them -- but a
137
135
  // deck can't edit them, because editing one would mean editing somebody else's
@@ -204,86 +202,16 @@ function listDeckFiles(deckDir) {
204
202
  out.sort((a, b) => a.localeCompare(b));
205
203
  return out;
206
204
  }
207
- function asFileTypes(value) {
208
- if (!Array.isArray(value))
209
- return undefined;
210
- const out = value.filter((e) => Boolean(e) && typeof e === "object" && typeof e.ext === "string");
211
- return out.length > 0 ? out : undefined;
212
- }
213
- function asStringArray(value) {
214
- if (Array.isArray(value) && value.every((e) => typeof e === "string")) {
215
- return value;
216
- }
217
- return undefined;
218
- }
219
- // Read the deck's `castle.json` `editor` block. Tolerates an absent /
220
- // unparseable file (returns {}). Folds the legacy top-level `editorExtensions`
221
- // into `editor.extensions` for decks saved before the `editor` block existed.
222
- function readEditorConfig(deckDir) {
223
- let data;
224
- try {
225
- data = JSON.parse(fs.readFileSync(path.join(deckDir, "castle.json"), "utf8"));
226
- }
227
- catch {
228
- return {};
229
- }
230
- const editor = data.editor ?? {};
231
- const config = {
232
- initialPanels: Array.isArray(editor.initialPanels)
233
- ? editor.initialPanels
234
- : undefined,
235
- hiddenPaths: asStringArray(editor.hiddenPaths),
236
- visiblePaths: asStringArray(editor.visiblePaths),
237
- fileTypes: asFileTypes(editor.fileTypes),
238
- extensions: asStringArray(editor.extensions) ?? asStringArray(data.editorExtensions),
239
- defaultPlayFile: typeof editor.defaultPlayFile === "string" ? editor.defaultPlayFile : undefined,
240
- };
241
- return config;
242
- }
243
- // Extensions the kit owns a rich editor for. A kitless (bare) deck has no kit
244
- // entry, so this is empty and the builtin editor renders everything. A deck may
245
- // override the default list via `castle.json` `editor.extensions`.
246
- // Where the kit lives for this deck: the deck itself when it holds the kit's
247
- // files, else the import that provides one. A deck built on an imported kit has
248
- // no `main.jsx` of its own -- its entry point is the kit's, at
249
- // `imports/<alias>/main.jsx` -- so looking only in the deck root would read as
250
- // "no kit" and hand every .scene / .pxart to the builtin text editor.
251
- function findKitRoot(deckDir) {
252
- if (fs.existsSync(path.join(deckDir, "main.jsx")))
253
- return deckDir;
254
- const importsDir = path.join(deckDir, IMPORTS_DIR);
255
- let aliases;
256
- try {
257
- aliases = fs.readdirSync(importsDir);
258
- }
259
- catch {
260
- return null;
205
+ // Extensions a kit owns a rich editor for, from the deck's resolved file types
206
+ // -- which say outright which extensions get a kit editor, and which kit
207
+ // declared each one. A deck that declares none and imports none has no kit
208
+ // editors, and the builtin code editor renders everything.
209
+ function kitEditorExtensions(config, fileTypes) {
210
+ if (fileTypes) {
211
+ return fileTypes.filter((t) => t.editor === "kit").map((t) => t.ext);
261
212
  }
262
- for (const alias of aliases.sort()) {
263
- const root = path.join(importsDir, alias);
264
- if (fs.existsSync(path.join(root, "main.jsx")))
265
- return root;
266
- }
267
- return null;
268
- }
269
- function kitEditorExtensions(deckDir) {
270
- const config = readEditorConfig(deckDir);
271
- // Declared file types win: they say outright which extensions the kit edits.
272
- if (config.fileTypes) {
273
- return config.fileTypes.filter((t) => t.editor === "kit").map((t) => t.ext);
274
- }
275
- const configured = config.extensions;
276
- if (configured)
277
- return configured;
278
- const kitRoot = findKitRoot(deckDir);
279
- if (!kitRoot)
280
- return [];
281
- // Prefer the kit's own getFileKind (authoritative per deck); fall back to the
282
- // known rich content types if it couldn't be read/parsed.
283
- const parsed = parseKitEditorExtensions(kitRoot);
284
- if (parsed && parsed.length > 0)
285
- return parsed;
286
- return FALLBACK_KIT_EDITOR_EXTS;
213
+ // A deck saved before file types existed named its kit extensions directly.
214
+ return config.extensions ?? [];
287
215
  }
288
216
  // Apply the deck's `editor.hiddenPaths` / `editor.visiblePaths` globs to a file
289
217
  // list (deck-relative POSIX paths). Only hidden -> drop matches; only visible ->
@@ -325,6 +253,30 @@ function filterImportedFiles(deckDir, imported) {
325
253
  }
326
254
  return out;
327
255
  }
256
+ // Read a raw (binary) request body, refusing anything over `limit`. Uploads are
257
+ // arbitrary device files, so the cap is enforced as bytes arrive rather than
258
+ // after buffering the whole thing.
259
+ function readRequestBuffer(req, limit) {
260
+ return new Promise((resolve, reject) => {
261
+ const chunks = [];
262
+ let size = 0;
263
+ let over = false;
264
+ req.on("data", (c) => {
265
+ size += c.length;
266
+ // Past the cap, keep draining but stop KEEPING the bytes. Destroying the
267
+ // request here would reset the connection and the client would see a
268
+ // network failure instead of the reason it was refused.
269
+ if (size > limit) {
270
+ over = true;
271
+ chunks.length = 0;
272
+ return;
273
+ }
274
+ chunks.push(c);
275
+ });
276
+ req.on("end", () => over ? reject(new RangeError("too large")) : resolve(Buffer.concat(chunks)));
277
+ req.on("error", reject);
278
+ });
279
+ }
328
280
  function withJsonBody(req, res, handler) {
329
281
  void (async () => {
330
282
  let body;
@@ -367,11 +319,16 @@ function handleFilesWrite(deckDir, req, res) {
367
319
  });
368
320
  }
369
321
  // Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
370
- // newly-made folder show in the curated Files tree. Only touches a deck that is
322
+ // newly-made folder show in the curated Files tree.
323
+ function ensureVisiblePath(deckDir, rel) {
324
+ return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
325
+ }
326
+ // Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
371
327
  // ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
372
328
  // everything is visible, and adding a glob would wrongly start hiding things.
373
- // No-op if an existing glob already covers the folder. Returns whether it wrote.
374
- function ensureVisiblePath(deckDir, rel) {
329
+ // No-op if an existing glob already covers `probe`, a path the new glob would
330
+ // match. Returns whether it wrote.
331
+ function ensureVisibleGlob(deckDir, glob, probe) {
375
332
  const file = path.join(deckDir, "castle.json");
376
333
  let data;
377
334
  try {
@@ -385,10 +342,9 @@ function ensureVisiblePath(deckDir, rel) {
385
342
  : null;
386
343
  if (!visible || visible.length === 0)
387
344
  return false; // not curated -> all visible
388
- const glob = `${rel}/**`;
389
345
  if (visible.includes(glob))
390
346
  return false;
391
- if (picomatch(visible)(`${rel}/__probe__`))
347
+ if (picomatch(visible)(probe))
392
348
  return false; // already covered
393
349
  visible.push(glob);
394
350
  data.editor.visiblePaths = visible;
@@ -412,6 +368,142 @@ function handleFilesMkdir(deckDir, req, res) {
412
368
  sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
413
369
  });
414
370
  }
371
+ // Upload one file from the user's device: `POST files/upload?path=<rel>` with
372
+ // the file's bytes as the whole body (no multipart -- the client sends one
373
+ // request per file, so the path is all the metadata there is to carry). Refuses
374
+ // to clobber: the client picks a free name first and a collision here means
375
+ // something else took it in between.
376
+ //
377
+ // A curated deck (non-empty visiblePaths) also gets the destination registered,
378
+ // the same way mkdir does -- otherwise an upload lands on disk and is invisible
379
+ // in the very panel that put it there.
380
+ function handleFilesUpload(deckDir, req, res) {
381
+ const url = new URL(req.url ?? "/", "http://localhost");
382
+ const target = resolveDeckPath(deckDir, url.searchParams.get("path"), {
383
+ mutation: true,
384
+ });
385
+ if (!target.ok)
386
+ return sendJson(res, 400, { error: target.error });
387
+ const tooBig = () => {
388
+ const mb = Math.round(MAX_UPLOAD_BYTES / (1024 * 1024));
389
+ sendJson(res, 413, {
390
+ error: `${path.posix.basename(target.rel)} is larger than ${mb} MB.`,
391
+ });
392
+ };
393
+ // An oversized body is drained and refused at its END, not the moment the cap
394
+ // is crossed: answering mid-upload closes the connection under a client that
395
+ // is still writing, and it sees a network error instead of the reason.
396
+ void readRequestBuffer(req, MAX_UPLOAD_BYTES).then((bytes) => {
397
+ if (fs.existsSync(target.abs)) {
398
+ return sendJson(res, 409, { error: `Already exists: ${target.rel}` });
399
+ }
400
+ try {
401
+ fs.mkdirSync(path.dirname(target.abs), { recursive: true });
402
+ fs.writeFileSync(target.abs, bytes);
403
+ }
404
+ catch (err) {
405
+ return sendFailure(res, "upload", target.rel, err);
406
+ }
407
+ const dir = path.posix.dirname(target.rel);
408
+ const visiblePathAdded = dir === "."
409
+ ? ensureVisibleGlob(deckDir, target.rel, target.rel)
410
+ : ensureVisiblePath(deckDir, dir);
411
+ sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
412
+ }, (err) => {
413
+ if (err instanceof RangeError)
414
+ return tooBig();
415
+ sendFailure(res, "upload", target.rel, err);
416
+ });
417
+ }
418
+ // Serve a deck file as bytes. `read` returns UTF-8 text in JSON, which mangles
419
+ // anything binary; the shell's image/audio viewers read through this instead.
420
+ function handleFilesRaw(deckDir, req, res) {
421
+ const url = new URL(req.url ?? "/", "http://localhost");
422
+ const resolved = resolveDeckPath(deckDir, url.searchParams.get("path"));
423
+ if (!resolved.ok)
424
+ return sendJson(res, 400, { error: resolved.error });
425
+ let stat;
426
+ try {
427
+ stat = fs.statSync(resolved.abs);
428
+ }
429
+ catch {
430
+ return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
431
+ }
432
+ if (!stat.isFile()) {
433
+ return sendJson(res, 404, { error: `Not a file: ${resolved.rel}` });
434
+ }
435
+ const headers = {
436
+ "content-type": RAW_MIME[path.extname(resolved.abs).toLowerCase()] ??
437
+ "application/octet-stream",
438
+ "cache-control": "no-store",
439
+ // Seeking an <audio>/<video> is a range request; without this the browser
440
+ // won't scrub (it re-requests from 0 and the playhead snaps back).
441
+ "accept-ranges": "bytes",
442
+ };
443
+ const range = parseByteRange(req.headers.range, stat.size);
444
+ if (range === "unsatisfiable") {
445
+ res.writeHead(416, { "content-range": `bytes */${stat.size}` }).end();
446
+ return;
447
+ }
448
+ if (!range) {
449
+ res.writeHead(200, { ...headers, "content-length": String(stat.size) });
450
+ fs.createReadStream(resolved.abs).pipe(res);
451
+ return;
452
+ }
453
+ res.writeHead(206, {
454
+ ...headers,
455
+ "content-length": String(range.end - range.start + 1),
456
+ "content-range": `bytes ${range.start}-${range.end}/${stat.size}`,
457
+ });
458
+ fs.createReadStream(resolved.abs, { start: range.start, end: range.end }).pipe(res);
459
+ }
460
+ // A single `Range: bytes=<start>-<end>` (the only form a media element sends).
461
+ // null = serve the whole file; 'unsatisfiable' = a range past the end.
462
+ function parseByteRange(header, size) {
463
+ const match = /^bytes=(\d*)-(\d*)$/.exec((header ?? "").trim());
464
+ if (!match || size === 0)
465
+ return null;
466
+ const [, rawStart, rawEnd] = match;
467
+ if (rawStart === "" && rawEnd === "")
468
+ return null;
469
+ // `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);
472
+ if (!Number.isFinite(start) || start > end || start >= size)
473
+ return "unsatisfiable";
474
+ return { start, end };
475
+ }
476
+ // A NUL byte near the start is the practical test for "not text": no text file
477
+ // a deck holds has one, and every binary format this is likely to meet does.
478
+ function isBinary(bytes) {
479
+ return bytes.subarray(0, 8192).includes(0);
480
+ }
481
+ // Copy a file byte-for-byte. The Files panel's Duplicate goes through this
482
+ // rather than read-then-write, which would corrupt anything binary.
483
+ function handleFilesCopy(deckDir, req, res) {
484
+ withJsonBody(req, res, (body) => {
485
+ const from = resolveDeckPath(deckDir, body.from);
486
+ if (!from.ok)
487
+ return sendJson(res, 400, { error: from.error });
488
+ const to = resolveDeckPath(deckDir, body.to, { mutation: true });
489
+ if (!to.ok)
490
+ return sendJson(res, 400, { error: to.error });
491
+ if (!fs.existsSync(from.abs)) {
492
+ return sendJson(res, 404, { error: `Not found: ${from.rel}` });
493
+ }
494
+ if (fs.existsSync(to.abs)) {
495
+ return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
496
+ }
497
+ try {
498
+ fs.mkdirSync(path.dirname(to.abs), { recursive: true });
499
+ fs.copyFileSync(from.abs, to.abs);
500
+ sendJson(res, 200, { ok: true, path: to.rel });
501
+ }
502
+ catch (err) {
503
+ sendFailure(res, "copy", from.rel, err);
504
+ }
505
+ });
506
+ }
415
507
  // True when two paths resolve to the same underlying file (same inode+device) --
416
508
  // e.g. the source and target of a case-only rename on a case-insensitive FS.
417
509
  function isSameFile(a, b) {
@@ -475,15 +567,23 @@ function handleFilesApi(deckDir, req, res, reqPath) {
475
567
  const action = reqPath.slice(FILES_API_PREFIX.length);
476
568
  if (action === "info") {
477
569
  const config = readEditorConfig(deckDir);
570
+ // File types come from the deck AND its imports (a kit is an imported deck,
571
+ // so importing one is what gives a deck scenes and drawings); everything
572
+ // else in the block is the deck's own.
573
+ const fileTypes = resolveFileTypes(deckDir);
478
574
  sendJson(res, 200, {
479
575
  // Stable per-deck id for client-side layout persistence (localStorage key).
480
576
  deckId: path.resolve(deckDir),
481
- kitEditorExtensions: kitEditorExtensions(deckDir),
482
- fileTypes: config.fileTypes ?? null,
577
+ kitEditorExtensions: kitEditorExtensions(config, fileTypes),
578
+ fileTypes,
483
579
  defaultPlayFile: config.defaultPlayFile ?? null,
484
580
  initialPanels: config.initialPanels ?? null,
485
581
  hiddenPaths: config.hiddenPaths ?? [],
486
582
  visiblePaths: config.visiblePaths ?? [],
583
+ // Formats the platform knows won't survive publishing. Sent alongside the
584
+ // deck's own types rather than merged into them: these are not things the
585
+ // deck understands, they are things this CLI refuses to let it ship.
586
+ unsupportedMedia: UNSUPPORTED_MEDIA,
487
587
  });
488
588
  return true;
489
589
  }
@@ -549,14 +649,31 @@ function handleFilesApi(deckDir, req, res, reqPath) {
549
649
  if (!resolved.ok)
550
650
  return (sendJson(res, 400, { error: resolved.error }), true);
551
651
  try {
552
- const contents = fs.readFileSync(resolved.abs, "utf8");
553
- sendJson(res, 200, { path: resolved.rel, contents });
652
+ const bytes = fs.readFileSync(resolved.abs);
653
+ // Binary files (an uploaded image, a sound) come back EMPTY and flagged.
654
+ // Decoding one as UTF-8 mangles it, and the code editor saves what it
655
+ // holds -- opening a .png would quietly destroy it on the first keystroke.
656
+ if (isBinary(bytes)) {
657
+ sendJson(res, 200, {
658
+ path: resolved.rel,
659
+ contents: "",
660
+ binary: true,
661
+ size: bytes.length,
662
+ });
663
+ }
664
+ else {
665
+ sendJson(res, 200, { path: resolved.rel, contents: bytes.toString("utf8") });
666
+ }
554
667
  }
555
668
  catch {
556
669
  sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
557
670
  }
558
671
  return true;
559
672
  }
673
+ if (action === "copy") {
674
+ handleFilesCopy(deckDir, req, res);
675
+ return true;
676
+ }
560
677
  if (action === "write") {
561
678
  handleFilesWrite(deckDir, req, res);
562
679
  return true;
@@ -573,6 +690,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
573
690
  handleFilesMkdir(deckDir, req, res);
574
691
  return true;
575
692
  }
693
+ if (action === "upload") {
694
+ handleFilesUpload(deckDir, req, res);
695
+ return true;
696
+ }
697
+ if (action === "raw") {
698
+ handleFilesRaw(deckDir, req, res);
699
+ return true;
700
+ }
576
701
  return (sendJson(res, 404, { error: `Unknown files action: ${action}` }),
577
702
  true);
578
703
  }
package/dist/imports.d.ts CHANGED
@@ -35,6 +35,12 @@ export declare function addImport(dir: string, options?: {
35
35
  deckRef?: string;
36
36
  alias?: string;
37
37
  }): Promise<void>;
38
+ export interface AutoUpdatedImport {
39
+ alias: string;
40
+ from: string;
41
+ to: string;
42
+ }
43
+ export declare function autoUpdateImports(deckDir: string): Promise<AutoUpdatedImport[]>;
38
44
  export declare function updateImport(dir: string, options?: {
39
45
  alias?: string;
40
46
  check?: boolean;