@bitmagic/cli 0.1.1 → 0.1.2

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 (46) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +23 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/forge.d.ts +107 -0
  5. package/dist/commands/forge.js +393 -0
  6. package/dist/commands/forge.js.map +1 -0
  7. package/dist/commands/generate.js +58 -45
  8. package/dist/commands/generate.js.map +1 -1
  9. package/dist/commands/verify.js +16 -16
  10. package/dist/commands/verify.js.map +1 -1
  11. package/dist/forge/apply-modifications.d.ts +19 -0
  12. package/dist/forge/apply-modifications.js +184 -0
  13. package/dist/forge/apply-modifications.js.map +1 -0
  14. package/dist/forge/bake-progress.d.ts +44 -0
  15. package/dist/forge/bake-progress.js +77 -0
  16. package/dist/forge/bake-progress.js.map +1 -0
  17. package/dist/forge/browser-host.d.ts +100 -0
  18. package/dist/forge/browser-host.js +351 -0
  19. package/dist/forge/browser-host.js.map +1 -0
  20. package/dist/forge/local-store.d.ts +30 -0
  21. package/dist/forge/local-store.js +130 -0
  22. package/dist/forge/local-store.js.map +1 -0
  23. package/dist/forge/run-pipeline.d.ts +105 -0
  24. package/dist/forge/run-pipeline.js +269 -0
  25. package/dist/forge/run-pipeline.js.map +1 -0
  26. package/dist/forge/stream.d.ts +106 -0
  27. package/dist/forge/stream.js +286 -0
  28. package/dist/forge/stream.js.map +1 -0
  29. package/dist/forge/transport.d.ts +58 -0
  30. package/dist/forge/transport.js +145 -0
  31. package/dist/forge/transport.js.map +1 -0
  32. package/dist/forge/upload-proxy.d.ts +21 -0
  33. package/dist/forge/upload-proxy.js +134 -0
  34. package/dist/forge/upload-proxy.js.map +1 -0
  35. package/dist/generate/stream.d.ts +1 -10
  36. package/dist/generate/stream.js +5 -62
  37. package/dist/generate/stream.js.map +1 -1
  38. package/dist/http/sse.d.ts +40 -0
  39. package/dist/http/sse.js +98 -0
  40. package/dist/http/sse.js.map +1 -0
  41. package/dist/local-port.d.ts +30 -0
  42. package/dist/local-port.js +53 -0
  43. package/dist/local-port.js.map +1 -0
  44. package/dist/scaffold/project-files.js +89 -9
  45. package/dist/scaffold/project-files.js.map +1 -1
  46. package/package.json +4 -3
@@ -0,0 +1,100 @@
1
+ import type { ForgeTransport } from '@bitmagic/world-forger/pipeline/transport-types.js';
2
+ import { type ForgePageTransportOptions } from './transport.js';
3
+ export interface ForgeBrowserHostOptions {
4
+ /** Port of the project's own vite dev server. */
5
+ gamePort: number;
6
+ gameId: string;
7
+ /** The project's `src/work/world.json`, parsed — see {@link readProjectGameData}. */
8
+ gameData: Record<string, unknown>;
9
+ /**
10
+ * Base URL the engine reads back as `window.AI_AGENT_URL` (`engine/agentUrl.ts`). In this lane
11
+ * that must be **api-server**, not game-play-agent: the engine's uploads go to
12
+ * `POST /api/cli/v1/uploads/signed-url`, which api-server serves.
13
+ */
14
+ agentUrl: string;
15
+ log?: (message: string) => void;
16
+ gameLoadedTimeoutMs?: number;
17
+ /** Passed through to the transport (retry gap). Exposed for tests. */
18
+ transportOptions?: ForgePageTransportOptions;
19
+ }
20
+ /**
21
+ * The page URL. `source=creator` is not cosmetic — see precondition 1 in the file header.
22
+ */
23
+ export declare function forgePageUrl(gamePort: number, gameId: string): string;
24
+ /**
25
+ * Chromium flags for a forge run. Split out so the two rules that matter are testable without a
26
+ * browser: web security is always off (presigned PUTs from localhost), and software GL is added
27
+ * only where there is no GPU — CI, or an explicit opt-in for a headless dev box.
28
+ */
29
+ export declare function forgeLaunchArgs(env: Record<string, string | undefined>): string[];
30
+ /**
31
+ * The game data `LOAD_GAME` carries. There is no `/api/get-game-config` in this lane — the CLI
32
+ * project owns its game on disk.
33
+ *
34
+ * It takes BOTH of the project's `work/` JSON files, because `GameEngine.loadGame` reads from
35
+ * both: `gameData.worldProfileData` comes from world.json (GameEngine.ts:1431) and
36
+ * `gameData.gameGenre` from game.json (:1432). Passing world.json alone leaves the engine with no
37
+ * genre, and it refuses to load. This mirrors the web lane, where `session-file-manager.ts` keeps
38
+ * `<genre>-game.json` and `<genre>-world.json` as separate templates and the engine is handed the
39
+ * combination.
40
+ *
41
+ * `gameGenre` must be the GenreLoader REGISTRY name — `Voxel`, not `voxel`. `GenreLoader`'s map is
42
+ * keyed by it (`GenreLoader.ts:43-46`), and an unmatched key logs "Unknown genre" and returns null,
43
+ * which surfaces as `Failed to load genre "..."`. game.json already carries the registry-cased
44
+ * value, which is exactly why it is read rather than derived from `bitmagic.json`'s lowercase
45
+ * `genre` (that one names the genre DIRECTORY).
46
+ *
47
+ * `gameId` is forced from `bitmagic.json` and wins over both files. A scaffolded project's
48
+ * work-copy JSON is taken from a template and still carries the TEMPLATE's id — a game this
49
+ * creator does not own. Everything else in a forge already keys off `bitmagic.json` (the design
50
+ * request, the ownership check), so leaving the template's id in the game data splits one run
51
+ * across two games; and the engine uses `currentGameData.gameId` as the storage-key prefix when it
52
+ * asks for a signed upload URL, so every `.vxl`/`.vwld` PUT would be scoped to the wrong game and
53
+ * refused by `assertOwnsGame`.
54
+ */
55
+ export declare function readProjectGameData(root: string, metadata: {
56
+ gameId: string;
57
+ }): Record<string, unknown>;
58
+ export declare class ForgeBrowserHost {
59
+ private readonly options;
60
+ private readonly log;
61
+ private readonly listeners;
62
+ private readonly pageBridge;
63
+ private readonly forgeTransport;
64
+ private context;
65
+ private page;
66
+ private userDataDir;
67
+ constructor(options: ForgeBrowserHostOptions);
68
+ /**
69
+ * The pipeline's `ForgeDeps.transport`. Available before `loadGame()` only so the caller can
70
+ * build its deps in one place; using it earlier fails as a transport failure (null), because
71
+ * `postCommand` below refuses without a page.
72
+ */
73
+ get transport(): ForgeTransport;
74
+ /**
75
+ * Subscribe to every message the page posts, returning an unsubscribe.
76
+ *
77
+ * Exists for PROGRESS REPORTING, which the transport deliberately does not do: its
78
+ * `progressType` option only re-arms an idle timer, so the 20-minute level bake would otherwise
79
+ * run to completion without printing a single line and a creator would conclude it had hung and
80
+ * kill it mid-bake. The command subscribes here and prints the bake's own
81
+ * `VOXELIZE_GLB_AS_LEVEL_PROGRESS` chunk counts.
82
+ */
83
+ onPageMessage(listener: (message: Record<string, unknown>) => void): () => void;
84
+ private buildBridge;
85
+ private dispatch;
86
+ launch(): Promise<void>;
87
+ /**
88
+ * Open the game page and complete the `LOAD_GAME` → `GAME_LOADED` handshake. Every forge command
89
+ * sent before this resolves would be queued by the engine, not executed (precondition 2).
90
+ */
91
+ loadGame(): Promise<void>;
92
+ /**
93
+ * Wait for one handshake message. Unlike the transport's waits, these REJECT on timeout: a page
94
+ * that never boots or a game that never loads is not a "no answer" the caller can shrug off and
95
+ * retry — nothing downstream can run.
96
+ */
97
+ private waitForPageMessage;
98
+ closePage(): Promise<void>;
99
+ close(): Promise<void>;
100
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The CLI lane's browser host. Steps 3-5 of the world-forger pipeline (create archetypes, bake the
3
+ * voxel level, place instances) are not pure compute: they drive the game engine over
4
+ * `postMessage`, and the round trip through the engine is the only source of the instance ids
5
+ * step 5 persists. The web lane borrows the Creator's browser over a WebSocket relay; here the CLI
6
+ * owns a headless Chrome and posts into the page directly.
7
+ *
8
+ * Four preconditions, each of which fails SILENTLY if missed — they are the reason this file
9
+ * exists rather than a few lines inside the forge command:
10
+ *
11
+ * 1. `?source=creator` in the URL. Without it `safePostMessageToCreator`
12
+ * (`game/src/engine/CreatorMode.ts:28-36`) returns without posting, every reply is discarded,
13
+ * and the pipeline times out with no error logged anywhere.
14
+ * 2. `GAME_TEMPLATE_READY` → `LOAD_GAME` → `GAME_LOADED` must complete first, in that order. All
15
+ * six forge message types are in the engine's `REQUIRES_GAME_LOADED` set
16
+ * (`CreatorMessageHandler.ts:67`) and are queued, not run, before the handshake finishes — and
17
+ * `LOAD_GAME` itself is DROPPED, not queued, if it is posted before the engine registers its
18
+ * listener (see the long comment in `loadGame`).
19
+ * 3. `--disable-web-security`. The engine PUTs to presigned upload URLs from a `localhost`
20
+ * origin; the CORS preflight refuses them otherwise. Chromium only accepts that flag on a
21
+ * persistent context, which is why this is `launchPersistentContext` and why
22
+ * `src/verify/browser.ts` (which uses `newContext`) cannot be reused.
23
+ * 4. A real GPU context. Without one, Three.js renders nothing and the engine's scene validation
24
+ * bails — hence the SwiftShader flags on GPU-less machines.
25
+ *
26
+ * The page is top-level, so `window.parent === window` and the engine's `safePostMessageToCreator`
27
+ * posts to the page's own listener. That is the load-bearing reason this works with no creator
28
+ * frame at all: our injected listener sees exactly what the Creator would have seen.
29
+ */
30
+ import * as fs from 'fs';
31
+ import * as os from 'os';
32
+ import * as path from 'path';
33
+ import { chromium } from 'playwright-core';
34
+ import { CliError } from '../errors.js';
35
+ import { aliasSourceDir } from '../scaffold/aliases.js';
36
+ import { createForgePageTransport, } from './transport.js';
37
+ /** Name of the Node-side function the injected shim forwards page messages to. */
38
+ const MESSAGE_BRIDGE = '__bitmagicOnGameMessage';
39
+ /** The engine has to boot Rapier, load world.json and warm the scene before it answers. */
40
+ const DEFAULT_GAME_LOADED_TIMEOUT_MS = 120_000;
41
+ /**
42
+ * How long to wait for the engine to register its `message` listener. Much shorter than the
43
+ * GAME_LOADED budget: reaching this point only needs the bundle to parse and i18n to initialise,
44
+ * with no world, no physics and no assets involved. A separate budget AND a separate message keep
45
+ * "the page never booted" from being reported as "the world never loaded".
46
+ */
47
+ const DEFAULT_TEMPLATE_READY_TIMEOUT_MS = 60_000;
48
+ /**
49
+ * The page URL. `source=creator` is not cosmetic — see precondition 1 in the file header.
50
+ */
51
+ export function forgePageUrl(gamePort, gameId) {
52
+ return `http://localhost:${gamePort}/?source=creator&gameId=${encodeURIComponent(gameId)}`;
53
+ }
54
+ /**
55
+ * Chromium flags for a forge run. Split out so the two rules that matter are testable without a
56
+ * browser: web security is always off (presigned PUTs from localhost), and software GL is added
57
+ * only where there is no GPU — CI, or an explicit opt-in for a headless dev box.
58
+ */
59
+ export function forgeLaunchArgs(env) {
60
+ const args = ['--disable-web-security'];
61
+ if (env.CI === 'true' || env.BITMAGIC_BROWSER_SOFTWARE_GL === 'true') {
62
+ args.push('--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader');
63
+ }
64
+ return args;
65
+ }
66
+ /** Read one of the project's `work/` JSON files, naming it in every failure. */
67
+ function readWorkJson(root, name, why) {
68
+ // Via the alias model, not a hardcoded 'src/work': `scaffold/aliases.ts` is the one source of
69
+ // truth for where the creator's game lives, and `generate.ts` already resolves world.json the
70
+ // same way. Two hardcoded copies would drift the day the scaffold layout moves.
71
+ const file = path.join(root, aliasSourceDir('work'), name);
72
+ if (!fs.existsSync(file)) {
73
+ throw new CliError(`No ${name} at ${file}. \`bitmagic forge\` ${why} — run this from inside a Bitmagic project.`);
74
+ }
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
78
+ }
79
+ catch {
80
+ throw new CliError(`${file} is not valid JSON, so the game cannot be loaded.`);
81
+ }
82
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
83
+ throw new CliError(`${file} does not contain an object.`);
84
+ }
85
+ return parsed;
86
+ }
87
+ /**
88
+ * The game data `LOAD_GAME` carries. There is no `/api/get-game-config` in this lane — the CLI
89
+ * project owns its game on disk.
90
+ *
91
+ * It takes BOTH of the project's `work/` JSON files, because `GameEngine.loadGame` reads from
92
+ * both: `gameData.worldProfileData` comes from world.json (GameEngine.ts:1431) and
93
+ * `gameData.gameGenre` from game.json (:1432). Passing world.json alone leaves the engine with no
94
+ * genre, and it refuses to load. This mirrors the web lane, where `session-file-manager.ts` keeps
95
+ * `<genre>-game.json` and `<genre>-world.json` as separate templates and the engine is handed the
96
+ * combination.
97
+ *
98
+ * `gameGenre` must be the GenreLoader REGISTRY name — `Voxel`, not `voxel`. `GenreLoader`'s map is
99
+ * keyed by it (`GenreLoader.ts:43-46`), and an unmatched key logs "Unknown genre" and returns null,
100
+ * which surfaces as `Failed to load genre "..."`. game.json already carries the registry-cased
101
+ * value, which is exactly why it is read rather than derived from `bitmagic.json`'s lowercase
102
+ * `genre` (that one names the genre DIRECTORY).
103
+ *
104
+ * `gameId` is forced from `bitmagic.json` and wins over both files. A scaffolded project's
105
+ * work-copy JSON is taken from a template and still carries the TEMPLATE's id — a game this
106
+ * creator does not own. Everything else in a forge already keys off `bitmagic.json` (the design
107
+ * request, the ownership check), so leaving the template's id in the game data splits one run
108
+ * across two games; and the engine uses `currentGameData.gameId` as the storage-key prefix when it
109
+ * asks for a signed upload URL, so every `.vxl`/`.vwld` PUT would be scoped to the wrong game and
110
+ * refused by `assertOwnsGame`.
111
+ */
112
+ export function readProjectGameData(root, metadata) {
113
+ const game = readWorkJson(root, 'game.json', 'loads the project\'s own game.json into the game');
114
+ const world = readWorkJson(root, 'world.json', 'loads the project\'s own world.json into the game');
115
+ // world.json last: it owns worldProfileData/assets/environmentObjects. `gameId` is the only key
116
+ // the two files share, and it is overridden below regardless of which won.
117
+ return { ...game, ...world, gameId: metadata.gameId };
118
+ }
119
+ function isRecord(value) {
120
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
121
+ }
122
+ export class ForgeBrowserHost {
123
+ options;
124
+ log;
125
+ listeners = new Set();
126
+ pageBridge;
127
+ forgeTransport;
128
+ context = null;
129
+ page = null;
130
+ userDataDir = null;
131
+ constructor(options) {
132
+ this.options = options;
133
+ this.log = options.log ?? (() => { });
134
+ // Built once and reused: `postCommand` reads `this.page` at call time, so the bridge survives
135
+ // the page being replaced by a later `loadGame()`, and the transport handed out by `transport`
136
+ // stays valid across it.
137
+ this.pageBridge = this.buildBridge();
138
+ this.forgeTransport = createForgePageTransport(this.pageBridge, {
139
+ log: this.log,
140
+ ...options.transportOptions,
141
+ });
142
+ }
143
+ /**
144
+ * The pipeline's `ForgeDeps.transport`. Available before `loadGame()` only so the caller can
145
+ * build its deps in one place; using it earlier fails as a transport failure (null), because
146
+ * `postCommand` below refuses without a page.
147
+ */
148
+ get transport() {
149
+ return this.forgeTransport;
150
+ }
151
+ /**
152
+ * Subscribe to every message the page posts, returning an unsubscribe.
153
+ *
154
+ * Exists for PROGRESS REPORTING, which the transport deliberately does not do: its
155
+ * `progressType` option only re-arms an idle timer, so the 20-minute level bake would otherwise
156
+ * run to completion without printing a single line and a creator would conclude it had hung and
157
+ * kill it mid-bake. The command subscribes here and prints the bake's own
158
+ * `VOXELIZE_GLB_AS_LEVEL_PROGRESS` chunk counts.
159
+ */
160
+ onPageMessage(listener) {
161
+ return this.pageBridge.onMessage(listener);
162
+ }
163
+ buildBridge() {
164
+ return {
165
+ postCommand: async (command) => {
166
+ const page = this.page;
167
+ if (!page)
168
+ throw new Error('No game page is open — call loadGame() first.');
169
+ await page.evaluate((message) => {
170
+ // Runs inside the browser, where `window` is a real global. The CLI's own eslint config
171
+ // declares no DOM globals for the Node process it actually lints.
172
+ // eslint-disable-next-line no-undef
173
+ window.postMessage({ type: message.type, data: message.data }, '*');
174
+ }, command);
175
+ },
176
+ onMessage: (listener) => {
177
+ this.listeners.add(listener);
178
+ return () => this.listeners.delete(listener);
179
+ },
180
+ };
181
+ }
182
+ dispatch(message) {
183
+ if (!isRecord(message))
184
+ return;
185
+ for (const listener of [...this.listeners])
186
+ listener(message);
187
+ }
188
+ async launch() {
189
+ const args = forgeLaunchArgs(process.env);
190
+ this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitmagic-forge-chrome-'));
191
+ try {
192
+ // `channel: 'chrome'` drives the creator's installed Chrome, the same choice `verify` makes
193
+ // so that installing the CLI stays a small npm install with no browser download.
194
+ this.context = await chromium.launchPersistentContext(this.userDataDir, {
195
+ headless: true,
196
+ channel: 'chrome',
197
+ args,
198
+ viewport: { width: 1280, height: 720 },
199
+ bypassCSP: true,
200
+ });
201
+ }
202
+ catch {
203
+ // Not a forge failure. Saying so plainly matters: an agent told "the forge failed" would
204
+ // start editing game code to fix a missing browser.
205
+ throw new CliError('Could not launch Google Chrome. `bitmagic forge` bakes the world in the Chrome ' +
206
+ 'installed on this machine — install it from https://www.google.com/chrome/ and try again.');
207
+ }
208
+ this.log('Forge browser launched (headless Chrome).');
209
+ }
210
+ /**
211
+ * Open the game page and complete the `LOAD_GAME` → `GAME_LOADED` handshake. Every forge command
212
+ * sent before this resolves would be queued by the engine, not executed (precondition 2).
213
+ */
214
+ async loadGame() {
215
+ const context = this.context;
216
+ if (!context)
217
+ throw new CliError('ForgeBrowserHost.launch() must run before loadGame().');
218
+ await this.closePage();
219
+ const page = await context.newPage();
220
+ this.page = page;
221
+ // A silent renderer crash is otherwise indistinguishable from a slow bake.
222
+ page.on('pageerror', (error) => this.log(`[browser pageerror] ${error.message}`));
223
+ page.on('console', (message) => {
224
+ // Errors always. Everything else only under BITMAGIC_FORGE_VERBOSE, because the engine's
225
+ // handlers narrate themselves at console.log level and a bake is thousands of lines.
226
+ //
227
+ // That verbosity switch is not a convenience: several engine handlers bail with a plain
228
+ // `if (!ctx) return;` and post NO reply (see `validateSceneContext` in
229
+ // VoxelMessageHandlers.ts). From this side that is indistinguishable from a message that
230
+ // never arrived — the transport just waits out its idle timeout. When a step times out, the
231
+ // engine's own log line for that handler is the only evidence of which side went quiet.
232
+ if (message.type() === 'error') {
233
+ this.log(`[browser console error] ${message.text()}`);
234
+ }
235
+ else if (process.env.BITMAGIC_FORGE_VERBOSE === 'true') {
236
+ this.log(`[browser ${message.type()}] ${message.text()}`);
237
+ }
238
+ });
239
+ await page.exposeFunction(MESSAGE_BRIDGE, (message) => this.dispatch(message));
240
+ await page.addInitScript((bridgeName) => {
241
+ /* eslint-disable no-undef -- this callback body runs in the browser, not in the CLI's Node process. */
242
+ window.addEventListener('message', (event) => {
243
+ const forward = window[bridgeName];
244
+ // Looked up per message, not captured: exposeFunction installs it via its own init script,
245
+ // which may run after this one.
246
+ if (typeof forward === 'function')
247
+ forward(event.data);
248
+ });
249
+ /* eslint-enable no-undef */
250
+ }, MESSAGE_BRIDGE);
251
+ const timeoutMs = this.options.gameLoadedTimeoutMs ?? DEFAULT_GAME_LOADED_TIMEOUT_MS;
252
+ // BOTH waits are subscribed before `goto`, because both signals can arrive during page load.
253
+ const templateReady = this.waitForPageMessage('GAME_TEMPLATE_READY', DEFAULT_TEMPLATE_READY_TIMEOUT_MS, `The game page never reported GAME_TEMPLATE_READY within ${DEFAULT_TEMPLATE_READY_TIMEOUT_MS}ms, ` +
254
+ 'so it never finished booting. Run `bitmagic verify` to see what broke on startup.');
255
+ const gameLoaded = this.waitForPageMessage('GAME_LOADED', timeoutMs, `The game did not report GAME_LOADED within ${timeoutMs}ms. Nothing can be forged ` +
256
+ 'until it loads — run `bitmagic verify` to see what broke on startup.');
257
+ // Swallow late rejections so an earlier throw on the navigation path below (e.g. `goto`
258
+ // timing out) cannot escape as an unhandledRejection with no handler attached yet.
259
+ templateReady.promise.catch(() => undefined);
260
+ gameLoaded.promise.catch(() => undefined);
261
+ const url = forgePageUrl(this.options.gamePort, this.options.gameId);
262
+ this.log(`Loading ${this.options.gameId} for forging: ${url}`);
263
+ try {
264
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
265
+ // Wait for the engine to say it is listening before posting LOAD_GAME. `domcontentloaded` is
266
+ // NOT enough: the engine registers its `message` listener in `init()`
267
+ // (`game/src/engine/GameTemplate.ts:248`), which the module IIFE reaches only after
268
+ // `await initI18n()` — strictly later. `index.html` buffers nothing, and
269
+ // `CreatorMessageHandler`'s `pendingMessages` queue cannot help because it lives BEHIND the
270
+ // listener that does not exist yet, so a LOAD_GAME landing in that window is dropped
271
+ // outright and the only symptom is a GAME_LOADED timeout pointing at the wrong culprit.
272
+ // `GAME_TEMPLATE_READY` is posted on the line right after the listener is registered
273
+ // (:249) and is what the real Creator waits on
274
+ // (`creator/src/vue/composables/useIframeMessages.ts:1617`).
275
+ //
276
+ // `lab/browser.ts` does not hit this only by luck: its `await fetch('/api/get-game-config')`
277
+ // sits between goto and LOAD_GAME and provides accidental slack. This lane reads world.json
278
+ // from disk, so that slack is gone and the race is real.
279
+ await templateReady.promise;
280
+ await page.evaluate((payload) => {
281
+ // eslint-disable-next-line no-undef -- browser-side callback, see above.
282
+ window.postMessage({
283
+ type: 'LOAD_GAME',
284
+ data: {
285
+ gameId: payload.gameId,
286
+ gameData: payload.gameData,
287
+ skipMenu: true,
288
+ agentUrl: payload.agentUrl,
289
+ },
290
+ }, '*');
291
+ }, {
292
+ gameId: this.options.gameId,
293
+ gameData: this.options.gameData,
294
+ agentUrl: this.options.agentUrl,
295
+ });
296
+ await gameLoaded.promise;
297
+ this.log(`Game loaded in the forge browser (${this.options.gameId}).`);
298
+ }
299
+ finally {
300
+ templateReady.cancel();
301
+ gameLoaded.cancel();
302
+ }
303
+ }
304
+ /**
305
+ * Wait for one handshake message. Unlike the transport's waits, these REJECT on timeout: a page
306
+ * that never boots or a game that never loads is not a "no answer" the caller can shrug off and
307
+ * retry — nothing downstream can run.
308
+ */
309
+ waitForPageMessage(type, timeoutMs, timeoutMessage) {
310
+ let timer;
311
+ let unsubscribe;
312
+ const cancel = () => {
313
+ if (timer)
314
+ clearTimeout(timer);
315
+ unsubscribe?.();
316
+ };
317
+ const promise = new Promise((resolve, reject) => {
318
+ timer = setTimeout(() => {
319
+ cancel();
320
+ reject(new CliError(timeoutMessage));
321
+ }, timeoutMs);
322
+ unsubscribe = this.pageBridge.onMessage((message) => {
323
+ if (message.type !== type)
324
+ return;
325
+ cancel();
326
+ resolve();
327
+ });
328
+ });
329
+ return { promise, cancel };
330
+ }
331
+ async closePage() {
332
+ if (!this.page)
333
+ return;
334
+ const page = this.page;
335
+ this.page = null;
336
+ await page.close();
337
+ }
338
+ async close() {
339
+ await this.closePage();
340
+ if (this.context) {
341
+ const context = this.context;
342
+ this.context = null;
343
+ await context.close();
344
+ }
345
+ if (this.userDataDir) {
346
+ fs.rmSync(this.userDataDir, { recursive: true, force: true });
347
+ this.userDataDir = null;
348
+ }
349
+ }
350
+ }
351
+ //# sourceMappingURL=browser-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-host.js","sourceRoot":"","sources":["../../src/forge/browser-host.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import type { ForgeStorageClient, ForgeStorageEnv } from '@bitmagic/world-forger/pipeline/index.js';
2
+ /** Where forge job artifacts live, relative to the project root. `.bitmagic/` is gitignored by
3
+ * the scaffold (see scaffold/project-files.ts) and already holds `verify/`'s artifacts. */
4
+ export declare const FORGE_STORE_DIR: string;
5
+ /**
6
+ * Resolve one storage key to a file path, refusing anything that would escape the store root.
7
+ *
8
+ * The keys this store sees are built by the shared package from a jobId api-server already
9
+ * validated against `FORGE_JOB_ID_PATTERN`, so this can never fire in practice today. It is here
10
+ * because the check is what makes that a *fact about the current callers* rather than a
11
+ * *property of this module*: a key is untrusted input the moment anything else builds one, and
12
+ * "it happens to be safe upstream" is not a guarantee a filesystem writer should rely on.
13
+ * Refuses loudly — it is never correct to silently write somewhere else.
14
+ */
15
+ export declare function resolveStoreKeyPath(rootDir: string, key: string): string;
16
+ /**
17
+ * A `ForgeStorageClient` over the local filesystem. `bucket` is an absolute DIRECTORY here
18
+ * rather than a bucket name — the seam is structural (see the package's storage-types.ts), and a
19
+ * directory is exactly the "namespace a key is resolved against" the field means.
20
+ */
21
+ export declare function createLocalForgeStorageClient(): ForgeStorageClient;
22
+ /**
23
+ * The `ForgeStorageEnv` steps 3-5 run against, rooted in this project.
24
+ *
25
+ * `publicUrl` is a `file://` URL for the store directory. Nothing in steps 3-5 reads it — it
26
+ * exists for step 2's uploads, which run on api-server — but the seam declares it required, and
27
+ * the honest value for a local store is where those bytes actually are. (`storage-types.ts`
28
+ * names `file://` as an expected form.)
29
+ */
30
+ export declare function createLocalForgeStorage(projectRoot: string): ForgeStorageEnv;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The CLI lane's `ForgeDeps.storage` — a LOCAL, project-scoped artifact store under
3
+ * `<root>/.bitmagic/forge/`, not the GCS bucket api-server writes to.
4
+ *
5
+ * ── Why local, and why that is not a shortcut ─────────────────────────────────────────────
6
+ *
7
+ * The obvious reading of "storage must reach the job's artifacts" is that the CLI needs
8
+ * credentialed access to `PORTAL_WEBSITE_BUCKET`. It does not, and giving a creator's machine a
9
+ * bucket credential would be a real regression in the security posture of this lane — the CLI
10
+ * authenticates as a *user* through Auth0 and every privileged action it takes is mediated by
11
+ * api-server, which is what makes ownership checks and spark billing enforceable at all.
12
+ *
13
+ * It is unnecessary because of what steps 3-5 actually read from the store. Exhaustively:
14
+ *
15
+ * | artifact | who writes it | how the CLI gets it |
16
+ * |-----------------------|--------------------------|-----------------------------------------|
17
+ * | `forge` | step 2, on api-server | the terminal SSE frame carries it |
18
+ * | `input` | the route, at job start | it IS this CLI's own frozen request |
19
+ * | `platformer-movement` | step 1, on api-server | the terminal SSE frame carries it |
20
+ * | `archetype-progress` | step 3, HERE | written and read locally |
21
+ * | `archetype-assets` | step 3, HERE | written and read locally |
22
+ * | `level-bake` | step 4, HERE | written and read locally |
23
+ * | `persisted` | the host, HERE | written locally |
24
+ *
25
+ * `forge` arrives in the terminal frame on a fresh run AND on a resume — `forgeWorld` ends every
26
+ * successful run with `store.get<ForgeArtifact>('forge')` regardless of whether either step was
27
+ * skipped (`api-server/src/cli/cli-forge.ts`). `platformer-movement` rides the same frame, as an
28
+ * optional field, and `seedForgeStore` writes both into this store. So the two artifacts that
29
+ * genuinely cross the machine boundary travel over the wire, and the other four are produced and
30
+ * consumed entirely on this side. Round-tripping those four through a remote bucket would add
31
+ * latency and a write credential in exchange for nothing.
32
+ *
33
+ * ── The two caveats, stated rather than hidden ────────────────────────────────────────────
34
+ *
35
+ * 1. An api-server deployed BEFORE `platformerMovement` was added to the terminal frame sends
36
+ * no such field, and the CLI and api-server ship independently — so a platformer level
37
+ * forged against an older server still persists without its `worldForgerMovement` contract.
38
+ * `placeAndPersistLevel` reads it with `store.get` (not `requireArtifact`) and omits the
39
+ * field when absent, so this degrades rather than fails, and
40
+ * `warnIfPlatformerMovementMissing` says so out loud before the level is written.
41
+ * 2. A resume from a DIFFERENT machine (or after `.bitmagic/` is deleted) re-runs steps 3-5
42
+ * from scratch. It still skips steps 1-2, which are the paid ones — the server's own store
43
+ * is what makes that work — so the expensive half of a resume is unaffected. A platformer
44
+ * resume does re-fetch `platformer-movement`, since it rides the terminal frame the resumed
45
+ * request returns.
46
+ */
47
+ import * as fs from 'fs';
48
+ import * as path from 'path';
49
+ import { CliError } from '../errors.js';
50
+ /** Where forge job artifacts live, relative to the project root. `.bitmagic/` is gitignored by
51
+ * the scaffold (see scaffold/project-files.ts) and already holds `verify/`'s artifacts. */
52
+ export const FORGE_STORE_DIR = path.join('.bitmagic', 'forge');
53
+ /**
54
+ * Resolve one storage key to a file path, refusing anything that would escape the store root.
55
+ *
56
+ * The keys this store sees are built by the shared package from a jobId api-server already
57
+ * validated against `FORGE_JOB_ID_PATTERN`, so this can never fire in practice today. It is here
58
+ * because the check is what makes that a *fact about the current callers* rather than a
59
+ * *property of this module*: a key is untrusted input the moment anything else builds one, and
60
+ * "it happens to be safe upstream" is not a guarantee a filesystem writer should rely on.
61
+ * Refuses loudly — it is never correct to silently write somewhere else.
62
+ */
63
+ export function resolveStoreKeyPath(rootDir, key) {
64
+ const base = path.resolve(rootDir);
65
+ const target = path.resolve(base, key);
66
+ if (target !== base && !target.startsWith(base + path.sep)) {
67
+ throw new CliError(`Refusing to write forge artifact outside ${base}: the key "${key}" escapes it.`);
68
+ }
69
+ return target;
70
+ }
71
+ /**
72
+ * A `ForgeStorageClient` over the local filesystem. `bucket` is an absolute DIRECTORY here
73
+ * rather than a bucket name — the seam is structural (see the package's storage-types.ts), and a
74
+ * directory is exactly the "namespace a key is resolved against" the field means.
75
+ */
76
+ export function createLocalForgeStorageClient() {
77
+ return {
78
+ async putObject(params) {
79
+ const file = resolveStoreKeyPath(params.bucket, params.key);
80
+ const body = typeof params.body === 'string' ? new TextEncoder().encode(params.body) : params.body;
81
+ fs.mkdirSync(path.dirname(file), { recursive: true });
82
+ // Written through a temp file in the same directory then renamed, so an interrupted write
83
+ // cannot leave a truncated artifact behind. A truncated `archetype-assets` would fail a
84
+ // resume with a JSON parse error rather than re-baking, which is the worse outcome.
85
+ const temp = `${file}.${process.pid}.tmp`;
86
+ fs.writeFileSync(temp, body);
87
+ fs.renameSync(temp, file);
88
+ return body.length;
89
+ },
90
+ async headObject(params) {
91
+ return fs.existsSync(resolveStoreKeyPath(params.bucket, params.key));
92
+ },
93
+ async getObject(params) {
94
+ const file = resolveStoreKeyPath(params.bucket, params.key);
95
+ // "Absent" is the seam's `null`. Anything else (a permissions error, a directory where a
96
+ // file belongs) is a real failure and must propagate rather than be reported as "missing",
97
+ // which would make a resume silently re-bake instead of naming what is wrong.
98
+ if (!fs.existsSync(file))
99
+ return null;
100
+ return new Uint8Array(fs.readFileSync(file));
101
+ },
102
+ };
103
+ }
104
+ /**
105
+ * The `ForgeStorageEnv` steps 3-5 run against, rooted in this project.
106
+ *
107
+ * `publicUrl` is a `file://` URL for the store directory. Nothing in steps 3-5 reads it — it
108
+ * exists for step 2's uploads, which run on api-server — but the seam declares it required, and
109
+ * the honest value for a local store is where those bytes actually are. (`storage-types.ts`
110
+ * names `file://` as an expected form.)
111
+ */
112
+ export function createLocalForgeStorage(projectRoot) {
113
+ const dir = path.join(projectRoot, FORGE_STORE_DIR);
114
+ try {
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ }
117
+ catch (error) {
118
+ // A read-only checkout or a permissions problem is creator-actionable, so it gets a designed
119
+ // message rather than an EACCES stack trace from several frames down. Named here because this
120
+ // is the one condition in the pre-request span that would otherwise surface as a plain Error.
121
+ const detail = error instanceof Error ? error.message : String(error);
122
+ throw new CliError(`Cannot create ${dir}, where a forge keeps its resumable state: ${detail}`);
123
+ }
124
+ return {
125
+ storageClient: createLocalForgeStorageClient(),
126
+ bucket: dir,
127
+ publicUrl: `file://${dir}`,
128
+ };
129
+ }
130
+ //# sourceMappingURL=local-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-store.js","sourceRoot":"","sources":["../../src/forge/local-store.ts"],"names":[],"mappings":"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"}