castle-web-cli 0.4.122 → 0.4.124

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 (60) hide show
  1. package/dist/agent-prompts.js +6 -2
  2. package/dist/castleJson.d.ts +2 -0
  3. package/dist/castleJson.js +39 -0
  4. package/dist/diffText.d.ts +14 -0
  5. package/dist/diffText.js +138 -0
  6. package/dist/headlessCover.d.ts +13 -0
  7. package/dist/headlessCover.js +99 -0
  8. package/dist/ide.d.ts +2 -1
  9. package/dist/ide.js +100 -43
  10. package/dist/imports.js +42 -2
  11. package/dist/index.js +49 -11
  12. package/dist/init.d.ts +1 -0
  13. package/dist/init.js +45 -1
  14. package/dist/localPaths.d.ts +1 -0
  15. package/dist/localPaths.js +5 -0
  16. package/dist/preview.d.ts +0 -1
  17. package/dist/preview.js +0 -58
  18. package/dist/save-deck.js +52 -1
  19. package/dist/serve.js +16 -0
  20. package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
  21. package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
  22. package/dist/shell/assets/index-BfOPkSej.css +1 -0
  23. package/dist/shell/assets/index-u0nYFqbF.js +434 -0
  24. package/dist/shell/index.html +2 -2
  25. package/dist/versionStore.d.ts +54 -0
  26. package/dist/versionStore.js +281 -0
  27. package/dist/versions.d.ts +82 -0
  28. package/dist/versions.js +446 -0
  29. package/kits/physics-2d/CLAUDE.md +2 -2
  30. package/kits/physics-2d/castle.json +10 -2
  31. package/kits/physics-2d/docs/pxart-format.md +33 -26
  32. package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
  33. package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
  34. package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
  35. package/kits/physics-2d/editors/pathOverlay.js +1 -1
  36. package/kits/physics-2d/editors/pathTools.js +9 -1
  37. package/kits/physics-2d/editors/pixelGeometry.js +14 -13
  38. package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
  39. package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
  40. package/kits/physics-2d/editors/pxArtTools.js +3 -43
  41. package/kits/physics-2d/editors/styleEditor.module.css +105 -0
  42. package/kits/physics-2d/editors/styleTheme.js +16 -0
  43. package/kits/physics-2d/engine/files.js +2 -1
  44. package/kits/physics-2d/engine/liveReload.js +4 -3
  45. package/kits/physics-2d/engine/palettes.js +636 -0
  46. package/kits/physics-2d/engine/pxart.js +6 -6
  47. package/kits/physics-2d/engine/svgImport.js +1056 -0
  48. package/kits/physics-2d/engine/ui.jsx +2 -0
  49. package/kits/physics-2d/engine/ui.module.css +54 -9
  50. package/kits/physics-2d/package-lock.json +1 -1
  51. package/kits/physics-2d/package.json +1 -0
  52. package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
  53. package/kits/physics-2d/scripts/draw.mjs +5 -3
  54. package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
  55. package/kits/physics-2d/scripts/palette.mjs +10 -0
  56. package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
  57. package/kits/physics-2d/theme.style +3 -0
  58. package/package.json +1 -1
  59. package/dist/shell/assets/index-BkJ87APM.css +0 -1
  60. package/dist/shell/assets/index-DXBpj3-y.js +0 -434
package/dist/index.js CHANGED
@@ -9,7 +9,8 @@ import { addImport, updateImport } from './imports.js';
9
9
  import { listDecks } from './list-decks.js';
10
10
  import { getCliVersion, init } from './init.js';
11
11
  import { install } from './install.js';
12
- import { connectWS, savePreviewImage, takeScreenshot } from './preview.js';
12
+ import { connectWS, takeScreenshot } from './preview.js';
13
+ import { listVersions, restoreVersion, saveVersion, showVersion } from './versions.js';
13
14
  const args = process.argv.slice(2);
14
15
  const command = args[0];
15
16
  const FLAGS_WITH_VALUES = new Set([
@@ -23,10 +24,19 @@ const FLAGS_WITH_VALUES = new Set([
23
24
  '--visibility',
24
25
  '--as',
25
26
  '--kind',
27
+ '--dir',
28
+ '--message',
29
+ '-m',
26
30
  ]);
31
+ // `--foo` is always a flag; a single dash only when it is one we know (`-m`).
32
+ // Anything else starting with `-` is a positional -- a path can begin with one,
33
+ // and so can an opaque version id.
34
+ function isFlag(arg) {
35
+ return arg.startsWith('--') || FLAGS_WITH_VALUES.has(arg);
36
+ }
27
37
  function findPositionalDir() {
28
38
  for (let i = 1; i < args.length; i++) {
29
- if (args[i].startsWith('--')) {
39
+ if (isFlag(args[i])) {
30
40
  if (FLAGS_WITH_VALUES.has(args[i]))
31
41
  i++;
32
42
  continue;
@@ -41,7 +51,7 @@ function findPositionalDir() {
41
51
  function readPositionals() {
42
52
  const out = [];
43
53
  for (let i = 1; i < args.length; i++) {
44
- if (args[i].startsWith('--')) {
54
+ if (isFlag(args[i])) {
45
55
  if (FLAGS_WITH_VALUES.has(args[i]))
46
56
  i++;
47
57
  continue;
@@ -81,13 +91,16 @@ function usage() {
81
91
  castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
82
92
  castle-web restart [--port PORT]
83
93
  castle-web screenshot [--out FILE] [--port PORT]
84
- castle-web save-preview-image [dir] [--port PORT] [--no-restart]
85
94
  castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
86
95
  castle-web get-deck [dir] [--deck-id ID] [--force] (replaces the source if the deck is already there)
87
96
  castle-web list-decks [dir] [--kind mine|saved|kits] (decks this one could import; ids are what add-import takes)
88
97
  castle-web add-import <deckId|url> [dir] [--as ALIAS] (adds another deck as a read-only dependency in imports/)
89
98
  castle-web update-import [alias] [dir] [--check] [--revert] (re-fetches imports; no alias means all)
90
99
  castle-web install [dir]
100
+ castle-web save-version [-m MESSAGE] [paths...] [--dir DIR] (no paths saves the whole deck)
101
+ castle-web list-versions [version] [--dir DIR] (defaults to the latest version)
102
+ castle-web show-version <version> [--dir DIR] (diffs of the files that version changed)
103
+ castle-web restore-version <version> [--force] [--dir DIR]
91
104
  castle-web login
92
105
  castle-web --version
93
106
 
@@ -173,6 +186,38 @@ async function main() {
173
186
  await install(findPositionalDir());
174
187
  break;
175
188
  }
189
+ // The version commands spend their positionals on their own arguments --
190
+ // paths to save, a version to list from or restore to -- so the deck dir is
191
+ // `--dir`, defaulting to the cwd.
192
+ case 'save-version': {
193
+ saveVersion(getFlagValue('--dir') ?? '.', {
194
+ message: getFlagValue('-m') ?? getFlagValue('--message'),
195
+ paths: readPositionals(),
196
+ });
197
+ break;
198
+ }
199
+ case 'list-versions': {
200
+ listVersions(getFlagValue('--dir') ?? '.', readPositionals()[0]);
201
+ break;
202
+ }
203
+ case 'show-version': {
204
+ const target = readPositionals()[0];
205
+ if (!target) {
206
+ console.error('Usage: castle-web show-version <version>');
207
+ process.exit(1);
208
+ }
209
+ showVersion(getFlagValue('--dir') ?? '.', target);
210
+ break;
211
+ }
212
+ case 'restore-version': {
213
+ const target = readPositionals()[0];
214
+ if (!target) {
215
+ console.error('Usage: castle-web restore-version <version> [--force]');
216
+ process.exit(1);
217
+ }
218
+ restoreVersion(getFlagValue('--dir') ?? '.', target, { force: hasFlag('--force') });
219
+ break;
220
+ }
176
221
  case 'restart': {
177
222
  const dir = findPositionalDir();
178
223
  const wsPort = getWsPort(dir);
@@ -196,13 +241,6 @@ async function main() {
196
241
  console.log(`Saved ${outFile}`);
197
242
  return;
198
243
  }
199
- case 'save-preview-image': {
200
- const dir = findPositionalDir();
201
- const wsPort = getWsPort(dir);
202
- const noRestart = args.includes('--no-restart');
203
- await savePreviewImage(dir, wsPort, noRestart);
204
- return;
205
- }
206
244
  case 'login':
207
245
  await login();
208
246
  break;
package/dist/init.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare function isPristineBareDeck(dir: string): boolean;
9
9
  export declare function adoptImportEntry(deckDir: string, alias: string, mainFile: string): void;
10
10
  export declare function getCliVersion(): string;
11
11
  export declare function writeStarterScene(projectDir: string, kitDir: string, alias: string): void;
12
+ export declare function writeStarterTheme(projectDir: string, kitDir: string): void;
12
13
  export declare function init(dir: string, opts?: {
13
14
  kit?: string;
14
15
  serve?: boolean;
package/dist/init.js CHANGED
@@ -6,6 +6,7 @@ import { deckMainFile, deckStarterScene } from './castleJson.js';
6
6
  import { IMPORTS_DIR, lockImportTree } from './imports.js';
7
7
  import { getCliEntryPath, getKitsDir, getRepoRoot, getSdkPackagePath, toPosixPath, } from './localPaths.js';
8
8
  import { serve } from './serve.js';
9
+ import { createVersion } from './versions.js';
9
10
  const INDEX_HTML = `<!DOCTYPE html>
10
11
  <html>
11
12
  <head>
@@ -37,7 +38,7 @@ const DEFAULT_KIT = 'physics-2d';
37
38
  // Registry version of castle-web-sdk to inject when scaffolding from a
38
39
  // globally-installed castle-web (not from inside the workspace). Bumped
39
40
  // alongside cli/sdk version bumps.
40
- const PUBLISHED_SDK_VERSION = '0.4.13';
41
+ const PUBLISHED_SDK_VERSION = '0.4.14';
41
42
  // The account the first-party kits are (to be) published under, so a kit
42
43
  // imported from the CLI's copy is named the same as one fetched from the server.
43
44
  const KIT_AUTHOR = 'castle';
@@ -280,6 +281,19 @@ export function writeStarterScene(projectDir, kitDir, alias) {
280
281
  fs.mkdirSync(path.join(projectDir, 'scenes'), { recursive: true });
281
282
  writeJsonFile(path.join(projectDir, 'scenes', 'main.scene'), scene ? rewrite(scene) : { name: 'Main', actors: [] });
282
283
  }
284
+ // A kit may ship a starter `theme.style` (deck-level styling, e.g. the color
285
+ // palette). Like the starter scene, it is deck content: the engine reads it
286
+ // from the DECK root, and it is the creator's to edit -- so it gets seeded once
287
+ // at scaffold time rather than living read-only in the import. Copied verbatim
288
+ // (its values are palette ids/hexes, never kit file paths, so no ref
289
+ // rewriting). Exported for the same two scaffold paths as writeStarterScene.
290
+ export function writeStarterTheme(projectDir, kitDir) {
291
+ const src = path.join(kitDir, 'theme.style');
292
+ const dest = path.join(projectDir, 'theme.style');
293
+ if (!fs.existsSync(src) || fs.existsSync(dest))
294
+ return;
295
+ fs.copyFileSync(src, dest);
296
+ }
283
297
  function makeImportedKitClaudeMd(alias) {
284
298
  return `# Castle deck
285
299
 
@@ -353,6 +367,7 @@ function scaffoldFromKitImport(kit, projectDir) {
353
367
  const title = typeof kitConfig.title === 'string' ? kitConfig.title : kit;
354
368
  writeDeckIndexHtml(projectDir, alias, title, deckMainFile(kitDir));
355
369
  writeStarterScene(projectDir, kitDir, alias);
370
+ writeStarterTheme(projectDir, kitDir);
356
371
  // The deck's own castle.json: the kit's editor config (panel layout, file
357
372
  // filters) as a starting point -- it is the deck's to edit from here -- plus
358
373
  // the import pin.
@@ -386,6 +401,34 @@ function scaffoldFromKitImport(kit, projectDir) {
386
401
  appendCommonInstructions(projectDir);
387
402
  lockImportTree(importDir);
388
403
  }
404
+ // The scaffold as version 1, so a creator can always get back to the pristine
405
+ // deck. AFTER the install: it rewrites package.json and writes a lockfile, so a
406
+ // version taken before it describes a tree that never existed on disk, and a
407
+ // later restore would fight the package manager.
408
+ //
409
+ // This is the ordinary save path with no parent -- `parentId: null` and every
410
+ // file as an addition -- so the store has no special case for it. It composes
411
+ // with the no-op rule for free: `save-version` straight after init records
412
+ // nothing, so a fresh deck has exactly one version.
413
+ //
414
+ // The message is the only place a deck's kit lineage is recorded at all.
415
+ // Existing decks get none of this: their history starts at their first save,
416
+ // because a baseline synthesized from mid-work content would be labelled
417
+ // pristine while being nothing of the sort.
418
+ function saveInitialVersion(projectDir, kit) {
419
+ try {
420
+ const { version } = createVersion(projectDir, {
421
+ message: kit ? `New deck from ${kit}` : 'New deck',
422
+ });
423
+ if (version)
424
+ console.log(`Saved version ${version.id} — the deck as scaffolded.`);
425
+ }
426
+ catch (e) {
427
+ // A deck that scaffolded but failed to record its first version is still a
428
+ // deck. Losing the scaffold over it would be the wrong trade.
429
+ console.log(`Could not save the initial version: ${e instanceof Error ? e.message : String(e)}`);
430
+ }
431
+ }
389
432
  export async function init(dir, opts = {}) {
390
433
  const projectDir = path.resolve(dir);
391
434
  if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
@@ -423,6 +466,7 @@ export async function init(dir, opts = {}) {
423
466
  catch {
424
467
  console.error('dependency install failed; re-run `pnpm install` (or `npm install`) in the deck.');
425
468
  }
469
+ saveInitialVersion(projectDir, bare ? null : kit);
426
470
  const autoServe = opts.serve !== false;
427
471
  if (autoServe && installed) {
428
472
  // Call serve() with detach so init returns once the server is up. serve()
@@ -3,4 +3,5 @@ export declare function getRepoRoot(): string;
3
3
  export declare function getCliEntryPath(): string;
4
4
  export declare function getSdkPackagePath(): string;
5
5
  export declare function getKitsDir(): string;
6
+ export declare const COVER_FILE = "preview.png";
6
7
  export declare function toPosixPath(filepath: string): string;
@@ -28,6 +28,11 @@ export function getKitsDir() {
28
28
  return bundled;
29
29
  return path.join(getRepoRoot(), 'kits');
30
30
  }
31
+ // The deck's local cover image, deck-relative. Lives here rather than in the
32
+ // serve that writes it, because the version code needs to know about it too and
33
+ // `ide.ts` already imports that -- naming it here keeps the two from importing
34
+ // each other.
35
+ export const COVER_FILE = 'preview.png';
31
36
  export function toPosixPath(filepath) {
32
37
  return filepath.split(path.sep).join('/');
33
38
  }
package/dist/preview.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  import type { WebSocket as WSClient } from 'ws';
2
2
  export declare function connectWS(wsPort: number): Promise<WSClient>;
3
3
  export declare function takeScreenshot(ws: WSClient): Promise<string>;
4
- export declare function savePreviewImage(dir: string, wsPort: number, noRestart?: boolean): Promise<void>;
package/dist/preview.js CHANGED
@@ -1,7 +1,3 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import * as api from './api.js';
4
- import * as config from './config.js';
5
1
  export async function connectWS(wsPort) {
6
2
  const { default: WS } = await import('ws');
7
3
  return new Promise((resolve, reject) => {
@@ -45,57 +41,3 @@ export function takeScreenshot(ws) {
45
41
  ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
46
42
  });
47
43
  }
48
- function waitForRestart(ws) {
49
- return new Promise((resolve) => {
50
- ws.send(JSON.stringify({ type: 'restart' }));
51
- setTimeout(resolve, 2000);
52
- });
53
- }
54
- // The shared declaration (castleJson.ts) rather than a local copy of the two
55
- // fields this file reads, so it can't drift from what the rest of the CLI
56
- // believes castle.json contains.
57
- function readCastleJson(projectDir) {
58
- const castleJsonPath = path.join(projectDir, 'castle.json');
59
- if (!fs.existsSync(castleJsonPath))
60
- return null;
61
- return JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
62
- }
63
- async function captureAndUpload(projectDir, ws, castleJson) {
64
- // A deck has no cardId until its first save, and the cover IS that card's
65
- // background -- so there is nothing to set it on yet. Both callers reach here
66
- // right after a save, so this is the never-saved edge, not the normal path.
67
- // Reported back so a caller doesn't record a cover that was never set.
68
- const { cardId } = castleJson;
69
- if (!cardId) {
70
- ws.close();
71
- console.log('Preview skipped: save the deck first.');
72
- return false;
73
- }
74
- const base64 = await takeScreenshot(ws);
75
- ws.close();
76
- fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
77
- console.log('Saved preview.png');
78
- const file = await api.uploadBase64(base64, 'preview.png');
79
- await api.updateCardCustomBackgroundImage(cardId, file.fileId);
80
- console.log('Set deck preview image.');
81
- return true;
82
- }
83
- export async function savePreviewImage(dir, wsPort, noRestart = false) {
84
- const projectDir = path.resolve(dir);
85
- const castleJson = readCastleJson(projectDir);
86
- if (!castleJson) {
87
- console.error('No castle.json found. Save the deck first.');
88
- process.exit(1);
89
- }
90
- if (!config.getToken()) {
91
- console.error('Not logged in. Run `castle-web login` first.');
92
- process.exit(1);
93
- }
94
- const ws = await connectWS(wsPort);
95
- if (!noRestart)
96
- await waitForRestart(ws);
97
- // Asking for this by name is choosing a cover, so it goes to the card's
98
- // CUSTOM slot -- the one that takes precedence over the cover a save
99
- // publishes. Clearing it is an editor action (Deck settings -> Clear cover).
100
- await captureAndUpload(projectDir, ws, castleJson);
101
- }
package/dist/save-deck.js CHANGED
@@ -79,8 +79,59 @@ async function uploadSource(projectDir, deckId) {
79
79
  //
80
80
  // Never fatal: a deck that saves but whose cover upload failed is a deck that
81
81
  // saved. Losing the push over a thumbnail would be the wrong trade.
82
+ // The deck's own serve, if one is running -- the URL a headless cover capture
83
+ // loads. No serve means no deck to photograph, which is a normal state (a save
84
+ // from a script), not an error.
85
+ function runningServeUrl(projectDir) {
86
+ try {
87
+ const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
88
+ const port = JSON.parse(raw).port;
89
+ return typeof port === 'number' ? `http://localhost:${port}` : null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ // Photograph the running deck with a real browser, into a scratch file.
96
+ //
97
+ // This OUTRANKS the editor's `preview.png`, and deliberately: both are the
98
+ // AUTOMATIC cover (a creator's hand-picked one lives in the card's custom slot
99
+ // and outranks both), so the question is only which automatic capture is better
100
+ // -- and the in-page one silently drops a WebGL canvas that lacks
101
+ // `preserveDrawingBuffer`, plus anything using `filter`/`clip-path`/`mask`. A
102
+ // browser compositing its own frame has no such list.
103
+ //
104
+ // `preview.png` does have one real advantage -- it is whatever the creator last
105
+ // had on screen, where this is a cold load -- so it stays the fallback. But a
106
+ // cover that is missing the game beats a cover of the opening seconds only if
107
+ // you never look at it, and nobody looks at an unattended capture. Writing to a
108
+ // scratch file rather than over `preview.png` keeps that fallback intact.
109
+ async function captureAutomaticCover(projectDir) {
110
+ const serveUrl = runningServeUrl(projectDir);
111
+ if (!serveUrl)
112
+ return null;
113
+ const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
114
+ const { captureCoverHeadless } = await import('./headlessCover.js');
115
+ const result = await captureCoverHeadless({
116
+ serveUrl,
117
+ outPath,
118
+ onProgress: (message) => console.log(message),
119
+ });
120
+ if (!result.ok) {
121
+ // Never fatal, same as the upload below: a deck that saved without a cover
122
+ // is a deck that saved.
123
+ console.log(`Cover capture skipped: ${result.error}`);
124
+ return null;
125
+ }
126
+ return outPath;
127
+ }
82
128
  async function uploadLocalCover(projectDir) {
83
- const coverPath = path.join(projectDir, COVER_FILE);
129
+ const captured = await captureAutomaticCover(projectDir);
130
+ const coverPath = captured ?? path.join(projectDir, COVER_FILE);
131
+ if (captured)
132
+ console.log('Captured a cover from the running deck.');
133
+ else if (fs.existsSync(coverPath))
134
+ console.log('Using the cover captured in the editor.');
84
135
  if (!fs.existsSync(coverPath))
85
136
  return null;
86
137
  try {
package/dist/serve.js CHANGED
@@ -239,6 +239,22 @@ export async function serve(dir, options = {}) {
239
239
  console.warn(`auto-update: skipped -- ${e instanceof Error ? e.message : String(e)}`);
240
240
  });
241
241
  }
242
+ // Start pulling the headless browser now, in the background.
243
+ //
244
+ // `save-deck` photographs the deck with it to make the deck's cover, and the
245
+ // binary is a ~250MB one-time download. Left lazy, that download lands inside
246
+ // the creator's first "Push to Castle" -- the one moment they are watching a
247
+ // spinner and least want an unexplained wait. A serve means someone is editing
248
+ // a deck they will probably push, so this is the cheapest place to get ahead
249
+ // of it.
250
+ //
251
+ // Fire-and-forget and single-flight: it shares one download with the agent's
252
+ // playtest tool, so this is free when that already pulled it, and every
253
+ // failure is swallowed -- the capture re-detects and reports properly at the
254
+ // point it actually matters.
255
+ void import('./native/playtest-browser.js')
256
+ .then((m) => m.createPlaytestBrowserManager().prewarm())
257
+ .catch(() => undefined);
242
258
  // Close stdin when not attached to a TTY so the process can be cleanly
243
259
  // backgrounded with `&` (otherwise stdin reads can block or hold the parent).
244
260
  if (!process.stdin.isTTY) {