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.
- package/dist/agent-prompts.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +70 -24
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +108 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +19 -11
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +83 -2
- package/kits/base/sdk/commands.d.ts +37 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +20 -9
- package/kits/multiplayer-2d/castle.json +4 -4
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
- package/kits/multiplayer-2d/package-lock.json +2 -4
- package/kits/multiplayer-2d/package.json +1 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +5 -5
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
- package/kits/multiplayer-3d/package-lock.json +2 -4
- package/kits/multiplayer-3d/package.json +1 -0
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +9 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +2 -2
- package/kits/turn-based/room.js +162 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +5 -5
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
package/dist/headlessCover.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
2
|
-
// bundled from `src/shell/` into `dist/shell/`) and runs the
|
|
3
|
-
// PTY. The deck loads in an iframe inside the shell; an
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
181
|
-
const result = forkImportFile(
|
|
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
|
|
191
|
-
const result = revertImportFile(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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) };
|
package/dist/save-deck.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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 (
|
|
199
|
+
else if (selected.outcome === 'editor')
|
|
242
200
|
console.log('Using the cover captured in the editor.');
|
|
243
|
-
if (!
|
|
201
|
+
if (!selected.path)
|
|
244
202
|
return null;
|
|
245
|
-
const base64 = fs.readFileSync(
|
|
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
|
|
766
|
-
//
|
|
767
|
-
|
|
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(
|
|
774
|
-
const localContentServers = await startLocalContentServers(
|
|
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 },
|
package/dist/serveSecurity.d.ts
CHANGED
|
@@ -50,7 +50,17 @@ export declare class ServeSecurity {
|
|
|
50
50
|
editOrigin: string;
|
|
51
51
|
playOrigin: string;
|
|
52
52
|
};
|
|
53
|
-
|
|
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>;
|
package/dist/serveSecurity.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|