castle-web-cli 0.4.169 → 0.4.171

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 (106) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +70 -24
  4. package/dist/atomicFile.d.ts +29 -0
  5. package/dist/atomicFile.js +120 -0
  6. package/dist/castle-host/host.js +108 -0
  7. package/dist/get-deck.js +2 -11
  8. package/dist/headlessCover.d.ts +4 -0
  9. package/dist/headlessCover.js +7 -5
  10. package/dist/ide.d.ts +10 -0
  11. package/dist/ide.js +19 -11
  12. package/dist/index.js +10 -6
  13. package/dist/init.d.ts +1 -1
  14. package/dist/init.js +11 -1
  15. package/dist/remix.js +2 -14
  16. package/dist/save-deck.d.ts +3 -0
  17. package/dist/save-deck.js +40 -73
  18. package/dist/saveCover.d.ts +18 -0
  19. package/dist/saveCover.js +61 -0
  20. package/dist/serve.js +18 -5
  21. package/dist/serveSecurity.d.ts +11 -1
  22. package/dist/serveSecurity.js +38 -4
  23. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  24. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  25. package/dist/shell/index.html +3 -3
  26. package/dist/versionStore.js +13 -4
  27. package/kits/base/CLAUDE.md +9 -0
  28. package/kits/base/castle.json +16 -11
  29. package/kits/base/sdk/README.md +83 -2
  30. package/kits/base/sdk/commands.d.ts +37 -1
  31. package/kits/base/sdk/commands.js +9 -0
  32. package/kits/base/sdk/lifecycle.d.ts +20 -0
  33. package/kits/base/sdk/lifecycle.js +7 -1
  34. package/kits/base/sdk/resumeState.d.ts +2 -0
  35. package/kits/base/sdk/resumeState.js +84 -0
  36. package/kits/base/sdk/runtime.js +65 -0
  37. package/kits/base/sdk/transport.d.ts +3 -0
  38. package/kits/base/sdk/transport.js +39 -5
  39. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  40. package/kits/base/sdk/unloadHandshake.js +47 -0
  41. package/kits/base/sdk/user.d.ts +4 -0
  42. package/kits/base/sdk/user.js +36 -1
  43. package/kits/multiplayer-2d/CLAUDE.md +20 -9
  44. package/kits/multiplayer-2d/castle.json +4 -4
  45. package/kits/multiplayer-2d/code/server/players.js +27 -6
  46. package/kits/multiplayer-2d/code/server/world.js +24 -1
  47. package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
  48. package/kits/multiplayer-2d/package-lock.json +2 -4
  49. package/kits/multiplayer-2d/package.json +1 -0
  50. package/kits/multiplayer-3d/CLAUDE.md +28 -9
  51. package/kits/multiplayer-3d/castle.json +5 -5
  52. package/kits/multiplayer-3d/code/server/players.js +54 -5
  53. package/kits/multiplayer-3d/code/server/world.js +45 -1
  54. package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
  55. package/kits/multiplayer-3d/package-lock.json +2 -4
  56. package/kits/multiplayer-3d/package.json +1 -0
  57. package/kits/physics-2d/CLAUDE.md +75 -43
  58. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  59. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  60. package/kits/physics-2d/castle.json +6 -6
  61. package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
  62. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  63. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  64. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  65. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  66. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  67. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  68. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  69. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  70. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  71. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  72. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  73. package/kits/physics-2d/engine/icons.js +56 -0
  74. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  75. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  76. package/kits/physics-2d/engine/physics/controls.js +76 -12
  77. package/kits/physics-2d/engine/scene.js +47 -9
  78. package/kits/physics-2d/engine/ui.jsx +13 -125
  79. package/kits/physics-2d/engine/ui.module.css +53 -28
  80. package/kits/physics-2d/package-lock.json +0 -29
  81. package/kits/physics-2d/package.json +3 -1
  82. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  83. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  84. package/kits/physics-3d/castle.json +5 -5
  85. package/kits/physics-3d/package-lock.json +0 -29
  86. package/kits/physics-3d/package.json +0 -1
  87. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  88. package/kits/real-time/CLAUDE.md +23 -4
  89. package/kits/real-time/castle.json +1 -1
  90. package/kits/real-time/code/client/connection.js +9 -4
  91. package/kits/real-time/code/client/joinOverlay.js +46 -0
  92. package/kits/real-time/code/client/messages.js +4 -0
  93. package/kits/real-time/code/server/gameHooks.js +4 -0
  94. package/kits/real-time/code/server/persist.js +105 -0
  95. package/kits/real-time/code/server/session.js +289 -19
  96. package/kits/real-time/package-lock.json +1139 -0
  97. package/kits/turn-based/CLAUDE.md +76 -20
  98. package/kits/turn-based/castle.json +3 -3
  99. package/kits/turn-based/code/server/index.js +16 -5
  100. package/kits/turn-based/package-lock.json +0 -27
  101. package/kits/turn-based/package.json +2 -2
  102. package/kits/turn-based/room.js +162 -13
  103. package/kits/turn-based/testing.js +276 -92
  104. package/package.json +5 -5
  105. package/dist/shell/assets/index-CVHj503j.css +0 -1
  106. package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
@@ -54,7 +54,7 @@ const CARD_SELECTOR = "#castle-card, [data-castle-card]";
54
54
  export async function captureCoverHeadless(opts) {
55
55
  const remoteGrant = contentGrantFromBrowserUrl(opts.serveUrl);
56
56
  if (remoteGrant) {
57
- return captureCoverRemote(remoteGrant, opts.outPath);
57
+ return captureCoverRemote(remoteGrant, opts.outPath, opts.remoteBudgetMs ?? REMOTE_CAPTURE_BUDGET_MS, opts.signal);
58
58
  }
59
59
  // A manager we made is a manager we must close. `withBrowser` deliberately
60
60
  // leaves the browser RUNNING so the next call reuses it -- right for the
@@ -94,8 +94,9 @@ export async function captureCoverHeadless(opts) {
94
94
  await manager.shutdown().catch(() => undefined);
95
95
  }
96
96
  }
97
- async function captureCoverRemote(contentGrant, outPath) {
98
- const signal = AbortSignal.timeout(REMOTE_CAPTURE_BUDGET_MS);
97
+ async function captureCoverRemote(contentGrant, outPath, budgetMs, callerSignal) {
98
+ const budgetSignal = AbortSignal.timeout(Math.max(1, budgetMs));
99
+ const signal = callerSignal ? AbortSignal.any([budgetSignal, callerSignal]) : budgetSignal;
99
100
  try {
100
101
  const result = await api.runBrowserJob(contentGrant, { kind: 'cover' }, signal);
101
102
  const pngBase64 = result.pngBase64;
@@ -117,10 +118,11 @@ async function captureCoverRemote(contentGrant, outPath) {
117
118
  if (signal.aborted) {
118
119
  return {
119
120
  ok: false,
120
- error: `remote cover gave up after ${REMOTE_CAPTURE_BUDGET_MS / 1000}s`,
121
+ outcome: 'timed_out',
122
+ error: `remote cover gave up after ${budgetMs / 1000}s`,
121
123
  };
122
124
  }
123
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
125
+ return { ok: false, outcome: 'capture_failed', error: error instanceof Error ? error.message : String(error) };
124
126
  }
125
127
  }
126
128
  // Resolve with `work`, or with null if the budget runs out first. The loser is
package/dist/ide.d.ts CHANGED
@@ -9,6 +9,16 @@ export declare const FAVICON_LINK_TAGS: string;
9
9
  export declare const FILES_API_PREFIX = "/__castle/files/";
10
10
  export declare const COVER_API_PATH = "/__castle/cover";
11
11
  export { COVER_FILE };
12
+ export declare function resolveDeckPath(deckDir: string, requestedPath: unknown, opts?: {
13
+ mutation?: boolean;
14
+ }): {
15
+ ok: true;
16
+ abs: string;
17
+ rel: string;
18
+ } | {
19
+ ok: false;
20
+ error: string;
21
+ };
12
22
  export interface IdeServer {
13
23
  /** Serve the IDE page + its static assets. Returns true if it handled the request. */
14
24
  handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse, reqPath: string): boolean;
package/dist/ide.js CHANGED
@@ -1,7 +1,7 @@
1
- // IDE backend for `castle-web serve`: serves the shell UI (a dockview workspace
2
- // bundled from `src/shell/` into `dist/shell/`) and runs the terminal panel's
3
- // PTY. The deck loads in an iframe inside the shell; an agent CLI can be driven
4
- // in the terminal panel right next to the live deck.
1
+ // IDE backend for `castle-web serve`: serves the shell UI (the flow
2
+ // workbench bundled from `src/shell/` into `dist/shell/`) and runs the
3
+ // terminal panel's PTY. The deck loads in an iframe inside the shell; an
4
+ // agent CLI can be driven in the terminal panel right next to the live deck.
5
5
  //
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
@@ -226,7 +226,7 @@ function isImportPath(rel) {
226
226
  // writeProjectFile guards so the builtin editor can't escape the deck. Callers
227
227
  // that are about to CHANGE the file pass `mutation`, which additionally refuses
228
228
  // anything a dependency owns.
229
- function resolveDeckPath(deckDir, requestedPath, opts = {}) {
229
+ export function resolveDeckPath(deckDir, requestedPath, opts = {}) {
230
230
  if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
231
231
  return { ok: false, error: 'Missing file path.' };
232
232
  }
@@ -482,6 +482,15 @@ function handleVersionRestore(deckDir, req, res, restart) {
482
482
  }
483
483
  });
484
484
  }
485
+ // A curated deck hides anything not covered by editor.visiblePaths. Writes,
486
+ // moves, and uploads already mkdir parent dirs; they also have to register the
487
+ // dest folder or a new path lands on disk and stays invisible in Files.
488
+ function ensureFileParentVisible(deckDir, rel) {
489
+ const dir = path.posix.dirname(rel);
490
+ if (dir === '.')
491
+ return ensureVisibleGlob(deckDir, rel, rel);
492
+ return ensureVisiblePath(deckDir, dir);
493
+ }
485
494
  function handleFilesWrite(deckDir, req, res) {
486
495
  withMutationPath(deckDir, req, res, (target, body) => {
487
496
  if (typeof body.contents !== 'string') {
@@ -490,7 +499,8 @@ function handleFilesWrite(deckDir, req, res) {
490
499
  try {
491
500
  fs.mkdirSync(path.dirname(target.abs), { recursive: true });
492
501
  fs.writeFileSync(target.abs, body.contents, 'utf8');
493
- sendJson(res, 200, { ok: true, path: target.rel });
502
+ const visiblePathAdded = ensureFileParentVisible(deckDir, target.rel);
503
+ sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
494
504
  }
495
505
  catch (err) {
496
506
  sendFailure(res, 'write', target.rel, err);
@@ -571,10 +581,7 @@ function handleFilesUpload(deckDir, req, res) {
571
581
  catch (err) {
572
582
  return sendFailure(res, 'upload', target.rel, err);
573
583
  }
574
- const dir = path.posix.dirname(target.rel);
575
- const visiblePathAdded = dir === '.'
576
- ? ensureVisibleGlob(deckDir, target.rel, target.rel)
577
- : ensureVisiblePath(deckDir, dir);
584
+ const visiblePathAdded = ensureFileParentVisible(deckDir, target.rel);
578
585
  sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
579
586
  }, (err) => {
580
587
  if (err instanceof RangeError)
@@ -703,7 +710,8 @@ function handleFilesRename(deckDir, req, res) {
703
710
  try {
704
711
  fs.mkdirSync(path.dirname(to.abs), { recursive: true });
705
712
  fs.renameSync(from.abs, to.abs);
706
- sendJson(res, 200, { ok: true, path: to.rel });
713
+ const visiblePathAdded = ensureFileParentVisible(deckDir, to.rel);
714
+ sendJson(res, 200, { ok: true, path: to.rel, visiblePathAdded });
707
715
  }
708
716
  catch (err) {
709
717
  sendFailure(res, 'rename', from.rel, err);
package/dist/index.js CHANGED
@@ -62,6 +62,10 @@ function readPositionals() {
62
62
  }
63
63
  return out;
64
64
  }
65
+ function readImportFileArgs() {
66
+ const positionals = readPositionals();
67
+ return { deckDir: path.resolve(positionals[1] ?? '.'), relPosix: positionals[0] ?? '' };
68
+ }
65
69
  function getFlagValue(flag) {
66
70
  const idx = args.indexOf(flag);
67
71
  return idx >= 0 ? args[idx + 1] : undefined;
@@ -89,7 +93,7 @@ function getServeSocketPort(dir) {
89
93
  }
90
94
  function usage() {
91
95
  console.log(`Usage:
92
- castle-web init <dir> [--kit NAME] (kits: physics-2d (default), physics-3d, none)
96
+ castle-web init <dir> [--deck-id ID] [--card-id ID] [--kit NAME] (kits: physics-2d (default), physics-3d, none)
93
97
  castle-web init <dir> --remix <deckId> [--deck-id ID] [--card-id ID] (starts from a published deck instead of a kit)
94
98
  castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
95
99
  castle-web restart [--port PORT]
@@ -130,7 +134,7 @@ async function main() {
130
134
  case 'init': {
131
135
  const dir = findPositionalDir();
132
136
  if (dir === '.') {
133
- console.error('Usage: castle-web init <dir> [--kit NAME | --remix DECK_ID]');
137
+ console.error('Usage: castle-web init <dir> [--deck-id ID] [--card-id ID] [--kit NAME | --remix DECK_ID]');
134
138
  process.exit(1);
135
139
  }
136
140
  const kit = getFlagValue('--kit');
@@ -177,8 +181,8 @@ async function main() {
177
181
  }
178
182
  case 'fork-import-file': {
179
183
  // `castle-web fork-import-file <imports/...> [dir]`
180
- const positionals = readPositionals();
181
- const result = forkImportFile(path.resolve(positionals[1] ?? '.'), positionals[0] ?? '');
184
+ const { deckDir, relPosix } = readImportFileArgs();
185
+ const result = forkImportFile(deckDir, relPosix);
182
186
  if (!result.ok) {
183
187
  console.error(result.error);
184
188
  process.exit(1);
@@ -187,8 +191,8 @@ async function main() {
187
191
  break;
188
192
  }
189
193
  case 'revert-import-file': {
190
- const positionals = readPositionals();
191
- const result = revertImportFile(path.resolve(positionals[1] ?? '.'), positionals[0] ?? '');
194
+ const { deckDir, relPosix } = readImportFileArgs();
195
+ const result = revertImportFile(deckDir, relPosix);
192
196
  if (!result.ok) {
193
197
  console.error(result.error);
194
198
  process.exit(1);
package/dist/init.d.ts CHANGED
@@ -19,7 +19,7 @@ export declare function init(dir: string, opts?: {
19
19
  serve?: boolean;
20
20
  /** Remix this published deck instead of starting from a kit. */
21
21
  remixOf?: string;
22
- /** The identity the server already minted for a remix. */
22
+ /** The identity the server already minted for this new deck. */
23
23
  deckId?: string;
24
24
  cardId?: string;
25
25
  }): Promise<void>;
package/dist/init.js CHANGED
@@ -55,7 +55,7 @@ if (!(await mountEditorRoute())) startGame(initCard());
55
55
  // Registry version of castle-web-sdk to inject when scaffolding from a
56
56
  // globally-installed castle-web (not from inside the workspace). Bumped
57
57
  // alongside cli/sdk version bumps.
58
- const PUBLISHED_SDK_VERSION = '0.4.26';
58
+ const PUBLISHED_SDK_VERSION = '0.4.28';
59
59
  // Range, not a pin: `castle-web-fonts` is its own package precisely so a tenth
60
60
  // face can ship without a cli release, and a caret range means an existing
61
61
  // deck's next install picks it up. Nothing here has to move when it does.
@@ -651,6 +651,16 @@ export async function init(dir, opts = {}) {
651
651
  else {
652
652
  scaffoldFromKitImport(kit, projectDir);
653
653
  }
654
+ if (!opts.remixOf && (opts.deckId || opts.cardId)) {
655
+ // Apply server identity before install/version 1, so retries and a restore
656
+ // to the original scaffold cannot allocate a second deck or card.
657
+ const configPath = path.join(projectDir, 'castle.json');
658
+ writeJsonFile(configPath, {
659
+ ...readJsonFile(configPath),
660
+ ...(opts.deckId ? { deckId: opts.deckId } : {}),
661
+ ...(opts.cardId ? { cardId: opts.cardId } : {}),
662
+ });
663
+ }
654
664
  if (!opts.remixOf) {
655
665
  console.log(`Created project in ${projectDir}/${bare ? '' : ` (from kit "${kit}")`}`);
656
666
  }
package/dist/remix.js CHANGED
@@ -1,9 +1,7 @@
1
1
  import * as fs from 'fs';
2
- import * as os from 'os';
3
2
  import * as path from 'path';
4
- import { nanoid } from 'nanoid';
5
3
  import * as api from './api.js';
6
- import { runTar } from './save-deck.js';
4
+ import { downloadSourceArchive, runTar } from './save-deck.js';
7
5
  // Scaffolding a deck from another published deck, for `init --remix`.
8
6
  // Dropped from the copied castle.json. `autoUpdateWhenImported` is the parent's
9
7
  // promise to ITS importers, which a remix hasn't made.
@@ -34,17 +32,7 @@ export async function fetchParentSource(parentDeckId) {
34
32
  console.error(`Deck ${parentDeckId} has no saved source to remix. Only decks saved by the web CLI can be remixed.`);
35
33
  process.exit(1);
36
34
  }
37
- console.log(`Fetching ${source.archiveUrl}`);
38
- const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
39
- if (!res.ok) {
40
- throw new Error(`Archive fetch failed: HTTP ${res.status}`);
41
- }
42
- const buf = Buffer.from(await res.arrayBuffer());
43
- console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
44
- // Read and untarred out of, never moved out of: in a sandbox the deck is on a
45
- // different filesystem from tmp, and a rename across that is EXDEV.
46
- const archivePath = path.join(os.tmpdir(), `castle-remix-${nanoid(8)}.tar.gz`);
47
- fs.writeFileSync(archivePath, buf);
35
+ const archivePath = await downloadSourceArchive(source, 'castle-remix');
48
36
  // `./castle.json`, because archiveSource packs with `-C <dir> .`.
49
37
  const text = await runTar(['-xzOf', archivePath, './castle.json'], { capture: true });
50
38
  return { archivePath, castleJson: parseJson(text) };
@@ -1,10 +1,13 @@
1
+ import * as api from './api.js';
1
2
  export declare const SOURCE_ARCHIVE_EXCLUDES: string[];
2
3
  export declare const SAVE_STAGE_TELEMETRY_PREFIX = "CASTLE_SAVE_TELEMETRY ";
4
+ export declare const SAVE_COVER_SELECTION_TELEMETRY_PREFIX = "CASTLE_SAVE_COVER_SELECTION ";
3
5
  export type SaveStage = 'bundle' | 'cover' | 'scene_upload_config' | 'scene_upload' | 'content_publish' | 'server_publish' | 'source_archive' | 'source_upload_config' | 'source_upload' | 'source_commit';
4
6
  export declare function runTar(args: string[], opts?: {
5
7
  capture?: boolean;
6
8
  }): Promise<string>;
7
9
  export declare function archiveSource(projectDir: string): Promise<Buffer>;
10
+ export declare function downloadSourceArchive(source: api.WebDeckSource, namePrefix: string): Promise<string>;
8
11
  export type SaveVisibility = 'unlisted' | 'private';
9
12
  export interface SaveDeckOptions {
10
13
  title?: string;
package/dist/save-deck.js CHANGED
@@ -7,7 +7,8 @@ import * as api from './api.js';
7
7
  import * as config from './config.js';
8
8
  import { bundleProject } from './bundle.js';
9
9
  import { resolveServerConfig } from './castleJson.js';
10
- import { COVER_FILE } from './ide.js';
10
+ import { selectSaveCover } from './saveCover.js';
11
+ import { COVER_FILE } from './localPaths.js';
11
12
  import { buildServerBundle } from './serverBundle.js';
12
13
  // `imports/` is derived state like node_modules: a dependency's files belong to
13
14
  // the deck that published them, and castle.json's import pins are enough to fetch
@@ -20,6 +21,7 @@ export const SOURCE_ARCHIVE_EXCLUDES = ['node_modules', 'dist', '.castle', '.git
20
21
  // adding machine output to interactive local saves. Old CLIs ignore the env, so releases remain
21
22
  // independently deployable.
22
23
  export const SAVE_STAGE_TELEMETRY_PREFIX = 'CASTLE_SAVE_TELEMETRY ';
24
+ export const SAVE_COVER_SELECTION_TELEMETRY_PREFIX = 'CASTLE_SAVE_COVER_SELECTION ';
23
25
  function emitSaveStage(stage, outcome, startedAtMs, fields = {}) {
24
26
  if (process.env.CASTLE_SAVE_STAGE_TELEMETRY !== '1')
25
27
  return;
@@ -90,6 +92,21 @@ export async function archiveSource(projectDir) {
90
92
  }
91
93
  }
92
94
  }
95
+ // Written to tmp and read or untarred out of there, never moved out: in a sandbox
96
+ // the deck is on a different filesystem from tmp, and a rename across that is EXDEV.
97
+ // The caller owns the file it gets back.
98
+ export async function downloadSourceArchive(source, namePrefix) {
99
+ console.log(`Fetching ${source.archiveUrl}`);
100
+ const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
101
+ if (!res.ok) {
102
+ throw new Error(`Archive fetch failed: HTTP ${res.status}`);
103
+ }
104
+ const buf = Buffer.from(await res.arrayBuffer());
105
+ console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
106
+ const file = path.join(os.tmpdir(), `${namePrefix}-${nanoid(8)}.tar.gz`);
107
+ fs.writeFileSync(file, buf);
108
+ return file;
109
+ }
93
110
  async function uploadPresignedFile(uploadConfig, file, contentType, timeoutMs, label) {
94
111
  const formData = new FormData();
95
112
  formData.append('Content-Type', contentType);
@@ -166,83 +183,24 @@ async function publishServerBundle(projectDir, deckId) {
166
183
  writePublishedServerBundleState(projectDir, { deckId, sha256: bundle.sha256 });
167
184
  console.log(`Server bundle published${saved ? ` as ${saved.version}` : ''} (sha256 ${bundle.sha256}).`);
168
185
  }
169
- // Publish the deck's local cover, if it has one, and return the file id for the
170
- // card. The editor writes `preview.png` as you play (see the serve's cover
171
- // endpoint); this is the moment it becomes public.
172
- //
173
- // Never fatal: a deck that saves but whose cover upload failed is a deck that
174
- // saved. Losing the push over a thumbnail would be the wrong trade.
175
- // The deck's own serve, if one is running -- the URL a headless cover capture
176
- // loads. No serve means no deck to photograph, which is a normal state (a save
177
- // from a script), not an error.
178
- async function runningServeUrl(projectDir) {
179
- try {
180
- const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
181
- const parsed = JSON.parse(raw);
182
- const port = parsed.port;
183
- if (process.env.CASTLE_SANDBOX_ID && typeof port === 'number') {
184
- try {
185
- const response = await fetch(`http://localhost:${port}/__castle/browser-content-grant`, { signal: AbortSignal.timeout(2_000) });
186
- const body = (await response.json());
187
- if (response.ok && typeof body.url === 'string')
188
- return body.url;
189
- }
190
- catch {
191
- // Fall through to the existing best-effort local capture path.
192
- }
193
- }
194
- if (typeof parsed.contentPlayUrl === 'string')
195
- return parsed.contentPlayUrl;
196
- return typeof port === 'number' ? `http://localhost:${port}` : null;
197
- }
198
- catch {
199
- return null;
200
- }
201
- }
202
- // Photograph the running deck with a real browser, into a scratch file.
203
- //
204
- // This OUTRANKS the editor's `preview.png`, and deliberately: both are the
205
- // AUTOMATIC cover (a creator's hand-picked one lives in the card's custom slot
206
- // and outranks both), so the question is only which automatic capture is better
207
- // -- and the in-page one silently drops a WebGL canvas that lacks
208
- // `preserveDrawingBuffer`, plus anything using `filter`/`clip-path`/`mask`. A
209
- // browser compositing its own frame has no such list.
210
- //
211
- // `preview.png` does have one real advantage -- it is whatever the creator last
212
- // had on screen, where this is a cold load -- so it stays the fallback. But a
213
- // cover that is missing the game beats a cover of the opening seconds only if
214
- // you never look at it, and nobody looks at an unattended capture. Writing to a
215
- // scratch file rather than over `preview.png` keeps that fallback intact.
216
- async function captureAutomaticCover(projectDir) {
217
- const serveUrl = await runningServeUrl(projectDir);
218
- if (!serveUrl)
219
- return null;
220
- const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
221
- const { captureCoverHeadless } = await import('./headlessCover.js');
222
- const result = await captureCoverHeadless({
223
- serveUrl,
224
- outPath,
225
- onProgress: (message) => console.log(message),
226
- });
227
- if (!result.ok) {
228
- // Never fatal, same as the upload below: a deck that saved without a cover
229
- // is a deck that saved.
230
- console.log(`Cover capture skipped: ${result.error}`);
231
- return null;
232
- }
233
- return outPath;
234
- }
235
186
  async function uploadLocalCover(projectDir) {
236
187
  try {
237
- const captured = await captureAutomaticCover(projectDir);
238
- const coverPath = captured ?? path.join(projectDir, COVER_FILE);
239
- if (captured)
188
+ const selected = await selectSaveCover(projectDir, { onProgress: (message) => console.log(message) });
189
+ if (process.env.CASTLE_SAVE_STAGE_TELEMETRY === '1') {
190
+ console.log(`${SAVE_COVER_SELECTION_TELEMETRY_PREFIX}${JSON.stringify({
191
+ event: 'castle_web_save_cover_selected',
192
+ outcome: selected.outcome,
193
+ capture_outcome: selected.captureOutcome,
194
+ duration_ms: selected.durationMs,
195
+ })}`);
196
+ }
197
+ if (selected.outcome === 'remote' || selected.outcome === 'local_browser')
240
198
  console.log('Captured a cover from the running deck.');
241
- else if (fs.existsSync(coverPath))
199
+ else if (selected.outcome === 'editor')
242
200
  console.log('Using the cover captured in the editor.');
243
- if (!fs.existsSync(coverPath))
201
+ if (!selected.path)
244
202
  return null;
245
- const base64 = fs.readFileSync(coverPath).toString('base64');
203
+ const base64 = fs.readFileSync(selected.path).toString('base64');
246
204
  const file = await api.uploadBase64(base64, COVER_FILE);
247
205
  return file?.fileId ?? null;
248
206
  }
@@ -411,6 +369,15 @@ export async function saveDeck(dir, opts = {}) {
411
369
  else if (code === 'DECK_INVALID_PERMISSIONS') {
412
370
  console.error('You do not have permission to save this deck.');
413
371
  }
372
+ else if (code === 'DECK_DELETED') {
373
+ // The deck this workspace points at was deleted somewhere else -- the app,
374
+ // the cloud editor, another machine. The server refuses the write rather
375
+ // than publishing into a deck nobody can reach, so say what happened and
376
+ // what recovers the code, which is still here and untouched.
377
+ console.error(`This deck was deleted (${castleJson?.deckId}), so it can no longer be saved to.\n` +
378
+ 'Your files are unchanged. To publish them as a new deck, remove the ' +
379
+ '"deckId" and "cardId" fields from castle.json and save again.');
380
+ }
414
381
  else {
415
382
  throw e;
416
383
  }
@@ -0,0 +1,18 @@
1
+ export type SaveCoverOutcome = 'remote' | 'local_browser' | 'editor' | 'none';
2
+ export interface SaveCoverSelection {
3
+ outcome: SaveCoverOutcome;
4
+ captureOutcome: 'succeeded' | 'timed_out' | 'capture_failed' | 'not_attempted';
5
+ path?: string;
6
+ durationMs: number;
7
+ }
8
+ export interface SaveCoverOptions {
9
+ totalBudgetMs?: number;
10
+ fetch?: typeof globalThis.fetch;
11
+ now?: () => number;
12
+ onProgress?: (message: string) => void;
13
+ }
14
+ export declare function runningServeUrl(projectDir: string, options?: Pick<SaveCoverOptions, 'fetch'> & {
15
+ signal?: AbortSignal;
16
+ }): Promise<string | null>;
17
+ /** Select exactly the automatic cover that save-deck would upload, without uploading it. */
18
+ export declare function selectSaveCover(projectDir: string, options?: SaveCoverOptions): Promise<SaveCoverSelection>;
@@ -0,0 +1,61 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { COVER_FILE } from './localPaths.js';
4
+ import { contentGrantFromBrowserUrl } from './native/playtest-remote.js';
5
+ export async function runningServeUrl(projectDir, options = {}) {
6
+ try {
7
+ const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
8
+ const parsed = JSON.parse(raw);
9
+ const port = parsed.port;
10
+ if (process.env.CASTLE_SANDBOX_ID && typeof port === 'number') {
11
+ try {
12
+ const response = await (options.fetch ?? globalThis.fetch)(`http://localhost:${port}/__castle/browser-content-grant`, { signal: options.signal ? AbortSignal.any([AbortSignal.timeout(2_000), options.signal]) : AbortSignal.timeout(2_000) });
13
+ const body = (await response.json());
14
+ if (response.ok && typeof body.url === 'string')
15
+ return body.url;
16
+ }
17
+ catch {
18
+ // The serve metadata below remains a valid local fallback.
19
+ }
20
+ }
21
+ if (typeof parsed.contentPlayUrl === 'string')
22
+ return parsed.contentPlayUrl;
23
+ return typeof port === 'number' ? `http://localhost:${port}` : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** Select exactly the automatic cover that save-deck would upload, without uploading it. */
30
+ export async function selectSaveCover(projectDir, options = {}) {
31
+ const now = options.now ?? Date.now;
32
+ const startedAt = now();
33
+ const totalBudgetMs = options.totalBudgetMs ?? 15_000;
34
+ const deadlineMs = startedAt + totalBudgetMs;
35
+ const signal = AbortSignal.timeout(Math.max(1, totalBudgetMs));
36
+ const serveUrl = await runningServeUrl(projectDir, { ...options, signal });
37
+ const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
38
+ const isRemote = Boolean(serveUrl && contentGrantFromBrowserUrl(serveUrl));
39
+ let captureOutcome = 'not_attempted';
40
+ // If cloud grant discovery consumed the caller budget, do not reinterpret its localhost
41
+ // metadata fallback as permission to start a new 60-second local-browser attempt.
42
+ if (serveUrl && !signal.aborted) {
43
+ const { captureCoverHeadless } = await import('./headlessCover.js');
44
+ const result = await captureCoverHeadless({
45
+ serveUrl,
46
+ outPath,
47
+ onProgress: options.onProgress,
48
+ ...(isRemote ? { remoteBudgetMs: Math.max(1, deadlineMs - now()), signal } : {}),
49
+ });
50
+ captureOutcome = result.outcome ?? (result.ok ? 'succeeded' : 'capture_failed');
51
+ if (result.ok && fs.existsSync(outPath)) {
52
+ return { outcome: isRemote ? 'remote' : 'local_browser', captureOutcome: 'succeeded', path: outPath, durationMs: Math.max(0, now() - startedAt) };
53
+ }
54
+ }
55
+ if (process.env.CASTLE_SANDBOX_ID && signal.aborted)
56
+ captureOutcome = 'timed_out';
57
+ const localPath = path.join(projectDir, COVER_FILE);
58
+ return fs.existsSync(localPath)
59
+ ? { outcome: 'editor', captureOutcome, path: localPath, durationMs: Math.max(0, now() - startedAt) }
60
+ : { outcome: 'none', captureOutcome, durationMs: Math.max(0, now() - startedAt) };
61
+ }
package/dist/serve.js CHANGED
@@ -762,16 +762,25 @@ export async function serve(dir, options = {}) {
762
762
  : options.host === '::'
763
763
  ? '::1'
764
764
  : options.host;
765
- // Content origins follow --host: a serve bound to a tailnet/LAN address is
766
- // being viewed from another device, where loopback is that device's own.
767
- const contentBindHost = !options.host ||
765
+ // Content proxies must listen where a remote browser can reach them. Binding
766
+ // the shell to 0.0.0.0 / :: means a phone on LAN/tailnet opens the shell; its
767
+ // content iframes need those same interfaces — the phone's 127.0.0.1 is itself.
768
+ const contentListenHost = options.host === '0.0.0.0' || options.host === '::'
769
+ ? options.host
770
+ : !options.host || options.host === 'localhost'
771
+ ? '127.0.0.1'
772
+ : options.host;
773
+ // Playtest / serve.json still advertise loopback: Chromium on this machine
774
+ // cannot navigate to 0.0.0.0, and same-machine Safari keeps the prior origins.
775
+ // Remote browsers get the Host they used via ServeSecurity.contentOrigins.
776
+ const contentAdvertiseHost = !options.host ||
768
777
  options.host === 'localhost' ||
769
778
  options.host === '0.0.0.0' ||
770
779
  options.host === '::'
771
780
  ? '127.0.0.1'
772
781
  : options.host;
773
- security.setLocalContentHost(contentBindHost);
774
- const localContentServers = await startLocalContentServers(contentBindHost, upstreamHost, port, security, new Set([port]));
782
+ security.setLocalContentHost(contentAdvertiseHost);
783
+ const localContentServers = await startLocalContentServers(contentListenHost, upstreamHost, port, security, new Set([port]));
775
784
  process.on('exit', () => {
776
785
  for (const server of localContentServers)
777
786
  server.close();
@@ -1318,6 +1327,10 @@ function trustedBridgeContentOrigin(security, request, role, value) {
1318
1327
  return null;
1319
1328
  if (origin === security.localContentOrigin(role) || origin === request.headers.origin)
1320
1329
  return origin;
1330
+ // Phone / LAN / tailnet: contentOrigins remints against the shell Host header.
1331
+ const expected = security.contentOrigins(request)[role === 'edit' ? 'editOrigin' : 'playOrigin'];
1332
+ if (origin === expected)
1333
+ return origin;
1321
1334
  const url = new URL(origin);
1322
1335
  const synthetic = {
1323
1336
  headers: { ...request.headers, host: url.host },
@@ -50,7 +50,17 @@ export declare class ServeSecurity {
50
50
  editOrigin: string;
51
51
  playOrigin: string;
52
52
  };
53
- localContentOrigin(role: ContentRole): string;
53
+ /**
54
+ * Origin a browser should use for a local content role.
55
+ *
56
+ * When the shell was opened via a LAN / tailnet hostname (or any non-loopback
57
+ * Host the serve already accepted), content iframes must use that same host —
58
+ * `127.0.0.1` is the *phone's* loopback, not the machine running the serve.
59
+ * Loopback / wildcard request hosts keep the advertised localContentHost
60
+ * (playtest and same-machine Safari).
61
+ */
62
+ localContentOrigin(role: ContentRole, requestHost?: string): string;
63
+ private contentHostForBrowser;
54
64
  /** A short-lived play URL for one shared-browser admission attempt. */
55
65
  browserContentOrigin(role?: ContentRole): string | null;
56
66
  localContentProxyHeaders(role: ContentRole): Record<string, string>;
@@ -41,6 +41,20 @@ function hostWithoutPort(host) {
41
41
  return host.slice(0, host.indexOf(']') + 1).toLowerCase();
42
42
  return host.split(':')[0].toLowerCase();
43
43
  }
44
+ /** Strip brackets so callers can compare / classify the bare hostname. */
45
+ function bareHostname(host) {
46
+ const h = hostWithoutPort(host).toLowerCase();
47
+ return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
48
+ }
49
+ /** IPv6 literals need brackets in an origin URL. */
50
+ function formatHostForOrigin(host) {
51
+ const bare = bareHostname(host);
52
+ return bare.includes(':') ? `[${bare}]` : bare;
53
+ }
54
+ function isLoopbackHostname(host) {
55
+ const bare = bareHostname(host);
56
+ return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1';
57
+ }
44
58
  function requestOrigin(req) {
45
59
  const forwarded = req.headers['x-forwarded-proto'];
46
60
  const protocol = typeof forwarded === 'string' ? forwarded.split(',')[0] : 'http';
@@ -80,7 +94,8 @@ export class ServeSecurity {
80
94
  throw new Error('edit and play content ports must be distinct');
81
95
  this.localContentPorts = ports;
82
96
  }
83
- // The host a browser should use to reach deck content; see contentBindHost.
97
+ // Loopback host advertised to same-machine consumers (playtest, serve.json).
98
+ // Remote browsers get the Host they used; see localContentOrigin(role, host).
84
99
  setLocalContentHost(host) {
85
100
  this.localContentHost = host;
86
101
  }
@@ -276,17 +291,36 @@ window.fetch=function(input,init){
276
291
  // Safari on macOS 15 asks DNS to resolve `*.localhost`, where it fails.
277
292
  // Dedicated loopback ports are still distinct browser origins and need no DNS.
278
293
  if (!cloud || !contentDomain)
279
- return this.localContentOrigin(role);
294
+ return this.localContentOrigin(role, host);
280
295
  const label = this.mintContentLabel(role, sandboxId, expiresAtSeconds);
281
296
  return `https://${label}.${contentDomain}`;
282
297
  };
283
298
  return { editOrigin: originFor('edit'), playOrigin: originFor('play') };
284
299
  }
285
- localContentOrigin(role) {
300
+ /**
301
+ * Origin a browser should use for a local content role.
302
+ *
303
+ * When the shell was opened via a LAN / tailnet hostname (or any non-loopback
304
+ * Host the serve already accepted), content iframes must use that same host —
305
+ * `127.0.0.1` is the *phone's* loopback, not the machine running the serve.
306
+ * Loopback / wildcard request hosts keep the advertised localContentHost
307
+ * (playtest and same-machine Safari).
308
+ */
309
+ localContentOrigin(role, requestHost) {
286
310
  const port = this.localContentPorts?.[role];
287
311
  if (!port)
288
312
  throw new Error('local content ports are not configured');
289
- return `http://${this.localContentHost}:${port}`;
313
+ const host = this.contentHostForBrowser(requestHost);
314
+ return `http://${formatHostForOrigin(host)}:${port}`;
315
+ }
316
+ contentHostForBrowser(requestHost) {
317
+ if (!requestHost)
318
+ return this.localContentHost;
319
+ const bare = bareHostname(requestHost);
320
+ if (!bare || bare === '0.0.0.0' || bare === '::' || isLoopbackHostname(bare)) {
321
+ return this.localContentHost;
322
+ }
323
+ return bare;
290
324
  }
291
325
  /** A short-lived play URL for one shared-browser admission attempt. */
292
326
  browserContentOrigin(role = 'play') {