@genex-ai/cli-demo 0.94.0-dev.251 → 0.95.0-dev.252

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/index.js CHANGED
@@ -255,26 +255,23 @@ var GENEX_CONTRACT_BLOCK = `${CONTRACT_BEGIN}
255
255
  Your capabilities (all via \`npx genex \u2026\`): generate \`model\` \xB7 \`skybox\` \xB7 \`sfx\` \xB7 \`music\` \xB7 \`voice\` \xB7 \`texture\` \xB7 \`image\` (\`--edit\` \xB7 \`--inpaint\` \xB7 \`--glass\` \xB7 \`--clean\` \xB7 \`--upscale\`) \xB7 \`video\` \xB7 rigged \`character\` / \`creature\` \xB7 \`character animate <id> "<verb>"\` (also \`creature animate\`; \`--locomotion\` for the 8-way movement set, \`--video\` for your own footage) \xB7 the pixel toolbox \`ui extract|masks|plate|text-color|trim|audit\` \xB7 vendored \`controller character|car|drone|touch|quality\` \xB7 \`animations search\` \xB7 \`wait <id>\` / \`wait --all\` \xB7 \`preview\` / \`publish\`. Full options: \`npx genex --help\`. Task\u2192lane routing lives in the \`genex-game-director\` skill's routing map \u2014 re-load it whenever you're unsure which lane owns a task.
256
256
 
257
257
  1. Load the \`genex-game-director\` skill before starting the requested work. Route from the player's latest clear request: a focused request starts directly without replaying discovery or commissioning unrelated lanes. After ANY context compaction or session resume, re-read this file and \`DESIGN.md\`, re-load the skill for the stage you are executing, and continue from the Build plan's \`Now:\` line \u2014 never from memory alone.
258
- 2. Ask only when one unresolved answer materially changes the work. If the request is clear, do not repeat an interview, confirm the pitch, force a concept round, or ask whole-game-vs-one-part again. For a genuinely broad new game, ask one decision: whole coordinated build or one request-relevant part first. If there is no clear request, ask whether the player wants a game, a tool/component, or a change to existing work \u2014 never fill the blank by pitching concepts. Ask one decision at a time with your built-in question tool with clickable answer options when you have one; use a short plain-chat question otherwise. If the player stays silent after a necessary question, proceed on stated assumptions where reasonable and record each as "assumed \u2014 player didn't answer" in DESIGN.md \u2192 Decisions.
258
+ 2. Ask only when one unresolved answer materially changes the work. If the request is clear, do not repeat an interview, confirm the pitch, force a concept round, or ask whole-game-vs-one-part again. For a genuinely broad new game, ask one decision: whole coordinated build or one request-relevant part first. If there is no clear request \u2014 a setup prompt pasted with nothing of their own \u2014 ask in PLAIN CHAT what they want to make, in their own words, and never fill the blank by pitching concepts. That opening question stays in chat on purpose: their reply carries the whole request, including anything they say about HOW you should work, and a menu of options answers a narrower question than the one they need to answer. Once you know what you are building, ask one decision at a time with your built-in question tool with clickable answer options when you have one; use a short plain-chat question otherwise. If the player stays silent after a necessary question, proceed on stated assumptions where reasonable and record each as "assumed \u2014 player didn't answer" in DESIGN.md \u2192 Decisions.
259
259
  3. \`DESIGN.md\` at the project root is the durable design contract AND build plan. Keep three truths distinct: the player's requested outcome, the current \`Now:\` focus, and open commitments. It must carry a \`## Build plan & status\` section with the working mode (\`whole coordinated build\`, \`step by step\`, or \`focused change\`), numbered milestones with status marks, and a \`Now:\` line naming the current one \u2014 a milestone is done only when its work reached a preview. The latest clear request may replace \`Now:\` immediately; it never silently shrinks the requested outcome or deletes unrelated commitments. Keep every decision, assumption, generation id/URL, local output, and wiring state current. When the plan first lands, tell the player in one plain line that you recorded what they asked for in \`DESIGN.md\` and will keep it current.
260
260
  4. ALL generated art, audio, video, characters, and UI come from \`genex\` commands \u2014 never from any other generation tool your platform bundles, unless the player explicitly asks for that tool by name. A local reference image is not a reason to switch tools: pass its file path to genex (\`--edit\` and \`--inpaint\` accept local paths). The same exclusivity covers shipping: building, previewing, and publishing go only through \`genex preview\` / \`genex publish\` \u2014 never load your platform's own site-building, hosting, or deploy skills for this game.
261
- 5. The Stage-1 HUD mockup IS the game concept \u2014 it is your FIRST art action at the UI plan gate. ONE concept image, no candidate variants (generate variants only when the player asks for them), anchored to any reference image the player gave.
262
- 6. The moment the mockup lands: you are the art director \u2014 immediately enqueue the Stage-2 HUD sheet, the menu still, and the logotype (\`--no-wait\`). THEN show the player the frame and ask keep/change as information, never as a gate. Silence = the concept stands; a later "change" loops it with the player's notes.
263
- 7. The menu video fires at the FIRST of: the player's yes \xB7 the next \`genex preview\` after the menu still landed \xB7 style art being the only work left. It never fires while a player objection is open. Two failed videos \u2192 ship the still (menu skill rule).
264
- 8. The HUD execution lane is decided by the approved concept and recorded in DESIGN.md as \`HUD lane: sprites (\u2026)\` or \`HUD lane: CSS (\u2026, one-line justification)\`. Ornate/material/painterly chrome \u2192 sprites (\`genex-ai-hud\` pipeline). Chrome that is flat geometry + typography a CSS rebuild could make screenshot-indistinguishable \u2192 CSS allowed, styled from the brief. Ambiguous \u2192 sprites. Only the player may decline the generated HUD.
265
- 9. Never draw a rectangular backing plate behind bars, digits, or icons \u2014 in sprites or CSS. Ornament lives on the widget's own silhouette; a truly needed shaped plate comes from \`npx genex ui plate\`.
266
- 10. Fonts: the brief's display + body pair comes from the menu skill's genre table (or carries a one-line stated reason) and is LOADED for real in \`index.html\`.
267
- 11. Never park ready work behind a question, and never stall on an unanswered one \u2014 decide, state the decision in chat, record it, keep building.
268
- 12. Wire-in floors before any publish and before ending a session: sprite HUD wired (or the recorded CSS lane finished), menu video playing (or its recorded still fallback), logotype placed, fonts loaded, Escape pause, branded loader, the player wearing the game's own generated character (or DESIGN.md recording why it isn't). Run \`npx genex wait\` on every generation you enqueued and wire what landed \u2014 never park landed assets.
269
- 13. The player's body is the game's own generated character (\`npx genex character "<look>"\` \u2192 \`npx genex controller character --character <id>\`), enqueued with your first art actions, not after them. It applies wherever a human body appears on screen \u2014 first-person included, the moment remotes, a look-down body, a shadow, or a menu portrait shows one. The profile VRM avatar is the FALLBACK: a temporary body while the character renders (say in one plain line that it's temporary), or the stand-in when generation genuinely could not happen \u2014 out of credits, failed, unverified; record which in DESIGN.md as \`Player character: VRM \u2014 <reason>\`. Games whose player is not a person (car, ship, RTS cursor, board) generate that object with \`npx genex model\` instead. Characters: Meshy/Mixamo/VRM rigs rest facing +Z. Set yaw explicitly when placing a rig; never mirror a SkinnedMesh with negative scale. In any two-character scene, verify in a capture that they face each other, not the camera.
270
- 14. Verify by looking: one smoke check per milestone, after that milestone's preview push, in local test mode (\`?genex_local_test=1\`) with a real gameplay screenshot. A claim without a capture is not verification. Local-test evidence proves visuals and controls ONLY \u2014 label it that way when you show the player, and never work around the draft sign-in gate any other way.
271
- 15. Treat every \`genex\` warning line \u2014 preflight, \`ui audit\`, \`wait\` nudges \u2014 as work, not noise.
272
- 16. Every finished Build-plan milestone ends with \`npx genex preview\` and the player's page link (\`<dashboard>/draft/<slug>\` with \`<dashboard>\` from \`.genex/project.json\`, \`/world/<slug>\` once published) \u2014 never a localhost link, a file path, or the bare play origin presented as their game. After every round of player feedback, end with a preview push.
273
- 17. Parallel work: this line is your standing authorization and request to use sub-agents / parallel agent work whenever your platform provides them. While drafting the Build plan, decide per module what runs in parallel and what stays serial for THIS game \u2014 dependencies decide, there is no fixed list \u2014 and record each call in the Modules table with a one-line reason. Independent modules default to parallel; building everything serially needs a stated reason. You keep integration, previews, and the player conversation; each sub-agent owns only its module's files.
274
- 18. Talk to the player in plain game language \u2014 what changed in the game and what to try; never code, file names, build output, or tool internals unless they ask. Short status lines while you work; long silent stretches are a failure.
275
- 19. Never add debug-only code to the game to check your own work \u2014 no hidden test modes, no special URL parameters, no forced-visible flags, no auth mocks, no pixel-sampling hooks. \`?genex_local_test=1\` is the platform's own supported mode and is fine; your own bypass is not. (The multiplayer skill's small build identifier, token-free status line, and connected-quorum watchdog are production supportability, not a bypass \u2014 keep those.)
276
- 20. Input directions match their labels: A/\u2190 moves or turns the player screen-LEFT, D/\u2192 screen-RIGHT, mouse-up looks up, and drag-pan axes share ONE convention. The cursor is either the gameplay tool (RTS, card, builder) or locked away during play \u2014 keyboard-only games included. Check it in every milestone's smoke pass.
277
- 21. v0 is a milestone, not the destination. When the ask was bigger than one loop, every milestone after v0 grows back toward the FULL ask with DESIGN.md's content lines as the checklist \u2014 a slice that previewed well never quietly becomes the game. Cosmetics never jump the queue past promised content.
261
+ 5. Generated UI art is a tool you reach for, not a pipeline you owe. A restrained interface built in clean CSS is a finished, legitimate HUD \u2014 not a placeholder. Reach for the sprite lane (\`genex-ai-hud\`) when the game's own style genuinely wants drawn chrome \u2014 ornate, painterly, comic, hand-made \u2014 or when the player asks for HUD art. Generating ONE element you decided the game needs \u2014 a frame, a mask, an icon, a wordmark, a menu backdrop, a menu video \u2014 is a normal use of these tools, never a half-run pipeline. There is NO global game-concept image and no UI plan recited in chat: the art direction lives in the game's brief in words. A lane's own concept step survives only where the player is choosing a concrete thing (a character's candidates). Whatever you do generate, run its quality steps in full \u2014 extraction, masks, wiring, \`npx genex ui audit\`.
262
+ 6. Never draw a rectangular backing plate behind bars, digits, or icons \u2014 in sprites or CSS. Ornament lives on the widget's own silhouette; a truly needed shaped plate comes from \`npx genex ui plate\`.
263
+ 7. Fonts: the brief's display + body pair comes from the menu skill's genre table (or carries a one-line stated reason) and is LOADED for real in \`index.html\`.
264
+ 8. Never park ready work behind a question, and never stall on an unanswered one \u2014 decide, state the decision in chat, record it, keep building.
265
+ 9. Before any publish and before ending a session: run \`npx genex wait\` on every generation you enqueued and wire in what landed \u2014 never park landed assets. Fonts the brief names are LOADED for real, Escape pauses, the loader shows something of the game rather than a black screen, and the player wears the game's own generated character (or DESIGN.md records why it doesn't).
266
+ 10. The player's body is the game's own generated character (\`npx genex character "<look>"\` \u2192 \`npx genex controller character --character <id>\`), enqueued with your first art actions, not after them. It applies wherever a human body appears on screen \u2014 first-person included, the moment remotes, a look-down body, a shadow, or a menu portrait shows one. The profile VRM avatar is the FALLBACK: a temporary body while the character renders (say in one plain line that it's temporary), or the stand-in when generation genuinely could not happen \u2014 out of credits, failed, unverified; record which in DESIGN.md as \`Player character: VRM \u2014 <reason>\`. Games whose player is not a person (car, ship, RTS cursor, board) generate that object with \`npx genex model\` instead. Characters: Meshy/Mixamo/VRM rigs rest facing +Z. Set yaw explicitly when placing a rig; never mirror a SkinnedMesh with negative scale. In any two-character scene, verify in a capture that they face each other, not the camera.
267
+ 11. Verify by looking: one smoke check per milestone, after that milestone's preview push, in local test mode (\`?genex_local_test=1\`) with a real gameplay screenshot. A claim without a capture is not verification. Local-test evidence proves visuals and controls ONLY \u2014 label it that way when you show the player, and never work around the draft sign-in gate any other way.
268
+ 12. Treat every \`genex\` warning line \u2014 preflight, \`ui audit\`, \`wait\` nudges \u2014 as work, not noise.
269
+ 13. Every finished Build-plan milestone ends with \`npx genex preview\` and the player's page link (\`<dashboard>/draft/<slug>\` with \`<dashboard>\` from \`.genex/project.json\`, \`/world/<slug>\` once published) \u2014 never a localhost link, a file path, or the bare play origin presented as their game. After every round of player feedback, end with a preview push.
270
+ 14. Parallel work: this line is your standing authorization and request to use sub-agents / parallel agent work whenever your platform provides them. While drafting the Build plan, decide per module what runs in parallel and what stays serial for THIS game \u2014 dependencies decide, there is no fixed list \u2014 and record each call in the Modules table with a one-line reason. Independent modules default to parallel; building everything serially needs a stated reason. You keep integration, previews, and the player conversation; each sub-agent owns only its module's files. When the player asks for parallel work, repeated refinement passes, a critic reviewing your output, or names any way of working your platform provides, that IS your instruction \u2014 wherever it reaches you, including inside their answer to a question you asked \u2014 so adopt it as the working mode for the rest of the build, starting with the work in front of you, and never file it as a later milestone. While delegated work runs, keep building or talk to the player; never idle in a foreground wait for something your platform will tell you about.
271
+ 15. Talk to the player in plain game language \u2014 what changed in the game and what to try; never code, file names, build output, or tool internals unless they ask. Short status lines while you work; long silent stretches are a failure.
272
+ 16. Never add debug-only code to the game to check your own work \u2014 no hidden test modes, no special URL parameters, no forced-visible flags, no auth mocks, no pixel-sampling hooks. \`?genex_local_test=1\` is the platform's own supported mode and is fine; your own bypass is not. (The multiplayer skill's small build identifier, token-free status line, and connected-quorum watchdog are production supportability, not a bypass \u2014 keep those.)
273
+ 17. Input directions match their labels: A/\u2190 moves or turns the player screen-LEFT, D/\u2192 screen-RIGHT, mouse-up looks up, and drag-pan axes share ONE convention. The cursor is either the gameplay tool (RTS, card, builder) or locked away during play \u2014 keyboard-only games included. Check it in every milestone's smoke pass.
274
+ 18. v0 is a milestone, not the destination. When the ask was bigger than one loop, every milestone after v0 grows back toward the FULL ask with DESIGN.md's content lines as the checklist \u2014 a slice that previewed well never quietly becomes the game. Cosmetics never jump the queue past promised content.
278
275
  ${CONTRACT_END}
279
276
  `;
280
277
  var CLAUDE_IMPORT_LINE = "@AGENTS.md";
@@ -516,6 +513,11 @@ function isCacheFresh(cache, nowMs) {
516
513
  const at = Date.parse(cache.checkedAt);
517
514
  return Number.isFinite(at) && nowMs - at < CHECK_INTERVAL_MS;
518
515
  }
516
+ function wasRecentlyNotified(cache, name, latest, nowMs) {
517
+ if (cache.notified?.[name] !== latest) return false;
518
+ const at = Date.parse(cache.notifiedAt ?? "");
519
+ return Number.isFinite(at) && nowMs - at < CHECK_INTERVAL_MS;
520
+ }
519
521
  async function readUpdateCache() {
520
522
  try {
521
523
  const raw = await fs4.readFile(getUpdateCachePath(), "utf8");
@@ -551,7 +553,12 @@ function startUpdateCheck() {
551
553
  results.forEach((r, i) => {
552
554
  if (r.status === "fulfilled" && r.value) latest[PUBLISHED_PACKAGES[i].name] = r.value;
553
555
  });
554
- const next = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), latest };
556
+ const next = {
557
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
558
+ latest,
559
+ ...cached?.notified ? { notified: cached.notified } : {},
560
+ ...cached?.notifiedAt ? { notifiedAt: cached.notifiedAt } : {}
561
+ };
555
562
  await fs4.mkdir(getGenexDir(), { recursive: true });
556
563
  await fs4.writeFile(getUpdateCachePath(), JSON.stringify(next, null, 2) + "\n");
557
564
  return next;
@@ -572,20 +579,24 @@ async function installedPackageVersion(cwd, name) {
572
579
  function formatNudge(label, latest, installed, install) {
573
580
  return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install}`;
574
581
  }
575
- async function reportUpdateNudges(check, log, cwd = process.cwd()) {
582
+ async function reportUpdateNudges(check, log, cwd = process.cwd(), cachePath = getUpdateCachePath()) {
576
583
  try {
577
584
  const cache = await check;
578
585
  if (!cache) return;
586
+ const now = Date.now();
579
587
  const lines = [];
588
+ const announced = {};
580
589
  let whatsNew = null;
581
590
  for (const pkg of PUBLISHED_PACKAGES) {
582
591
  if (pkg.name === "@genex-ai/cli-demo" && CLI_CHANNEL === "dev") continue;
583
592
  const latest = cache.latest[pkg.name];
584
593
  if (!latest) continue;
594
+ if (wasRecentlyNotified(cache, pkg.name, latest, now)) continue;
585
595
  const installed = pkg.name === "@genex-ai/cli-demo" ? getCliVersion() : await installedPackageVersion(cwd, pkg.name);
586
596
  if (!installed) continue;
587
597
  if (isNewerVersion(latest, installed)) {
588
598
  lines.push(formatNudge(pkg.label, latest, installed, pkg.install));
599
+ announced[pkg.name] = latest;
589
600
  whatsNew ??= `https://www.npmjs.com/package/${pkg.name}`;
590
601
  }
591
602
  }
@@ -594,6 +605,19 @@ async function reportUpdateNudges(check, log, cwd = process.cwd()) {
594
605
  log.dim(
595
606
  ` Apply at a safe moment (never mid-task) \u2014 see the genex-updates skill. What's new: ${whatsNew}`
596
607
  );
608
+ await recordNotified(cache, announced, cachePath);
609
+ } catch {
610
+ }
611
+ }
612
+ async function recordNotified(cache, announced, cachePath) {
613
+ try {
614
+ const next = {
615
+ ...cache,
616
+ notified: { ...cache.notified ?? {}, ...announced },
617
+ notifiedAt: (/* @__PURE__ */ new Date()).toISOString()
618
+ };
619
+ await fs4.mkdir(path4.dirname(cachePath), { recursive: true });
620
+ await fs4.writeFile(cachePath, JSON.stringify(next, null, 2) + "\n");
597
621
  } catch {
598
622
  }
599
623
  }
@@ -984,22 +1008,22 @@ async function createDraftProject(opts) {
984
1008
  } catch (err) {
985
1009
  log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
986
1010
  log.dim(` ${String(err)}`);
987
- return null;
1011
+ return { meta: null };
988
1012
  }
989
1013
  if (res.status === 409 && i === 0) continue;
990
1014
  if (res.status === 401) {
991
1015
  log.warn("Not authorized to create the project (token rejected).");
992
- return null;
1016
+ return { meta: null, unauthorized: true };
993
1017
  }
994
1018
  if (!res.ok) {
995
1019
  log.warn(`Couldn't create the project (HTTP ${res.status}).`);
996
- return null;
1020
+ return { meta: null };
997
1021
  }
998
1022
  const data = await res.json().catch(() => null);
999
1023
  const project = data?.project;
1000
1024
  if (!project || !project.cloneUrl) {
1001
1025
  log.warn("Project created, but the API response was unexpected.");
1002
- return null;
1026
+ return { meta: null };
1003
1027
  }
1004
1028
  log.success(`Created project ${c.cyan(project.slug)}.`);
1005
1029
  log.plain(
@@ -1008,19 +1032,21 @@ async function createDraftProject(opts) {
1008
1032
  if (project.playUrl) log.dim(` play (after preview/publish): ${project.playUrl}`);
1009
1033
  log.dim(` dashboard: ${dashboardUrl}/dashboard`);
1010
1034
  return {
1011
- id: project.id,
1012
- slug: project.slug,
1013
- cloneUrl: project.cloneUrl,
1014
- apiUrl,
1015
- playUrl: project.playUrl ?? void 0,
1016
- status: "draft",
1017
- // Derived client-side from the auth/web origin the CLI just authorized
1018
- // against (respects --auth-url overrides) no API contract change.
1019
- dashboardOrigins: [new URL(dashboardUrl).origin]
1035
+ meta: {
1036
+ id: project.id,
1037
+ slug: project.slug,
1038
+ cloneUrl: project.cloneUrl,
1039
+ apiUrl,
1040
+ playUrl: project.playUrl ?? void 0,
1041
+ status: "draft",
1042
+ // Derived client-side from the auth/web origin the CLI just authorized
1043
+ // against (respects --auth-url overrides) — no API contract change.
1044
+ dashboardOrigins: [new URL(dashboardUrl).origin]
1045
+ }
1020
1046
  };
1021
1047
  }
1022
1048
  log.warn("Couldn't create a project with a unique name. Try `--name <unique>`.");
1023
- return null;
1049
+ return { meta: null };
1024
1050
  }
1025
1051
  function randomSuffix() {
1026
1052
  return crypto2.randomBytes(3).toString("hex");
@@ -1180,6 +1206,17 @@ async function writeUserToken(token, envPath) {
1180
1206
  const { path: written } = await writeEnvVar(getGenexEnvPath(envPath), ENV_TOKEN_KEY, token);
1181
1207
  return { path: written };
1182
1208
  }
1209
+ async function rotateRejectedEnv(envPath) {
1210
+ const file = getGenexEnvPath(envPath);
1211
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1212
+ const aside = `${file}.rejected-${stamp}`;
1213
+ try {
1214
+ await fs7.rename(file, aside);
1215
+ return aside;
1216
+ } catch {
1217
+ return null;
1218
+ }
1219
+ }
1183
1220
  async function readUserToken(envPath) {
1184
1221
  const fromGenex = await readTokenFromFile(getGenexEnvPath(envPath));
1185
1222
  if (fromGenex) return fromGenex;
@@ -1359,6 +1396,7 @@ async function runInit(opts) {
1359
1396
  }
1360
1397
  const authBaseUrl = getAuthUrl(opts.authUrl);
1361
1398
  let token = await readUserToken(opts.envPath);
1399
+ let savedToken = Boolean(token);
1362
1400
  if (!token) {
1363
1401
  try {
1364
1402
  token = await authorize(authBaseUrl, {
@@ -1373,18 +1411,22 @@ async function runInit(opts) {
1373
1411
  const { path: tokenPath } = await writeUserToken(token, opts.envPath);
1374
1412
  log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
1375
1413
  log.plain("");
1414
+ savedToken = false;
1376
1415
  }
1377
1416
  await writeGitignore(process.cwd(), log);
1378
1417
  if (await writeAgentsContract(process.cwd())) {
1379
1418
  log.dim(" wrote the Genex build contract into AGENTS.md (managed block) + CLAUDE.md stub");
1380
1419
  }
1381
1420
  const apiUrl = getApiUrl(opts.apiUrl);
1382
- const signedInEmail = await fetchSignedInEmail(apiUrl, token);
1383
- if (signedInEmail) log.plain(` signed in as ${c.cyan(signedInEmail)}`);
1421
+ const echoIdentity = async () => {
1422
+ const email = await fetchSignedInEmail(apiUrl, token);
1423
+ if (email) log.plain(` signed in as ${c.cyan(email)}`);
1424
+ };
1425
+ await echoIdentity();
1384
1426
  const projectName = opts.name?.trim() || path9.basename(process.cwd());
1385
- const meta = await createDraftProject({
1427
+ const create = (bearer) => createDraftProject({
1386
1428
  apiUrl,
1387
- token,
1429
+ token: bearer,
1388
1430
  name: projectName,
1389
1431
  repoUrl: opts.repo?.trim() || void 0,
1390
1432
  private: opts.private,
@@ -1392,6 +1434,28 @@ async function runInit(opts) {
1392
1434
  dashboardUrl: authBaseUrl,
1393
1435
  log
1394
1436
  });
1437
+ let created = await create(token);
1438
+ if (!created.meta && created.unauthorized && savedToken) {
1439
+ log.plain("");
1440
+ log.warn("Your saved sign-in was rejected \u2014 reconnecting in the browser\u2026");
1441
+ const aside = await rotateRejectedEnv(opts.envPath);
1442
+ if (aside) log.dim(` moved the rejected credential to ${c.cyan(aside)}`);
1443
+ try {
1444
+ token = await authorize(authBaseUrl, {
1445
+ log,
1446
+ timeoutMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
1447
+ });
1448
+ } catch (err) {
1449
+ log.error(err instanceof Error ? err.message : String(err));
1450
+ log.dim("Your workspace files were installed. Re-run `genex init` to finish authorizing.");
1451
+ throw err;
1452
+ }
1453
+ const { path: tokenPath } = await writeUserToken(token, opts.envPath);
1454
+ log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
1455
+ await echoIdentity();
1456
+ created = await create(token);
1457
+ }
1458
+ const meta = created.meta;
1395
1459
  if (!meta) {
1396
1460
  log.plain("");
1397
1461
  log.warn("Setup finished, but creating your game failed \u2014 fix the error above and re-run `genex init`.");
@@ -1644,11 +1708,11 @@ function relTime(iso) {
1644
1708
  }
1645
1709
 
1646
1710
  // src/lib/deploy.ts
1647
- import { spawn as spawn4 } from "child_process";
1711
+ import { spawn as spawn3 } from "child_process";
1648
1712
  import crypto3 from "crypto";
1649
- import fs14 from "fs/promises";
1713
+ import fs12 from "fs/promises";
1650
1714
  import os4 from "os";
1651
- import path14 from "path";
1715
+ import path12 from "path";
1652
1716
 
1653
1717
  // ../../packages/mobile-scan/src/image-dims.ts
1654
1718
  function u32be(b, o) {
@@ -3735,2259 +3799,2153 @@ async function uiAudit(opts, log) {
3735
3799
  if (opts.strict || hard.length > 0) process.exitCode = 1;
3736
3800
  }
3737
3801
 
3738
- // src/lib/generation-ledger.ts
3739
- import fs12 from "fs/promises";
3740
- import path12 from "path";
3741
- var ledgerPath = (cwd) => path12.join(cwd, ".genex", "generations.ndjson");
3742
- async function append(cwd, event) {
3802
+ // src/lib/deploy.ts
3803
+ function run(cmd, args, env) {
3804
+ return new Promise((resolve) => {
3805
+ let child;
3806
+ try {
3807
+ child = spawn3(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
3808
+ } catch {
3809
+ resolve({ code: -1, out: "", err: `${cmd} not found` });
3810
+ return;
3811
+ }
3812
+ let out = "";
3813
+ let err = "";
3814
+ child.stdout?.on("data", (d) => out += String(d));
3815
+ child.stderr?.on("data", (d) => err += String(d));
3816
+ child.on("error", () => resolve({ code: -1, out, err: `${cmd} not found` }));
3817
+ child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
3818
+ });
3819
+ }
3820
+ function printMobilePreflight(files, log) {
3743
3821
  try {
3744
- await fs12.access(path12.join(cwd, ".genex"));
3745
- await fs12.appendFile(ledgerPath(cwd), `${JSON.stringify(event)}
3746
- `, "utf8");
3822
+ const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
3823
+ const externalDims = /* @__PURE__ */ new Map();
3824
+ for (const url of scan.genexAssetUrls) {
3825
+ const role = url.split("/").pop() ?? "";
3826
+ if (scan.markers.adaptiveQuality && role === SKYBOX_ROLE) {
3827
+ externalDims.set(url, { width: 2048, height: 1024 });
3828
+ } else if (scan.markers.modelRungs && role.endsWith("-glb")) {
3829
+ externalDims.set(url, { width: 1024, height: 2048, glb: true });
3830
+ } else {
3831
+ externalDims.set(url, null);
3832
+ }
3833
+ }
3834
+ const est = estimateVram(scan, externalDims);
3835
+ const tier = tierFor(est.estVramMb);
3836
+ const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
3837
+ if (tier === "mobile-broad") {
3838
+ log.dim(` ${line}`);
3839
+ return;
3840
+ }
3841
+ log.warn(line);
3842
+ for (const url of est.unresolvedExternals) {
3843
+ const role = url.split("/").pop() ?? "";
3844
+ if (role === SKYBOX_ROLE) {
3845
+ log.warn(
3846
+ ` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
3847
+ );
3848
+ } else if (role.endsWith("-glb")) {
3849
+ log.warn(
3850
+ ` Model loaded without pickModel (${url.slice(0, 80)}\u2026) \u2014 phones fetch the full provider-raw GLB. Load it through loadModelWithFallback (genex-threejs-adaptive-quality) so phones get the @1024 rung.`
3851
+ );
3852
+ }
3853
+ }
3854
+ if (est.textureMaxDim > 2048) {
3855
+ log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
3856
+ }
3857
+ if (scan.markers.postStack && scan.markers.dprCapLiteral == null) {
3858
+ log.warn(
3859
+ " Post stack detected with no devicePixelRatio cap \u2014 cap at 1.5 on phones (genex-threejs-adaptive-quality)."
3860
+ );
3861
+ }
3862
+ if (!scan.markers.contextLossHandler) {
3863
+ log.warn(" No context-loss handler found \u2014 one GPU reset shows a dead canvas on phones.");
3864
+ }
3747
3865
  } catch {
3748
3866
  }
3749
3867
  }
3750
- async function readLedger(cwd = process.cwd()) {
3751
- let raw;
3868
+ async function printUiAuditPreflight(log) {
3752
3869
  try {
3753
- raw = await fs12.readFile(ledgerPath(cwd), "utf8");
3870
+ const findings = await collectUiAuditFindings("public/assets/hud", "src");
3871
+ if (!findings || findings.length === 0) return;
3872
+ log.warn(
3873
+ `HUD wiring audit: ${findings.length} finding(s) \u2014 heuristics, check each (\`npx genex ui audit\` for the full report):`
3874
+ );
3875
+ for (const finding of findings.slice(0, 6)) log.warn(` [${finding.kind}] ${finding.message}`);
3876
+ if (findings.length > 6) log.dim(` \u2026and ${findings.length - 6} more.`);
3754
3877
  } catch {
3755
- return [];
3756
3878
  }
3757
- const entries = /* @__PURE__ */ new Map();
3758
- for (const line of raw.split("\n")) {
3759
- if (!line.trim()) continue;
3760
- try {
3761
- const e = JSON.parse(line);
3762
- if (e.t === "q" && typeof e.id === "string" && typeof e.kind === "string") {
3763
- if (!entries.has(e.id)) {
3764
- entries.set(e.id, {
3765
- id: e.id,
3766
- kind: e.kind,
3767
- prompt: typeof e.prompt === "string" ? e.prompt : "",
3768
- ...typeof e.tail === "string" ? { promptTail: e.tail } : {},
3769
- queuedAt: typeof e.at === "string" ? e.at : "",
3770
- status: "queued"
3771
- });
3772
- }
3773
- } else if (e.t === "done" && typeof e.id === "string") {
3774
- const entry = entries.get(e.id);
3775
- if (entry && (e.status === "completed" || e.status === "failed")) {
3776
- entry.status = e.status;
3777
- if (Array.isArray(e.urls)) entry.urls = e.urls.filter((u) => typeof u === "string");
3778
- }
3879
+ }
3880
+ async function printPipelineStatePreflight(log, cwd = process.cwd()) {
3881
+ try {
3882
+ const design = await fs12.readFile(path12.join(cwd, "DESIGN.md"), "utf8").catch(() => "");
3883
+ const warnings = [];
3884
+ if (design.length > 0 && !/##\s*build plan & status/i.test(design)) {
3885
+ warnings.push(
3886
+ "DESIGN.md has no `## Build plan & status` section \u2014 numbered milestones with a `Now:` line are how a compacted or resumed session knows where the build is (genex-game-director's design contract has the shape)."
3887
+ );
3888
+ }
3889
+ if (!/player character:/i.test(design)) {
3890
+ const hasCharacter = await fs12.access(path12.join(cwd, "public", "assets", "meshy-character.json")).then(() => true, () => false);
3891
+ if (!hasCharacter && await loadsPlayerBody(cwd)) {
3892
+ warnings.push(
3893
+ `Player character is the stock avatar \u2014 no generated character is wired. The game's own generated character is the player's body wherever a human body appears on screen, first-person included (genex-ai-character). Generate it, or record "Player character: VRM \u2014 <reason>" in DESIGN.md (no human body in this game / out of credits / player declined).`
3894
+ );
3779
3895
  }
3780
- } catch {
3781
3896
  }
3897
+ for (const w of warnings) log.warn(w);
3898
+ } catch {
3782
3899
  }
3783
- return [...entries.values()];
3784
- }
3785
- async function recordQueued(id, kind, prompt, cwd = process.cwd()) {
3786
- await append(cwd, {
3787
- t: "q",
3788
- id,
3789
- kind,
3790
- prompt: prompt.slice(0, 120),
3791
- ...prompt.length > 120 ? { tail: prompt.slice(-120) } : {},
3792
- at: (/* @__PURE__ */ new Date()).toISOString()
3793
- });
3794
- }
3795
- async function recordTerminal(id, status, urls, cwd = process.cwd()) {
3796
- const existing = (await readLedger(cwd)).find((e) => e.id === id);
3797
- if (existing && existing.status === status) return;
3798
- await append(cwd, { t: "done", id, status, ...urls?.length ? { urls } : {} });
3799
3900
  }
3800
- async function countFailed(kind, cwd = process.cwd()) {
3801
- return (await readLedger(cwd)).filter((e) => e.kind === kind && e.status === "failed").length;
3901
+ async function loadsPlayerBody(cwd) {
3902
+ const BODY_LOADERS = /\bloadPlayerCharacter\s*\(|\bloadVrm(?:Clone)?\s*\(/;
3903
+ try {
3904
+ const entries = await fs12.readdir(path12.join(cwd, "src"), { recursive: true });
3905
+ for (const rel of entries) {
3906
+ if (rel.includes("node_modules")) continue;
3907
+ if (rel.split(path12.sep)[0] === "controllers" || rel.startsWith("controllers/")) continue;
3908
+ if (!/\.(ts|tsx|js|jsx)$/.test(rel)) continue;
3909
+ const text = await fs12.readFile(path12.join(cwd, "src", rel), "utf8").catch(() => "");
3910
+ if (BODY_LOADERS.test(text)) return true;
3911
+ }
3912
+ } catch {
3913
+ }
3914
+ return false;
3802
3915
  }
3803
-
3804
- // src/commands/generate.ts
3805
- import fs13 from "fs/promises";
3806
- import path13 from "path";
3807
- import { PNG as PNG4 } from "pngjs";
3808
-
3809
- // src/lib/glass.ts
3810
- import { PNG as PNG3 } from "pngjs";
3811
- var GLASS_PROMPT_SUFFIX = " Rendered on a perfectly flat, uniform, solid pure magenta background, hex #FF00FF, like a studio chroma-key screen: absolutely no shading, no gradient, no vignette, no glow, no shadows on the background. Subject centered, sharp edge silhouette.";
3812
- var GLASS_MIN_FLATNESS = 0.99;
3813
- var GLASS_MIN_KEY_STRENGTH = 180;
3814
- var FLATNESS_DEV_MAX = 12;
3815
- var clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
3816
- function median(values) {
3817
- const s = values.slice().sort((a, b) => a - b);
3818
- return s[Math.floor(s.length / 2)] ?? 0;
3916
+ var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
3917
+ function isSecretEnvFile(name) {
3918
+ return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
3819
3919
  }
3820
- function solveMagentaGlass(source) {
3821
- const { width: W, height: H, data } = source;
3822
- const ringW = Math.max(2, Math.round(Math.min(W, H) * 0.04));
3823
- const rs = [];
3824
- const gs = [];
3825
- const bs = [];
3826
- const ringIdx = [];
3827
- for (let y = 0; y < H; y++) {
3828
- for (let x = 0; x < W; x++) {
3829
- const inRing = x < ringW || y < ringW || x >= W - ringW || y >= H - ringW;
3830
- if (!inRing) continue;
3831
- const i = (y * W + x) * 4;
3832
- ringIdx.push(i);
3833
- rs.push(data[i]);
3834
- gs.push(data[i + 1]);
3835
- bs.push(data[i + 2]);
3920
+ async function deployGame(ctx, opts, log) {
3921
+ const cwd = process.cwd();
3922
+ if (!opts.noBuild && await hasBuildScript(cwd)) {
3923
+ log.step("Building the production bundle\u2026");
3924
+ const built = await run("npm", ["run", "build"]);
3925
+ if (built.code !== 0) {
3926
+ log.error("Build failed \u2014 your game was NOT deployed.");
3927
+ const tail = (built.err || built.out).trim().split("\n").slice(-12).join("\n");
3928
+ if (tail) log.dim(tail);
3929
+ return false;
3836
3930
  }
3931
+ log.success("Built.");
3837
3932
  }
3838
- const K = { r: median(rs), g: median(gs), b: median(bs) };
3839
- const keyHex = `#${[K.r, K.g, K.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
3840
- const keyStrength = Math.min(K.r, K.b) - K.g;
3841
- let flat = 0;
3842
- for (const i of ringIdx) {
3843
- const dev = Math.max(
3844
- Math.abs(data[i] - K.r),
3845
- Math.abs(data[i + 1] - K.g),
3846
- Math.abs(data[i + 2] - K.b)
3847
- );
3848
- if (dev < FLATNESS_DEV_MAX) flat++;
3933
+ const distDir = path12.join(cwd, "dist");
3934
+ const siteDir = await isDir2(distDir) ? distDir : cwd;
3935
+ const rel = path12.relative(cwd, siteDir) || ".";
3936
+ if (siteDir === cwd) await writeGitignore(cwd, log);
3937
+ const files = await collectFiles(siteDir);
3938
+ if (files.length === 0) {
3939
+ log.warn("Nothing to deploy \u2014 the build produced no files.");
3940
+ return false;
3849
3941
  }
3850
- const flatness = ringIdx.length === 0 ? 0 : flat / ringIdx.length;
3851
- const base = { keyColor: K, keyHex, keyStrength, flatness };
3852
- if (flatness < GLASS_MIN_FLATNESS || keyStrength < GLASS_MIN_KEY_STRENGTH) {
3853
- return {
3854
- ...base,
3855
- png: source,
3856
- ok: false,
3857
- rejected: `backdrop not keyable (flatness ${flatness.toFixed(3)}, key strength ${keyStrength} \u2014 want \u2265${GLASS_MIN_FLATNESS}/${GLASS_MIN_KEY_STRENGTH}): the model shaded the magenta screen instead of painting it flat. Regenerate (same prompt \u2014 this is a rare roll), or steer harder: keep the --glass suffix intact and avoid magenta/pink art in the subject.`
3858
- };
3942
+ if (!files.some((f) => f.relPath === "index.html")) {
3943
+ log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
3944
+ return false;
3859
3945
  }
3860
- const out = new PNG3({ width: W, height: H });
3861
- const mKey = Math.max(1, keyStrength);
3862
- for (let p = 0; p < W * H; p++) {
3863
- const i = p * 4;
3864
- const R = data[i];
3865
- const G = data[i + 1];
3866
- const B = data[i + 2];
3867
- const m = Math.max(0, Math.min(R, B) - G);
3868
- let a = 1 - m / mKey;
3869
- if (a < 0) a = 0;
3870
- if (a > 1) a = 1;
3871
- if (a <= 0.02) {
3872
- out.data[i] = 0;
3873
- out.data[i + 1] = 0;
3874
- out.data[i + 2] = 0;
3875
- out.data[i + 3] = 0;
3876
- continue;
3877
- }
3878
- out.data[i] = clamp255((R - (1 - a) * K.r) / a);
3879
- out.data[i + 1] = clamp255((G - (1 - a) * K.g) / a);
3880
- out.data[i + 2] = clamp255((B - (1 - a) * K.b) / a);
3881
- out.data[i + 3] = Math.round(a * 255);
3946
+ const lfsPointers = files.filter(
3947
+ (f) => f.bytes.length < 1024 && f.bytes.subarray(0, 64).toString("latin1").startsWith("version https://git-lfs.github.com/spec/v")
3948
+ );
3949
+ if (lfsPointers.length > 0) {
3950
+ log.error(
3951
+ `${lfsPointers.length} file${lfsPointers.length > 1 ? "s" : ""} in ${rel} ${lfsPointers.length > 1 ? "are" : "is"} an unresolved Git LFS pointer, not the real asset \u2014 the game would fail to load at runtime. Run \`git lfs pull\` and rebuild. Nothing deployed.`
3952
+ );
3953
+ for (const p of lfsPointers.slice(0, 8)) log.dim(` ${p.relPath}`);
3954
+ if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
3955
+ return false;
3882
3956
  }
3883
- return { ...base, png: out, ok: true };
3957
+ printMobilePreflight(files, log);
3958
+ await printUiAuditPreflight(log);
3959
+ await printPipelineStatePreflight(log);
3960
+ const commit = contentCommit(files);
3961
+ log.step("Requesting an upload token\u2026");
3962
+ const grant = await getUploadToken(ctx, commit, log);
3963
+ if (!grant) return false;
3964
+ const totalBytes = files.reduce((n, f) => n + f.bytes.length, 0);
3965
+ const mb = (n) => (n / 1048576).toFixed(n < 1048576 ? 2 : 1);
3966
+ if (files.length > grant.limits.maxFiles) {
3967
+ log.error(`Too many files (${files.length} > ${grant.limits.maxFiles}). Trim the build and retry.`);
3968
+ return false;
3969
+ }
3970
+ const oversized = files.find((f) => f.bytes.length > grant.limits.maxFileBytes);
3971
+ if (oversized) {
3972
+ log.error(`${oversized.relPath} is ${mb(oversized.bytes.length)} MB \u2014 over the ${mb(grant.limits.maxFileBytes)} MB per-file limit.`);
3973
+ return false;
3974
+ }
3975
+ if (totalBytes > grant.limits.maxTotalBytes) {
3976
+ log.error(`Build is ${mb(totalBytes)} MB \u2014 over the ${mb(grant.limits.maxTotalBytes)} MB limit.`);
3977
+ return false;
3978
+ }
3979
+ log.step(`Uploading ${files.length} files\u2026`);
3980
+ if (!await uploadAll(grant, files, log)) {
3981
+ log.error("Couldn't upload your game \u2014 please try again.");
3982
+ return false;
3983
+ }
3984
+ if (!await pushSource(cwd, ctx, log)) return false;
3985
+ log.step("Publishing\u2026");
3986
+ if (!await callPublish(ctx, commit, opts, log)) return false;
3987
+ const index = files.find((f) => f.relPath === "index.html");
3988
+ await waitUntilLive(grant.playUrl, fingerprintOf(index.bytes.toString("utf8")), opts.liveTimeoutMs ?? 2e4, log);
3989
+ return true;
3884
3990
  }
3885
-
3886
- // src/lib/open.ts
3887
- import { spawn as spawn3 } from "child_process";
3888
- function tokenize(cmd) {
3889
- return cmd.trim().split(/\s+/).filter(Boolean);
3991
+ async function hasBuildScript(cwd) {
3992
+ try {
3993
+ const pkg = JSON.parse(await fs12.readFile(path12.join(cwd, "package.json"), "utf8"));
3994
+ return Boolean(pkg.scripts?.build);
3995
+ } catch {
3996
+ return false;
3997
+ }
3890
3998
  }
3891
- function openUrl(url) {
3892
- let command;
3893
- let args;
3894
- const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
3895
- const customParts = custom ? tokenize(custom) : [];
3896
- if (customParts.length > 0) {
3897
- command = customParts[0];
3898
- args = [...customParts.slice(1), url];
3899
- } else {
3900
- switch (process.platform) {
3901
- case "darwin":
3902
- command = "open";
3903
- args = [url];
3904
- break;
3905
- case "win32":
3906
- command = "cmd";
3907
- args = ["/c", "start", "", url];
3908
- break;
3909
- default:
3910
- command = "xdg-open";
3911
- args = [url];
3912
- break;
3999
+ async function collectFiles(root) {
4000
+ const out = [];
4001
+ const walk2 = async (dir, prefix) => {
4002
+ for (const e of await fs12.readdir(dir, { withFileTypes: true })) {
4003
+ const relPath = prefix ? `${prefix}/${e.name}` : e.name;
4004
+ if (e.isDirectory()) {
4005
+ if (!EXCLUDE_DIRS.has(e.name)) await walk2(path12.join(dir, e.name), relPath);
4006
+ } else if (e.isFile() && !isSecretEnvFile(e.name)) {
4007
+ out.push({ relPath, bytes: await fs12.readFile(path12.join(dir, e.name)) });
4008
+ }
3913
4009
  }
4010
+ };
4011
+ await walk2(root, "");
4012
+ return out;
4013
+ }
4014
+ function contentCommit(files) {
4015
+ const h = crypto3.createHash("sha1");
4016
+ for (const f of [...files].sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)) {
4017
+ h.update(f.relPath, "utf8");
4018
+ h.update("\0");
4019
+ h.update(f.bytes);
3914
4020
  }
4021
+ return h.digest("hex");
4022
+ }
4023
+ function retryAfterHint(res) {
4024
+ const raw = Number(res.headers.get("retry-after"));
4025
+ if (!Number.isFinite(raw) || raw <= 0) return "";
4026
+ const secs = Math.ceil(raw);
4027
+ return ` Try again in ${secs < 90 ? `${secs}s` : `~${Math.ceil(secs / 60)} min`}.`;
4028
+ }
4029
+ async function getUploadToken(ctx, commit, log) {
4030
+ let res;
3915
4031
  try {
3916
- const child = spawn3(command, args, { stdio: "ignore", detached: true });
3917
- child.on("error", () => {
4032
+ res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
4033
+ method: "POST",
4034
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
4035
+ body: JSON.stringify({ commit })
3918
4036
  });
3919
- child.unref();
3920
- } catch {
4037
+ } catch (err) {
4038
+ log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
4039
+ return null;
4040
+ }
4041
+ if (res.status === 401) {
4042
+ log.error("Not authorized (HTTP 401) \u2014 your sign-in expired or was revoked.");
4043
+ log.plain(" Fix: run `genex link <your-game>` and sign in as the game's owner, then re-run this command.");
4044
+ return null;
4045
+ }
4046
+ if (res.status === 404) {
4047
+ log.error("This account doesn't own this game (HTTP 404) \u2014 you're signed in as a different account.");
4048
+ log.plain(" Fix: run `genex link <your-game>` and sign in as the OWNER account, then re-run this command.");
4049
+ return null;
4050
+ }
4051
+ if (res.status === 429) {
4052
+ log.error(`Rate limited requesting an upload token (HTTP 429).${retryAfterHint(res)}`);
4053
+ return null;
4054
+ }
4055
+ if (!res.ok) {
4056
+ log.error(`Couldn't get an upload token (HTTP ${res.status}).`);
4057
+ return null;
3921
4058
  }
4059
+ return await res.json();
3922
4060
  }
3923
-
3924
- // src/lib/sse.ts
3925
- async function* readSSE(body) {
3926
- const decoder = new TextDecoder();
3927
- const reader = body.getReader();
3928
- let buf = "";
3929
- let event = "";
3930
- let data = [];
3931
- try {
3932
- while (true) {
3933
- const { done, value } = await reader.read();
3934
- if (done) break;
3935
- buf += decoder.decode(value, { stream: true });
3936
- while (true) {
3937
- const nl = buf.search(/[\r\n]/);
3938
- if (nl === -1) break;
3939
- if (buf[nl] === "\r" && nl === buf.length - 1) break;
3940
- const sep = buf[nl] === "\r" && buf[nl + 1] === "\n" ? 2 : 1;
3941
- const line = buf.slice(0, nl);
3942
- buf = buf.slice(nl + sep);
3943
- if (line === "") {
3944
- if (data.length > 0) yield { event: event || "message", data: data.join("\n") };
3945
- event = "";
3946
- data = [];
3947
- continue;
3948
- }
3949
- if (line.startsWith(":")) continue;
3950
- const colon = line.indexOf(":");
3951
- const field = colon === -1 ? line : line.slice(0, colon);
3952
- let value2 = colon === -1 ? "" : line.slice(colon + 1);
3953
- if (value2.startsWith(" ")) value2 = value2.slice(1);
3954
- if (field === "event") event = value2;
3955
- else if (field === "data") data.push(value2);
4061
+ async function uploadAll(grant, files, log) {
4062
+ const base = grant.uploadUrl.replace(/\/+$/, "");
4063
+ const threshold = grant.limits.singlePutMaxBytes ?? grant.limits.maxFileBytes;
4064
+ const small = files.filter((f) => f.bytes.length <= threshold);
4065
+ const large = files.filter((f) => f.bytes.length > threshold);
4066
+ let failed = false;
4067
+ let next = 0;
4068
+ const worker = async () => {
4069
+ while (!failed) {
4070
+ const i = next++;
4071
+ if (i >= small.length) return;
4072
+ const f = small[i];
4073
+ const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
4074
+ let res = null;
4075
+ try {
4076
+ res = await fetch(`${base}/${encoded}`, {
4077
+ method: "PUT",
4078
+ headers: { Authorization: `Bearer ${grant.token}` },
4079
+ body: f.bytes
4080
+ });
4081
+ } catch {
4082
+ res = null;
4083
+ }
4084
+ if (!res || !res.ok) {
4085
+ failed = true;
4086
+ log.warn(`Upload failed for ${f.relPath}${res ? ` (HTTP ${res.status})` : " (network error)"}.`);
3956
4087
  }
3957
4088
  }
3958
- } finally {
3959
- reader.releaseLock();
4089
+ };
4090
+ await Promise.all(Array.from({ length: Math.min(8, small.length) }, () => worker()));
4091
+ if (failed) return false;
4092
+ for (const f of large) {
4093
+ if (!await uploadMultipart(base, grant.token, f, grant.limits.partSizeBytes, log)) return false;
3960
4094
  }
4095
+ return true;
3961
4096
  }
3962
-
3963
- // src/commands/generate.ts
3964
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3965
- var INLINE_IMAGE_LIMIT_BYTES = 4 * 1024 * 1024;
3966
- var IMAGE_MIME_BY_EXT = {
3967
- ".png": "image/png",
3968
- ".jpg": "image/jpeg",
3969
- ".jpeg": "image/jpeg",
3970
- ".webp": "image/webp"
3971
- };
3972
- var isRemoteRef = (value) => /^(https?:\/\/|data:)/i.test(value);
3973
- async function inlineLocalImage(filePath, flag) {
3974
- let bytes;
4097
+ async function uploadMultipart(base, uploadToken, f, partSize, log) {
4098
+ const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
4099
+ const auth = { Authorization: `Bearer ${uploadToken}` };
4100
+ const totalParts = Math.ceil(f.bytes.length / partSize);
4101
+ log.step(`Uploading ${f.relPath} (${(f.bytes.length / 1048576).toFixed(1)} MB in ${totalParts} parts)\u2026`);
4102
+ let uploadId;
3975
4103
  try {
3976
- bytes = await fs13.readFile(filePath);
4104
+ const res = await fetch(`${base}/${encoded}?action=mpu-create`, { method: "POST", headers: auth });
4105
+ if (!res.ok) {
4106
+ log.warn(`Upload failed for ${f.relPath} (HTTP ${res.status} starting multipart).`);
4107
+ return false;
4108
+ }
4109
+ uploadId = (await res.json()).uploadId;
3977
4110
  } catch {
3978
- return { ok: false, error: `Couldn't read the ${flag} file at ${filePath}.` };
4111
+ log.warn(`Upload failed for ${f.relPath} (network error starting multipart).`);
4112
+ return false;
3979
4113
  }
3980
- if (bytes.length > INLINE_IMAGE_LIMIT_BYTES) {
3981
- return {
3982
- ok: false,
3983
- error: `${flag} file is ${(bytes.length / 1048576).toFixed(1)} MB \u2014 over the ~4 MB inline limit. Downscale/compress it first, or pass an asset URL instead.`
3984
- };
3985
- }
3986
- const mime = IMAGE_MIME_BY_EXT[path13.extname(filePath).toLowerCase()] ?? "image/png";
3987
- return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
3988
- }
3989
- var SKYBOX_ENVIRONMENT_SUFFIX = ". The image contains ONLY sky: cloud, atmosphere, light, weather and distant haze at the horizon. Every structure, object, plant and ground surface is outside the frame.";
3990
- var SKYBOX_PROMPT_MAX = 2e3;
3991
- var SKYBOX_STRUCTURE_NOUNS = [
3992
- "building",
3993
- "buildings",
3994
- "house",
3995
- "houses",
3996
- "hut",
3997
- "huts",
3998
- "cabin",
3999
- "cabins",
4000
- "tower",
4001
- "towers",
4002
- "castle",
4003
- "castles",
4004
- "cathedral",
4005
- "cathedrals",
4006
- "church",
4007
- "churches",
4008
- "temple",
4009
- "temples",
4010
- "shrine",
4011
- "shrines",
4012
- "monastery",
4013
- "palace",
4014
- "fortress",
4015
- "citadel",
4016
- "ruin",
4017
- "ruins",
4018
- "city",
4019
- "cityscape",
4020
- "skyline",
4021
- "town",
4022
- "village",
4023
- "settlement",
4024
- "street",
4025
- "streets",
4026
- "road",
4027
- "roads",
4028
- "courtyard",
4029
- "plaza",
4030
- "bridge",
4031
- "bridges",
4032
- "wall",
4033
- "walls",
4034
- "gate",
4035
- "gates",
4036
- "fence",
4037
- "fences",
4038
- "pillar",
4039
- "pillars",
4040
- "column",
4041
- "columns",
4042
- "statue",
4043
- "statues",
4044
- "monument",
4045
- "obelisk",
4046
- "spire",
4047
- "spires",
4048
- "rooftop",
4049
- "rooftops",
4050
- "tree",
4051
- "trees",
4052
- "forest",
4053
- "jungle",
4054
- "ship",
4055
- "ships",
4056
- "boat",
4057
- "boats",
4058
- "car",
4059
- "cars",
4060
- "vehicle",
4061
- "vehicles",
4062
- "person",
4063
- "people",
4064
- "figure",
4065
- "figures",
4066
- "character",
4067
- "characters",
4068
- "creature",
4069
- "creatures",
4070
- "dragon",
4071
- "banner",
4072
- "banners",
4073
- "flag",
4074
- "flags",
4075
- "torch",
4076
- "torches",
4077
- "lamp",
4078
- "lamps"
4079
- ];
4080
- var SKYBOX_NOUN_RE = new RegExp(`\\b(${SKYBOX_STRUCTURE_NOUNS.join("|")})\\b`, "gi");
4081
- function skyboxStructureNouns(prompt) {
4082
- const found = /* @__PURE__ */ new Set();
4083
- for (const m of prompt.matchAll(SKYBOX_NOUN_RE)) found.add(m[1].toLowerCase());
4084
- return [...found];
4085
- }
4086
- function applySkyboxGuard(prompt, raw) {
4087
- if (raw) return { prompt, guarded: false, nouns: [] };
4088
- const nouns = skyboxStructureNouns(prompt);
4089
- const guarded = `${prompt}${SKYBOX_ENVIRONMENT_SUFFIX}`;
4090
- if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false, nouns };
4091
- return { prompt: guarded, guarded: true, nouns };
4092
- }
4093
- function buildGenOptions(kind, opts) {
4094
- const options = { ...opts.generationOptions };
4095
- if (kind === "texture" && opts.terrain) options.terrain = true;
4096
- if ((kind === "sfx" || kind === "music") && opts.duration) options.durationSeconds = opts.duration;
4097
- if (kind === "voice") {
4098
- if (opts.voice) options.voice = opts.voice;
4099
- if (opts.voiceId) options.voiceId = opts.voiceId;
4100
- }
4101
- if (kind === "image") {
4102
- if (opts.transparent) options.transparent = true;
4103
- if (opts.aspect) options.aspect = opts.aspect;
4104
- if (opts.quality) options.quality = opts.quality;
4105
- if (opts.candidates !== void 0 && opts.candidates > 1) options.candidates = opts.candidates;
4106
- if (opts.width && opts.height) {
4107
- options.width = opts.width;
4108
- options.height = opts.height;
4109
- }
4110
- if (opts.editUrl) {
4111
- options.sourceUrl = opts.editUrl;
4112
- options.op = "edit";
4114
+ const abort = async () => {
4115
+ try {
4116
+ await fetch(`${base}/${encoded}?action=mpu-abort&uploadId=${encodeURIComponent(uploadId)}`, {
4117
+ method: "DELETE",
4118
+ headers: auth
4119
+ });
4120
+ } catch {
4113
4121
  }
4114
- if (opts.cleanUrl) {
4115
- options.sourceUrl = opts.cleanUrl;
4116
- options.op = "remove-bg";
4117
- options.bgMode = opts.bgMode ?? "sheet";
4122
+ };
4123
+ const parts = [];
4124
+ let failed = false;
4125
+ let next = 0;
4126
+ const partWorker = async () => {
4127
+ while (!failed) {
4128
+ const i = next++;
4129
+ if (i >= totalParts) return;
4130
+ const partNumber = i + 1;
4131
+ const bytes = f.bytes.subarray(i * partSize, Math.min((i + 1) * partSize, f.bytes.length));
4132
+ const part = await uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, bytes);
4133
+ if (!part) {
4134
+ failed = true;
4135
+ log.warn(`Upload failed for ${f.relPath} (part ${partNumber}/${totalParts}).`);
4136
+ return;
4137
+ }
4138
+ parts.push(part);
4118
4139
  }
4119
- if (opts.upscaleUrl) {
4120
- options.sourceUrl = opts.upscaleUrl;
4121
- options.op = "upscale";
4140
+ };
4141
+ await Promise.all(Array.from({ length: Math.min(4, totalParts) }, () => partWorker()));
4142
+ if (failed) {
4143
+ await abort();
4144
+ return false;
4145
+ }
4146
+ parts.sort((a, b) => a.partNumber - b.partNumber);
4147
+ try {
4148
+ const res = await fetch(`${base}/${encoded}?action=mpu-complete&uploadId=${encodeURIComponent(uploadId)}`, {
4149
+ method: "POST",
4150
+ headers: { ...auth, "Content-Type": "application/json" },
4151
+ body: JSON.stringify({ parts })
4152
+ });
4153
+ if (!res.ok) {
4154
+ log.warn(`Upload failed for ${f.relPath} (HTTP ${res.status} completing multipart).`);
4155
+ await abort();
4156
+ return false;
4122
4157
  }
4123
- if (opts.inpaintUrl) options.maskUrl = opts.inpaintUrl;
4124
- if (opts.removeBackground) options.removeBackground = true;
4125
- if (opts.bgMode) options.bgMode = opts.bgMode;
4158
+ } catch {
4159
+ log.warn(`Upload failed for ${f.relPath} (network error completing multipart).`);
4160
+ await abort();
4161
+ return false;
4126
4162
  }
4127
- if (kind === "video") {
4128
- if (opts.duration) options.durationSeconds = opts.duration;
4129
- if (opts.resolution) options.resolution = opts.resolution;
4130
- if (opts.loop) options.loop = true;
4131
- if (opts.frameUrl) options.frameUrl = opts.frameUrl;
4132
- if (opts.firstFrameUrl) options.firstFrameUrl = opts.firstFrameUrl;
4133
- if (opts.lastFrameUrl) options.lastFrameUrl = opts.lastFrameUrl;
4163
+ return true;
4164
+ }
4165
+ async function uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, bytes) {
4166
+ for (let attempt = 0; ; attempt++) {
4167
+ let res = null;
4168
+ try {
4169
+ res = await fetch(
4170
+ `${base}/${encoded}?action=mpu-part&uploadId=${encodeURIComponent(uploadId)}&partNumber=${partNumber}`,
4171
+ { method: "PUT", headers: auth, body: bytes }
4172
+ );
4173
+ if (res.ok) return await res.json();
4174
+ } catch {
4175
+ res = null;
4176
+ }
4177
+ if (res && res.status < 500) return null;
4178
+ if (attempt >= 2) return null;
4179
+ await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 500 : 2e3));
4134
4180
  }
4135
- return options;
4136
4181
  }
4137
- async function runGenerate(kind, opts) {
4138
- const log = createLogger({ quiet: opts.quiet || opts.json });
4139
- const typedPrompt = opts.prompt?.trim();
4140
- if (!typedPrompt) {
4141
- log.error(`Missing prompt. Usage: ${c.cyan(`genex ${kind} "<prompt>"`)}`);
4142
- process.exitCode = 1;
4143
- return;
4182
+ async function callPublish(ctx, commit, opts, log) {
4183
+ let res;
4184
+ try {
4185
+ res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
4186
+ method: "POST",
4187
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
4188
+ // Detections ride every go-live (preview AND publish): matchmaking is
4189
+ // tri-state (object = upsert, null = clear); embedSdkVersion/multiplayer
4190
+ // are sent only when detected (absent = server keeps the stored value).
4191
+ body: JSON.stringify({
4192
+ commit,
4193
+ matchmaking: opts.matchmaking ?? null,
4194
+ ...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
4195
+ ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
4196
+ ...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
4197
+ })
4198
+ });
4199
+ } catch (err) {
4200
+ log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
4201
+ return false;
4144
4202
  }
4145
- const skyboxGuard = kind === "skybox" ? applySkyboxGuard(typedPrompt, opts.raw) : void 0;
4146
- if (skyboxGuard && skyboxGuard.nouns.length > 0) {
4147
- log.error(
4148
- `A skybox is sky only \u2014 this prompt names ${skyboxGuard.nouns.join(", ")}.`
4149
- );
4150
- log.plain(
4151
- [
4152
- " Structures painted into a sky render at infinite distance: they never get closer",
4153
- " as the player moves, and they read as broken the moment the camera strafes.",
4154
- ` ${c.cyan("\u2192")} Build them as scene geometry (${c.cyan("npx genex model")}); describe only light, colour,`,
4155
- " cloud, weather and time of day here.",
4156
- ` ${c.cyan("\u2192")} Genuinely want it painted into the sky (a space-station panorama for a scene`,
4157
- ` with no world geometry)? Re-run with ${c.cyan("--raw")}.`
4158
- ].join("\n")
4159
- );
4160
- process.exitCode = 1;
4161
- return;
4203
+ if (res.status === 429) {
4204
+ log.error(`Rate limited publishing (HTTP 429).${retryAfterHint(res)}`);
4205
+ return false;
4162
4206
  }
4163
- let prompt = skyboxGuard?.prompt ?? typedPrompt;
4164
- if (kind === "image" && opts.glass) {
4165
- const conflict = opts.transparent ? "--transparent" : opts.removeBackground ? "--remove-bg" : opts.editUrl ? "--edit" : opts.cleanUrl ? "--clean" : opts.upscaleUrl ? "--upscale" : opts.noWait ? "--no-wait" : void 0;
4166
- if (conflict) {
4167
- log.error(
4168
- conflict === "--no-wait" ? "--glass runs a local key-solve on the finished image \u2014 it has to wait. Drop --no-wait." : `--glass generates a NEW panel on a magenta key screen \u2014 it cannot combine with ${conflict}. The solved RGBA ships real translucency; no removal pass applies.`
4169
- );
4170
- process.exitCode = 1;
4171
- return;
4172
- }
4173
- prompt = `${prompt}${GLASS_PROMPT_SUFFIX}`;
4207
+ if (!res.ok) {
4208
+ const detail = await res.text().catch(() => "");
4209
+ log.error(`Publish failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}.`);
4210
+ return false;
4174
4211
  }
4175
- if (kind === "image" && opts.inpaintUrl && !/^https?:\/\//i.test(opts.inpaintUrl)) {
4176
- if (!opts.editUrl) {
4177
- log.error("--inpaint targets an edit \u2014 pass --edit <asset-url> alongside it.");
4178
- process.exitCode = 1;
4179
- return;
4212
+ return true;
4213
+ }
4214
+ async function pushSource(cwd, ctx, log) {
4215
+ const target = await fetchPushUrl(ctx, log);
4216
+ if (!target) return false;
4217
+ if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
4218
+ if (!target.managed) return false;
4219
+ log.info("Retrying the source push\u2026");
4220
+ await new Promise((r) => setTimeout(r, 2e3));
4221
+ const fresh = await fetchPushUrl(ctx, log);
4222
+ if (!fresh) return false;
4223
+ return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
4224
+ }
4225
+ function urlHasEmbeddedCredentials(pushUrl) {
4226
+ return /^[a-z][a-z0-9+.-]*:\/\/[^/@]+@/i.test(pushUrl);
4227
+ }
4228
+ async function pushWorktree(cwd, pushUrl, managed, log) {
4229
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
4230
+ const failed = () => {
4231
+ log.error("Couldn't save your game's source \u2014 please try again.");
4232
+ return false;
4233
+ };
4234
+ const gitDir = await fs12.mkdtemp(path12.join(os4.tmpdir(), "genex-source-"));
4235
+ const base = { GIT_DIR: gitDir };
4236
+ if (urlHasEmbeddedCredentials(pushUrl)) {
4237
+ base.GIT_CONFIG_COUNT = "1";
4238
+ base.GIT_CONFIG_KEY_0 = "credential.helper";
4239
+ base.GIT_CONFIG_VALUE_0 = "";
4240
+ }
4241
+ const ident = {
4242
+ GIT_AUTHOR_NAME: "genex",
4243
+ GIT_AUTHOR_EMAIL: "agent@genex.local",
4244
+ GIT_COMMITTER_NAME: "genex",
4245
+ GIT_COMMITTER_EMAIL: "agent@genex.local"
4246
+ };
4247
+ try {
4248
+ if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
4249
+ await fs12.writeFile(
4250
+ path12.join(gitDir, "info", "exclude"),
4251
+ // .env* are secrets — never publish them; `!` keeps the non-secret template.
4252
+ ["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
4253
+ );
4254
+ const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path12.join(gitDir, "index-source") };
4255
+ let lfs = (await run("git", ["lfs", "version"], base)).code !== 0 ? false : true;
4256
+ if (!lfs) {
4257
+ log.step("Installing git-lfs (keeps large binary assets out of the source push)\u2026");
4258
+ const [bin, args] = process.platform === "darwin" ? ["brew", ["install", "git-lfs"]] : ["sudo", ["-n", "apt-get", "install", "-y", "git-lfs"]];
4259
+ await run(bin, args, base);
4260
+ lfs = (await run("git", ["lfs", "version"], base)).code === 0;
4261
+ if (!lfs) {
4262
+ log.warn(
4263
+ "Couldn't install git-lfs \u2014 large binary assets won't be saved to your source (the game still publishes)."
4264
+ );
4265
+ }
4180
4266
  }
4181
- try {
4182
- const bytes = await fs13.readFile(opts.inpaintUrl);
4183
- opts = { ...opts, inpaintUrl: `data:image/png;base64,${bytes.toString("base64")}` };
4184
- } catch {
4185
- log.error(`Couldn't read the --inpaint mask at ${opts.inpaintUrl}.`);
4186
- process.exitCode = 1;
4187
- return;
4267
+ if (lfs) {
4268
+ await writeGitattributes(cwd, log);
4269
+ const filters = [
4270
+ ["filter.lfs.clean", "git-lfs clean -- %f"],
4271
+ ["filter.lfs.smudge", "git-lfs smudge -- %f"],
4272
+ ["filter.lfs.process", "git-lfs filter-process"],
4273
+ ["filter.lfs.required", "true"]
4274
+ ];
4275
+ for (const [key, value] of filters) await run("git", ["config", key, value], base);
4188
4276
  }
4189
- }
4190
- if (kind === "image" && opts.editUrl && !isRemoteRef(opts.editUrl)) {
4191
- const inlined = await inlineLocalImage(opts.editUrl, "--edit");
4192
- if (!inlined.ok) {
4193
- log.error(inlined.error);
4194
- process.exitCode = 1;
4195
- return;
4277
+ await run("git", ["add", "-A"], env);
4278
+ const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
4279
+ if (!tree || tree === EMPTY_TREE) {
4280
+ log.error("Nothing to publish \u2014 the project has no files.");
4281
+ return false;
4196
4282
  }
4197
- opts = { ...opts, editUrl: inlined.dataUri };
4198
- }
4199
- if (kind === "video" && opts.frameUrl && !isRemoteRef(opts.frameUrl)) {
4200
- const inlined = await inlineLocalImage(opts.frameUrl, "--frame");
4201
- if (!inlined.ok) {
4202
- log.error(inlined.error);
4203
- process.exitCode = 1;
4204
- return;
4283
+ const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
4284
+ await run("git", ["update-ref", "refs/heads/main", commit], base);
4285
+ if (lfs && (await run("git", ["lfs", "push", "--all", pushUrl, "main"], base)).code !== 0) {
4286
+ log.error("Couldn't upload your game's binary assets to the source store.");
4287
+ return false;
4205
4288
  }
4206
- opts = { ...opts, frameUrl: inlined.dataUri };
4207
- }
4208
- if (kind === "voice" && prompt.length > 1e3) {
4209
- log.warn(
4210
- `Voice text is ${prompt.length} chars \u2014 the server clamps at 1000 (billing follows the clamp). Split longer copy into separate lines.`
4289
+ const push = await run(
4290
+ "git",
4291
+ ["-c", "http.postBuffer=536870912", "push", "-q", pushUrl, "+refs/heads/main:main"],
4292
+ base
4211
4293
  );
4294
+ if (push.code === 0) return true;
4295
+ if (!managed) {
4296
+ log.error(
4297
+ "Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
4298
+ );
4299
+ return false;
4300
+ }
4301
+ return failed();
4302
+ } catch {
4303
+ return failed();
4304
+ } finally {
4305
+ await fs12.rm(gitDir, { recursive: true, force: true }).catch(() => {
4306
+ });
4212
4307
  }
4213
- const token = opts.token ?? await readUserToken(opts.envPath);
4214
- if (!token) {
4215
- log.error("Not authorized. Run `genex init` first to sign in.");
4216
- process.exitCode = 1;
4217
- return;
4218
- }
4219
- const meta = await readProject();
4220
- const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
4221
- const options = buildGenOptions(kind, opts);
4222
- log.plain(c.bold(`genex ${kind}`));
4223
- log.dim(` ${prompt}`);
4224
- if (skyboxGuard?.guarded) log.dim(" \u21B3 environment-only guard applied (--raw to disable)");
4225
- log.plain("");
4226
- let id;
4308
+ }
4309
+ async function fetchPushUrl(ctx, log) {
4310
+ let res;
4227
4311
  try {
4228
- const res = await apiFetch(`${apiUrl}/api/generations`, {
4312
+ res = await apiFetch(`${ctx.apiUrl}/api/projects/${ctx.projectId}/push-token`, {
4229
4313
  method: "POST",
4230
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
4231
- body: JSON.stringify({ kind, prompt, options })
4314
+ headers: { Authorization: `Bearer ${ctx.token}` }
4232
4315
  });
4233
- if (res.status === 401) {
4234
- log.error("Unauthorized \u2014 your token may have expired. Re-run `genex init`.");
4235
- process.exitCode = 1;
4236
- return;
4237
- }
4238
- if (printedStructuredError(res)) {
4239
- process.exitCode = 1;
4240
- return;
4241
- }
4242
- if (!res.ok) {
4243
- let message;
4244
- if (res.status >= 400 && res.status < 500) {
4245
- try {
4246
- const body = await res.json();
4247
- if (typeof body.message === "string" && body.message.trim()) message = body.message;
4248
- } catch {
4249
- }
4250
- }
4251
- log.error(message ?? `Generation request failed (HTTP ${res.status}).`);
4252
- process.exitCode = 1;
4253
- return;
4254
- }
4255
- ({ id } = await res.json());
4256
4316
  } catch (err) {
4257
- log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
4258
- process.exitCode = 1;
4259
- return;
4317
+ log.error(`Couldn't reach the API to authorize the source push: ${String(err)}`);
4318
+ return null;
4319
+ }
4320
+ if (res.status === 401) {
4321
+ log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
4322
+ return null;
4260
4323
  }
4261
- await recordQueued(id, kind, prompt);
4262
- if (opts.noWait) {
4263
- if (opts.json) {
4264
- writeJson({
4265
- kind,
4266
- id,
4267
- status: "pending",
4268
- nextCommand: `genex wait ${id} --json`
4269
- });
4270
- } else {
4271
- log.success(`Queued (${id}).`);
4272
- log.plain(` Pick it up any time with: ${c.cyan(`npx genex wait ${id}`)}`);
4273
- log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
4274
- }
4275
- return id;
4324
+ if (res.status === 429) {
4325
+ log.error(`Rate limited authorizing the source push (HTTP 429).${retryAfterHint(res)}`);
4326
+ return null;
4276
4327
  }
4277
- if (kind === "image" && opts.glass) {
4278
- if (!opts.json) log.step("Generating\u2026 (this can take up to a minute)");
4279
- const view = await waitForTerminalView(
4280
- apiUrl,
4281
- token,
4282
- id,
4283
- kind,
4284
- opts.json ? () => {
4285
- } : (p) => log.dim(` ${p}%`)
4286
- );
4287
- if (!view) {
4288
- if (opts.json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
4289
- else log.error("Timed out waiting for the generation.");
4290
- process.exitCode = 1;
4291
- return id;
4292
- }
4293
- await reportGlassTerminal(view, opts.outDir ?? ".", log, opts.json ?? false);
4294
- return id;
4328
+ if (!res.ok) {
4329
+ log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
4330
+ return null;
4295
4331
  }
4296
- await awaitAndReport(apiUrl, token, id, kind, log, opts.open, opts.json);
4297
- return id;
4332
+ const data = await res.json().catch(() => null);
4333
+ if (!data?.pushUrl) {
4334
+ log.error("The API didn't return a push URL.");
4335
+ return null;
4336
+ }
4337
+ return { pushUrl: data.pushUrl, managed: data.managed !== false };
4298
4338
  }
4299
- async function reportGlassTerminal(view, outDir, log, json) {
4300
- const files = (view.files ?? []).filter((f) => /^image-(main|alt-\d+)$/.test(f.role));
4301
- if (view.status !== "completed" || files.length === 0) {
4302
- await reportTerminal("image", view, log, false, json);
4339
+ async function isDir2(p) {
4340
+ try {
4341
+ return (await fs12.stat(p)).isDirectory();
4342
+ } catch {
4343
+ return false;
4344
+ }
4345
+ }
4346
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4347
+ function fingerprintOf(html) {
4348
+ const m = html.match(/[A-Za-z0-9_]+-[A-Za-z0-9_-]{8}\.(?:js|css)/);
4349
+ return m ? m[0] : null;
4350
+ }
4351
+ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
4352
+ if (!fingerprint) {
4353
+ log.success(`Live. \u{1F310} ${playUrl}`);
4303
4354
  return;
4304
4355
  }
4305
- await recordTerminal(view.id, "completed", files.map((f) => f.url));
4306
- await fs13.mkdir(outDir, { recursive: true });
4307
- const solved = [];
4308
- for (let i = 0; i < files.length; i++) {
4309
- const f = files[i];
4310
- let png;
4356
+ const deadline = Date.now() + timeoutMs;
4357
+ let n = 0;
4358
+ while (Date.now() < deadline) {
4311
4359
  try {
4312
- const res = await fetch(f.url);
4313
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
4314
- png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
4315
- } catch (err) {
4316
- solved.push({
4317
- path: null,
4318
- url: f.url,
4319
- keyHex: "",
4320
- keyStrength: 0,
4321
- flatness: 0,
4322
- rejected: `couldn't fetch the candidate (${String(err)}) \u2014 re-run later; the URL stays live.`
4323
- });
4324
- continue;
4325
- }
4326
- const r = solveMagentaGlass(png);
4327
- if (!r.ok) {
4328
- solved.push({
4329
- path: null,
4330
- url: f.url,
4331
- keyHex: r.keyHex,
4332
- keyStrength: r.keyStrength,
4333
- flatness: r.flatness,
4334
- rejected: r.rejected
4335
- });
4336
- continue;
4360
+ const res = await fetch(`${playUrl}?_genexlive=${n++}`, { cache: "no-store" });
4361
+ if (res.ok && (await res.text()).includes(fingerprint)) {
4362
+ log.success(`Live. \u{1F310} ${playUrl}`);
4363
+ return;
4364
+ }
4365
+ } catch {
4337
4366
  }
4338
- const outPath = path13.join(outDir, `glass-${i + 1}.png`);
4339
- await fs13.writeFile(outPath, PNG4.sync.write(r.png));
4340
- solved.push({
4341
- path: outPath,
4342
- url: f.url,
4343
- keyHex: r.keyHex,
4344
- keyStrength: r.keyStrength,
4345
- flatness: r.flatness
4346
- });
4347
- }
4348
- const shipped = solved.filter((s) => s.path !== null);
4349
- if (json) {
4350
- writeJson({ kind: "image", id: view.id, status: view.status, glass: solved });
4351
- if (shipped.length === 0) process.exitCode = 1;
4352
- return;
4367
+ await sleep(1500);
4353
4368
  }
4369
+ log.success(`Published. \u{1F310} ${playUrl}`);
4370
+ log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
4371
+ }
4372
+
4373
+ // src/commands/make-remixable.ts
4374
+ async function runMakeRemixable(opts) {
4375
+ const log = createLogger({ quiet: opts.quiet });
4376
+ log.plain(c.bold("genex make-remixable"));
4354
4377
  log.plain("");
4355
- if (shipped.length > 0) {
4356
- log.success(`Solved ${shipped.length} glass panel(s) \u2014 real translucency baked in the alpha:`);
4357
- }
4358
- for (const s of solved) {
4359
- if (s.path) {
4360
- log.plain(` ${s.path} ${c.dim(`key ${s.keyHex}, strength ${s.keyStrength}, flatness ${s.flatness.toFixed(3)}`)}`);
4361
- } else {
4362
- log.error(`Candidate rejected: ${s.rejected}`);
4363
- log.dim(` raw candidate stays at ${s.url}`);
4364
- }
4378
+ const meta = await readProject();
4379
+ if (!meta) {
4380
+ log.error("No genex project here. Run `genex init` in this directory first.");
4381
+ process.exitCode = 1;
4382
+ return;
4365
4383
  }
4366
- if (shipped.length > 0) {
4367
- log.plain("");
4368
- log.dim(
4369
- " Wire the PNG like any sprite (TextureLoader / <img>): its alpha IS the glass \u2014 no CSS opacity, no plate underneath. See genex-ai-image (--glass) and genex-ai-menu."
4370
- );
4371
- } else {
4384
+ const token = await readUserToken(opts.envPath);
4385
+ if (!token) {
4386
+ log.error("Not authorized. Run `genex init` first to sign in.");
4372
4387
  process.exitCode = 1;
4388
+ return;
4373
4389
  }
4374
- }
4375
- async function awaitAndReport(apiUrl, token, id, kind, log, open = false, json = false) {
4376
- if (!json) log.step("Generating\u2026 (this can take up to a minute)");
4377
- const view = await waitForTerminalView(
4378
- apiUrl,
4379
- token,
4380
- id,
4381
- kind,
4382
- json ? () => {
4383
- } : (p) => log.dim(` ${p}%`)
4384
- );
4385
- if (!view) {
4386
- if (json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
4387
- else log.error("Timed out waiting for the generation.");
4390
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
4391
+ log.step("Preparing a public repo for your game\u2026");
4392
+ let res;
4393
+ try {
4394
+ res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/make-remixable`, {
4395
+ method: "POST",
4396
+ headers: { Authorization: `Bearer ${token}` }
4397
+ });
4398
+ } catch (err) {
4399
+ log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
4388
4400
  process.exitCode = 1;
4389
4401
  return;
4390
4402
  }
4391
- await reportTerminal(kind, view, log, open, json);
4392
- }
4393
- async function waitForTerminalView(apiUrl, token, id, kind, onProgress = () => {
4394
- }) {
4395
- const timeoutMs = waitTimeoutFor(kind);
4396
- const deadline = Date.now() + timeoutMs;
4397
- const streamed = await waitViaSSE(apiUrl, token, id, onProgress, deadline);
4398
- return streamed === "unsupported" ? poll(apiUrl, token, id, onProgress, timeoutMs) : streamed;
4399
- }
4400
- async function reportTerminal(kind, view, log, open = false, json = false) {
4401
- if (view.status !== "completed") {
4402
- if (json) writeJson({ kind, id: view.id, status: view.status, error: view.error ?? null });
4403
- else log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
4404
- if (view.status === "failed") {
4405
- await recordTerminal(view.id, "failed");
4406
- if (kind === "video") {
4407
- const failures = await countFailed("video");
4408
- (failures >= 2 ? log.plain : log.dim)(videoFailureAdvice(failures));
4409
- }
4410
- }
4403
+ if (res.status === 401) {
4404
+ log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
4411
4405
  process.exitCode = 1;
4412
4406
  return;
4413
4407
  }
4414
- const files = view.files ?? [];
4415
- if (files.length === 0) {
4416
- if (json) writeJson({ kind, id: view.id, status: "failed", error: "Generation completed but produced no files." });
4417
- else log.error("Generation completed but produced no files.");
4408
+ if (res.status === 404) {
4409
+ log.error("That game wasn't found on your account. Re-run `genex init` or `genex link <slug>`.");
4418
4410
  process.exitCode = 1;
4419
4411
  return;
4420
4412
  }
4421
- await recordTerminal(view.id, "completed", files.map((f) => f.url));
4422
- if (kind === "character_concept") {
4423
- reportCharacterConceptTerminal(view, log, { json, open });
4413
+ if (!res.ok) {
4414
+ log.error(`Couldn't make the game public (HTTP ${res.status}).`);
4415
+ process.exitCode = 1;
4424
4416
  return;
4425
4417
  }
4426
- if (kind === "character_preview") {
4427
- reportCharacterPreviewTerminal(view, log, { json, open });
4418
+ const data = await res.json().catch(() => null);
4419
+ if (!data?.pushUrl) {
4420
+ log.error("The API didn't return a push URL.");
4421
+ process.exitCode = 1;
4428
4422
  return;
4429
4423
  }
4430
- if (json) {
4431
- writeJson({
4432
- kind,
4433
- id: view.id,
4434
- status: view.status,
4435
- files,
4436
- ...kind === "character" ? {
4437
- characterId: view.id,
4438
- nextCommand: `genex controller character --character ${view.id}`
4439
- } : {}
4440
- });
4424
+ log.step("Copying your game's source to the public repo\u2026");
4425
+ if (!await pushWorktree(process.cwd(), data.pushUrl, true, log)) {
4426
+ process.exitCode = 1;
4441
4427
  return;
4442
4428
  }
4443
- log.plain("");
4444
- log.success("Done \u2014 live on R2 (nothing downloaded or committed):");
4445
- for (const f of files) {
4446
- const label = files.length > 1 ? `${c.dim(`${f.role.replace(/^texture-/, "")}: `)}` : "";
4447
- log.plain(` ${label}${f.url}`);
4429
+ const newCloneUrl = data.project?.cloneUrl;
4430
+ if (newCloneUrl && newCloneUrl !== meta.cloneUrl) {
4431
+ await writeProject({ ...meta, cloneUrl: newCloneUrl });
4448
4432
  }
4449
4433
  log.plain("");
4450
- if (kind === "texture" && files[0]) await reportTextureSeam(files[0].url, log);
4451
- if (open && files[0]) {
4452
- openUrl(files[0].url);
4453
- log.plain(
4454
- ` ${c.bold("\u{1F441} Show this to the user")} \u2014 it just opened in their browser; paste this link in chat too so they can open it themselves:`
4455
- );
4456
- log.plain(` ${files[0].url}`);
4457
- log.plain("");
4434
+ log.success("Your game is public \u2014 anyone can remix it now. \u{1F310}");
4435
+ const dashboard = meta.dashboardOrigins?.[0];
4436
+ if (dashboard) {
4437
+ const page = meta.status === "published" ? "world" : "draft";
4438
+ log.plain(` your game's page: ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
4458
4439
  }
4459
- printHint(kind, view, files, log);
4460
- await maybePrintHudStageNudge(view, log);
4461
- }
4462
- var STAGE1_PROMPT_MARKER = "A screenshot of a complete game HUD";
4463
- var STAGE2_PROMPT_MARKER = "Look at this game HUD screenshot";
4464
- var MENU_STILL_PROMPT_MARKER = "No UI elements, no text, no buttons in the frame.";
4465
- var STAGE1_LANDING_NUDGE = "\u2192 HUD mockup landed: you are the art director \u2014 enqueue the Stage-2 sheet + menu still + logotype now (--no-wait). The keep/change answer can loop the concept later \u2014 do not wait for it (genex-ai-hud).";
4466
- async function shouldNudgeStage1(id, cwd = process.cwd()) {
4467
- const ledger = await readLedger(cwd);
4468
- const idx = ledger.findIndex((e) => e.id === id);
4469
- const me = ledger[idx];
4470
- if (!me || me.kind !== "image" || !me.prompt.startsWith(STAGE1_PROMPT_MARKER)) return false;
4471
- return !ledger.some(
4472
- (e, i) => i > idx && e.kind === "image" && e.prompt.startsWith(STAGE2_PROMPT_MARKER)
4473
- );
4474
4440
  }
4475
- async function maybePrintHudStageNudge(view, log) {
4441
+
4442
+ // src/lib/detect-features.ts
4443
+ import fs14 from "fs/promises";
4444
+ import path14 from "path";
4445
+
4446
+ // src/lib/generation-ledger.ts
4447
+ import fs13 from "fs/promises";
4448
+ import path13 from "path";
4449
+ var ledgerPath = (cwd) => path13.join(cwd, ".genex", "generations.ndjson");
4450
+ async function append(cwd, event) {
4476
4451
  try {
4477
- if (view.status !== "completed") return;
4478
- if (await shouldNudgeStage1(view.id)) {
4479
- log.plain("");
4480
- log.plain(` ${c.bold(STAGE1_LANDING_NUDGE)}`);
4481
- }
4452
+ await fs13.access(path13.join(cwd, ".genex"));
4453
+ await fs13.appendFile(ledgerPath(cwd), `${JSON.stringify(event)}
4454
+ `, "utf8");
4482
4455
  } catch {
4483
4456
  }
4484
4457
  }
4485
- function videoFailureAdvice(failures) {
4486
- if (failures >= 2) {
4487
- return ` ${c.bold(`\u270B ${failures} failed videos in this project \u2014 don't queue another attempt of the same clip.`)} One retry per clip is the budget. Use its key-art still as the backdrop instead (the genex-ai-menu skill's built-in fallback; a slow CSS pan gives it life), tell the user in one plain line, and spend the minutes in the game.`;
4488
- }
4489
- return " Video renders fail server-side sometimes. One retry is fair \u2014 shorter clip (4\u20136 s), simpler motion. After a second failure, switch to the key-art still (genex-ai-menu's fallback) instead of a third attempt.";
4490
- }
4491
- var WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
4492
- var KIND_WAIT_TIMEOUT_MS = {
4493
- video: 15 * 60 * 1e3,
4494
- // 15 min
4495
- motion: 20 * 60 * 1e3,
4496
- // 8 takes ≈ 8-9 GPU min + a serverless cold start
4497
- character_concept: 15 * 60 * 1e3,
4498
- character_preview: 30 * 60 * 1e3,
4499
- character: 30 * 60 * 1e3,
4500
- character_animation: 20 * 60 * 1e3,
4501
- // Per clip this lane is fast (locomotion ~2s, video ~51s, described ~130s) —
4502
- // the BATCH is what takes time. A 20-clip standard set is the sizing case.
4503
- character_motion: 30 * 60 * 1e3
4504
- };
4505
- var waitTimeoutFor = (kind) => KIND_WAIT_TIMEOUT_MS[kind] ?? WAIT_TIMEOUT_MS;
4506
- var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed"]);
4507
- async function waitViaSSE(apiUrl, token, id, onProgress, deadline) {
4508
- let connectFailures = 0;
4509
- let last = -1;
4510
- while (Date.now() < deadline) {
4511
- const abort = new AbortController();
4512
- const timer = setTimeout(() => abort.abort(), deadline - Date.now());
4513
- try {
4514
- let res;
4515
- try {
4516
- res = await apiFetch(`${apiUrl}/api/generations/${id}/events`, {
4517
- headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
4518
- signal: abort.signal
4519
- });
4520
- } catch {
4521
- if (++connectFailures >= 3) return "unsupported";
4522
- await sleep(2e3);
4523
- continue;
4524
- }
4525
- if (res.status === 404 || res.status === 405) return "unsupported";
4526
- const contentType = res.headers.get("content-type") ?? "";
4527
- if (!res.ok || !res.body || !contentType.includes("text/event-stream")) {
4528
- if (++connectFailures >= 3) return "unsupported";
4529
- await sleep(2e3);
4530
- continue;
4531
- }
4532
- connectFailures = 0;
4533
- try {
4534
- for await (const ev of readSSE(res.body)) {
4535
- if (ev.event !== "status") continue;
4536
- let view;
4537
- try {
4538
- view = JSON.parse(ev.data);
4539
- } catch {
4540
- continue;
4541
- }
4542
- if (view.progress !== last) {
4543
- last = view.progress;
4544
- onProgress(view.progress);
4545
- }
4546
- if (TERMINAL.has(view.status)) return view;
4547
- }
4548
- } catch {
4549
- }
4550
- await sleep(1e3);
4551
- } finally {
4552
- clearTimeout(timer);
4553
- abort.abort();
4554
- }
4458
+ async function readLedger(cwd = process.cwd()) {
4459
+ let raw;
4460
+ try {
4461
+ raw = await fs13.readFile(ledgerPath(cwd), "utf8");
4462
+ } catch {
4463
+ return [];
4555
4464
  }
4556
- return null;
4557
- }
4558
- async function poll(apiUrl, token, id, onProgress, timeoutMs) {
4559
- const deadline = Date.now() + timeoutMs;
4560
- let last = -1;
4561
- while (Date.now() < deadline) {
4465
+ const entries = /* @__PURE__ */ new Map();
4466
+ for (const line of raw.split("\n")) {
4467
+ if (!line.trim()) continue;
4562
4468
  try {
4563
- const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
4564
- headers: { Authorization: `Bearer ${token}` }
4565
- });
4566
- if (res.ok) {
4567
- const { generation } = await res.json();
4568
- if (generation.progress !== last) {
4569
- last = generation.progress;
4570
- onProgress(generation.progress);
4469
+ const e = JSON.parse(line);
4470
+ if (e.t === "q" && typeof e.id === "string" && typeof e.kind === "string") {
4471
+ if (!entries.has(e.id)) {
4472
+ entries.set(e.id, {
4473
+ id: e.id,
4474
+ kind: e.kind,
4475
+ prompt: typeof e.prompt === "string" ? e.prompt : "",
4476
+ ...typeof e.tail === "string" ? { promptTail: e.tail } : {},
4477
+ queuedAt: typeof e.at === "string" ? e.at : "",
4478
+ status: "queued"
4479
+ });
4571
4480
  }
4572
- if (generation.status === "completed" || generation.status === "failed") {
4573
- return generation;
4481
+ } else if (e.t === "done" && typeof e.id === "string") {
4482
+ const entry = entries.get(e.id);
4483
+ if (entry && (e.status === "completed" || e.status === "failed")) {
4484
+ entry.status = e.status;
4485
+ if (Array.isArray(e.urls)) entry.urls = e.urls.filter((u) => typeof u === "string");
4574
4486
  }
4575
4487
  }
4576
4488
  } catch {
4577
4489
  }
4578
- await sleep(3e3);
4579
- }
4580
- return null;
4581
- }
4582
- async function reportTextureSeam(url, log) {
4583
- try {
4584
- const res = await fetch(url);
4585
- if (!res.ok) return;
4586
- const png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
4587
- if (png.width < 4 || png.height < 4) return;
4588
- const r = measureSeam(png);
4589
- if (r.seamless) {
4590
- log.dim(` Tiling check: seam ${r.worstRatio}\xD7 this texture's own detail \u2014 tiles cleanly.`);
4591
- return;
4592
- }
4593
- log.error(
4594
- `Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
4595
- );
4596
- log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
4597
- log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground).');
4598
- log.plain(" Do NOT hide it by tiling less: a hand-picked repeat trades a seam you can name for a");
4599
- log.plain(" stretched texture you can't. Scale belongs in the UVs (worldUV \u2014 genex-ai-texture).");
4600
- log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
4601
- } catch {
4602
- }
4603
- }
4604
- function printHint(kind, view, files, log) {
4605
- const url = files[0]?.url ?? "";
4606
- const hint = {
4607
- model: `Standard GLB \u2014 load with GLTFLoader straight from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
4608
- skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
4609
- sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. url = "${url}"`,
4610
- music: `Load with AudioLoader into a non-positional THREE.Audio: setLoop(true), setVolume(0.30), start on the first user gesture \u2014 and the settings screen MUST have Music + SFX volume sliders \u2014 see genex-ai-music. url = "${url}"`,
4611
- voice: `Load with AudioLoader into a THREE.Audio (or PositionalAudio at the speaker); voice lines are content \u2014 keep them short, SUBTITLED, and skippable \u2014 see genex-ai-voice. url = "${url}"`,
4612
- texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`,
4613
- image: `Load with TextureLoader (set colorSpace = SRGBColorSpace) onto any mesh/plane/sprite \u2014 see genex-ai-image. url = "${url}"`,
4614
- video: `Wire an HTMLVideoElement (crossOrigin="anonymous", muted, loop, playsInline) into a THREE.VideoTexture \u2014 see genex-ai-video. url = "${url}"`,
4615
- motion: `Raw ARDY takes + verify report \u2014 download the takes, rename them meaningfully (routing is by filename), then: genex motion verify \u2192 compile \u2192 install. Full procedure: the genex-ai-character skill's motion reference.`,
4616
- character_concept: "Show all three concept candidates to the user and wait for an explicit selection.",
4617
- character_preview: "Show all four preview views to the user and wait for explicit approval before finalizing.",
4618
- character: `Install the controller and current manifest with genex controller character --character ${view.id}.`,
4619
- character_animation: "The action is now part of the character's current manifest; refresh the local controller manifest before testing.",
4620
- character_motion: `The clips are part of the character's current manifest \u2014 install them with genex controller character --character ${view.id}, then play the game and watch the motion on the real character before calling it done.`
4621
- };
4622
- log.dim(` ${hint[kind]}`);
4623
- log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
4624
- }
4625
- var CONCEPT_STOP = "STOP \u2014 show all three concept images to the user and wait for an explicit selection. Do not create the 3D preview until they choose candidate 1, 2, or 3.";
4626
- function metadataOf(view) {
4627
- return view.metadata && typeof view.metadata === "object" ? view.metadata : {};
4628
- }
4629
- function stringValue(value) {
4630
- return typeof value === "string" && value.length > 0 ? value : void 0;
4631
- }
4632
- function finiteNumber(value) {
4633
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
4634
- }
4635
- function conceptCandidates(view) {
4636
- const candidates = [];
4637
- for (const file of view.files ?? []) {
4638
- const match = /^(?:concept-candidate|character-concept)-([123])(?:\.[a-z0-9]+)?$/i.exec(file.role);
4639
- if (!match) continue;
4640
- candidates.push({ candidate: Number(match[1]), role: file.role, url: file.url });
4641
4490
  }
4642
- return candidates.sort((a, b) => a.candidate - b.candidate);
4643
- }
4644
- function conceptContactSheet(view) {
4645
- return (view.files ?? []).find(
4646
- (file) => /^(?:concept-gallery|character-concepts-contact-sheet)(?:\.[a-z0-9]+)?$/i.test(file.role)
4647
- );
4491
+ return [...entries.values()];
4648
4492
  }
4649
- function requestedBuildOf(view) {
4650
- const metadata = metadataOf(view);
4651
- const raw = metadata.requestedBuild && typeof metadata.requestedBuild === "object" ? metadata.requestedBuild : {};
4652
- const actionIds = Array.isArray(raw.actionIds) ? raw.actionIds.filter((id) => typeof id === "number" && Number.isInteger(id)) : [];
4653
- return {
4654
- actionIds,
4655
- controllerPack: raw.controllerPack !== false,
4656
- ...finiteNumber(raw.heightMeters) === void 0 ? {} : { heightMeters: finiteNumber(raw.heightMeters) }
4657
- };
4493
+ async function recordQueued(id, kind, prompt, cwd = process.cwd()) {
4494
+ await append(cwd, {
4495
+ t: "q",
4496
+ id,
4497
+ kind,
4498
+ prompt: prompt.slice(0, 120),
4499
+ ...prompt.length > 120 ? { tail: prompt.slice(-120) } : {},
4500
+ at: (/* @__PURE__ */ new Date()).toISOString()
4501
+ });
4658
4502
  }
4659
- function characterFinalizeCommand(previewId, intent) {
4660
- const args = [
4661
- "genex",
4662
- "character",
4663
- "finalize",
4664
- previewId,
4665
- "--user-approved",
4666
- "--approve-remesh",
4667
- "10000"
4668
- ];
4669
- if (intent.heightMeters !== void 0) args.push("--height", String(intent.heightMeters));
4670
- for (const actionId of intent.actionIds) args.push("--animation", String(actionId));
4671
- return args.join(" ");
4503
+ async function recordTerminal(id, status, urls, cwd = process.cwd()) {
4504
+ const existing = (await readLedger(cwd)).find((e) => e.id === id);
4505
+ if (existing && existing.status === status) return;
4506
+ await append(cwd, { t: "done", id, status, ...urls?.length ? { urls } : {} });
4672
4507
  }
4673
- function reportCharacterConceptTerminal(view, log, opts = {}) {
4674
- const candidates = conceptCandidates(view);
4675
- if (candidates.length !== 3 || candidates.some((entry, index) => entry.candidate !== index + 1)) {
4676
- const error = "Character concept generation completed without exactly candidates 1, 2, and 3.";
4677
- if (opts.json) writeJson({ kind: "character_concept", id: view.id, status: "failed", error });
4678
- else log.error(error);
4679
- process.exitCode = 1;
4680
- return;
4681
- }
4682
- const metadata = metadataOf(view);
4683
- const contactSheet = conceptContactSheet(view);
4684
- const nextCommand = `genex character preview ${view.id} --candidate <n> --user-approved`;
4685
- if (opts.open) openUrl(contactSheet?.url ?? candidates[0].url);
4686
- if (opts.json) {
4687
- writeJson({
4688
- kind: "character_concept",
4689
- stage: "concept",
4690
- id: view.id,
4691
- status: view.status,
4692
- provider: stringValue(metadata.provider),
4693
- model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
4694
- candidates,
4695
- ...contactSheet ? { contactSheetUrl: contactSheet.url } : {},
4696
- ...opts.quote ? { quote: opts.quote } : {},
4697
- requiresUserApproval: true,
4698
- nextCommand
4699
- });
4700
- return;
4701
- }
4702
- log.success("Three character concept candidates are ready:");
4703
- for (const candidate of candidates) log.plain(` ${candidate.candidate}. ${candidate.url}`);
4704
- if (contactSheet) log.plain(` Contact sheet: ${contactSheet.url}`);
4705
- log.plain("");
4706
- log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all three numbered links, not only your favorite.`);
4707
- log.plain(` ${c.bold(CONCEPT_STOP)}`);
4708
- log.plain(` After approval: ${c.cyan(nextCommand)}`);
4709
- log.plain("");
4508
+ async function countFailed(kind, cwd = process.cwd()) {
4509
+ return (await readLedger(cwd)).filter((e) => e.kind === kind && e.status === "failed").length;
4710
4510
  }
4711
- var PREVIEW_DIRECTIONS = ["front", "back", "left", "right"];
4712
- function previewViews(view) {
4713
- const metadata = metadataOf(view);
4714
- const rawViews = metadata.views && typeof metadata.views === "object" ? metadata.views : {};
4715
- const result = {};
4716
- for (const direction of PREVIEW_DIRECTIONS) {
4717
- const fromMetadata = stringValue(rawViews[direction]);
4718
- const fromFile = (view.files ?? []).find((file) => {
4719
- const role = file.role.toLowerCase();
4720
- return file.contentType.startsWith("image/") && role.includes(direction);
4721
- })?.url;
4722
- result[direction] = fromMetadata ?? fromFile;
4511
+
4512
+ // src/lib/detect-features.ts
4513
+ async function detectEmbedSdkVersion(cwd = process.cwd()) {
4514
+ try {
4515
+ const raw = await fs14.readFile(path14.join(cwd, "package.json"), "utf8");
4516
+ const pkg = JSON.parse(raw);
4517
+ const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
4518
+ return typeof version === "string" && version ? version : null;
4519
+ } catch {
4520
+ return null;
4723
4521
  }
4724
- return result;
4725
4522
  }
4726
- function previewSource(view) {
4727
- return (view.files ?? []).find(
4728
- (file) => file.contentType === "model/gltf-binary" || /(?:source-highpoly|character-preview)(?:\.glb)?$/i.test(file.role)
4729
- )?.url;
4523
+ async function detectMultiplayer(cwd = process.cwd()) {
4524
+ try {
4525
+ const raw = await fs14.readFile(path14.join(cwd, "package.json"), "utf8");
4526
+ const pkg = JSON.parse(raw);
4527
+ return Boolean(
4528
+ pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
4529
+ );
4530
+ } catch {
4531
+ return false;
4532
+ }
4730
4533
  }
4731
- function reportCharacterPreviewTerminal(view, log, opts = {}) {
4732
- const metadata = metadataOf(view);
4733
- const views = previewViews(view);
4734
- const sourceFaceCount = finiteNumber(metadata.sourceFaceCount);
4735
- const missingViews = PREVIEW_DIRECTIONS.filter((direction) => !views[direction]);
4736
- if (sourceFaceCount === void 0 || missingViews.length > 0) {
4737
- const detail = [
4738
- sourceFaceCount === void 0 ? "source face count" : "",
4739
- missingViews.length > 0 ? `${missingViews.join(", ")} view${missingViews.length === 1 ? "" : "s"}` : ""
4740
- ].filter(Boolean).join(" and ");
4741
- const error = `Character preview completed without ${detail}.`;
4742
- if (opts.json) writeJson({ kind: "character_preview", id: view.id, status: "failed", error });
4743
- else log.error(error);
4744
- process.exitCode = 1;
4745
- return;
4534
+ async function detectMatchmaking(log, cwd = process.cwd()) {
4535
+ let pkg;
4536
+ try {
4537
+ pkg = JSON.parse(await fs14.readFile(path14.join(cwd, "package.json"), "utf8"));
4538
+ } catch (err) {
4539
+ log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
4540
+ return null;
4746
4541
  }
4747
- const intent = requestedBuildOf(view);
4748
- const nextCommand = characterFinalizeCommand(view.id, intent);
4749
- const sourceUrl = previewSource(view);
4750
- const stop = `STOP \u2014 show the front, back, left, and right views to the user. The high-detail source has ${sourceFaceCount} faces and remains preserved in R2. Ask them to approve a separate 10,000-face triangle rigging copy.`;
4751
- if (opts.open) openUrl(views.front);
4752
- if (opts.json) {
4753
- writeJson({
4754
- kind: "character_preview",
4755
- stage: "preview",
4756
- id: view.id,
4757
- status: view.status,
4758
- provider: stringValue(metadata.provider),
4759
- model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
4760
- poseMode: stringValue(metadata.poseMode),
4761
- shouldRemesh: metadata.shouldRemesh === true,
4762
- sourceFaceCount,
4763
- views,
4764
- ...sourceUrl ? { sourceUrl } : {},
4765
- requestedBuild: intent,
4766
- ...opts.quote ? { quote: opts.quote } : {},
4767
- requiresUserApproval: true,
4768
- nextCommand
4769
- });
4770
- return;
4542
+ const mm = pkg.genex?.matchmaking;
4543
+ if (mm && typeof mm.preset === "string" && mm.preset) {
4544
+ return {
4545
+ preset: mm.preset,
4546
+ ...mm.winCondition ? { winCondition: mm.winCondition } : {},
4547
+ ...mm.config ? { config: mm.config } : {}
4548
+ };
4771
4549
  }
4772
- log.success("The high-detail Meshy character preview is ready:");
4773
- for (const direction of PREVIEW_DIRECTIONS) log.plain(` ${direction}: ${views[direction]}`);
4774
- if (sourceUrl) log.plain(` High-detail source: ${sourceUrl}`);
4775
- log.plain("");
4776
- log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all four views.`);
4777
- log.plain(` ${c.bold(stop)}`);
4778
- log.plain(` After approval: ${c.cyan(nextCommand)}`);
4779
- log.plain("");
4780
- }
4781
- function writeJson(value) {
4782
- process.stdout.write(`${JSON.stringify(value)}
4783
- `);
4550
+ return null;
4784
4551
  }
4785
-
4786
- // src/lib/deploy.ts
4787
- function run(cmd, args, env) {
4788
- return new Promise((resolve) => {
4789
- let child;
4552
+ var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
4553
+ async function detectMobileControls(cwd = process.cwd()) {
4554
+ try {
4555
+ const raw = await fs14.readFile(path14.join(cwd, "package.json"), "utf8");
4556
+ const pkg = JSON.parse(raw);
4557
+ if (pkg.genex?.mobileControls === true) return true;
4558
+ } catch {
4559
+ }
4560
+ const srcDir = path14.join(cwd, "src");
4561
+ let entries;
4562
+ try {
4563
+ entries = await fs14.readdir(srcDir, { recursive: true });
4564
+ } catch {
4565
+ return false;
4566
+ }
4567
+ for (const rel of entries) {
4568
+ if (rel.includes("node_modules")) continue;
4569
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
4790
4570
  try {
4791
- child = spawn4(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
4571
+ const content = await fs14.readFile(path14.join(srcDir, rel), "utf8");
4572
+ if (TOUCH_KIT_MARKERS.test(content)) return true;
4792
4573
  } catch {
4793
- resolve({ code: -1, out: "", err: `${cmd} not found` });
4794
- return;
4795
4574
  }
4796
- let out = "";
4797
- let err = "";
4798
- child.stdout?.on("data", (d) => out += String(d));
4799
- child.stderr?.on("data", (d) => err += String(d));
4800
- child.on("error", () => resolve({ code: -1, out, err: `${cmd} not found` }));
4801
- child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
4802
- });
4575
+ }
4576
+ return false;
4803
4577
  }
4804
- function printMobilePreflight(files, log) {
4578
+ var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
4579
+ async function detectGameStateUsage(cwd = process.cwd()) {
4580
+ const srcDir = path14.join(cwd, "src");
4581
+ let entries;
4805
4582
  try {
4806
- const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
4807
- const externalDims = /* @__PURE__ */ new Map();
4808
- for (const url of scan.genexAssetUrls) {
4809
- const role = url.split("/").pop() ?? "";
4810
- if (scan.markers.adaptiveQuality && role === SKYBOX_ROLE) {
4811
- externalDims.set(url, { width: 2048, height: 1024 });
4812
- } else if (scan.markers.modelRungs && role.endsWith("-glb")) {
4813
- externalDims.set(url, { width: 1024, height: 2048, glb: true });
4814
- } else {
4815
- externalDims.set(url, null);
4816
- }
4817
- }
4818
- const est = estimateVram(scan, externalDims);
4819
- const tier = tierFor(est.estVramMb);
4820
- const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
4821
- if (tier === "mobile-broad") {
4822
- log.dim(` ${line}`);
4823
- return;
4824
- }
4825
- log.warn(line);
4826
- for (const url of est.unresolvedExternals) {
4827
- const role = url.split("/").pop() ?? "";
4828
- if (role === SKYBOX_ROLE) {
4829
- log.warn(
4830
- ` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
4831
- );
4832
- } else if (role.endsWith("-glb")) {
4833
- log.warn(
4834
- ` Model loaded without pickModel (${url.slice(0, 80)}\u2026) \u2014 phones fetch the full provider-raw GLB. Load it through loadModelWithFallback (genex-threejs-adaptive-quality) so phones get the @1024 rung.`
4835
- );
4836
- }
4837
- }
4838
- if (est.textureMaxDim > 2048) {
4839
- log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
4583
+ entries = await fs14.readdir(srcDir, { recursive: true });
4584
+ } catch {
4585
+ return false;
4586
+ }
4587
+ for (const rel of entries) {
4588
+ if (rel.includes("node_modules")) continue;
4589
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
4590
+ try {
4591
+ const content = await fs14.readFile(path14.join(srcDir, rel), "utf8");
4592
+ if (GAME_STATE_CALLS.test(content)) return true;
4593
+ } catch {
4840
4594
  }
4841
- if (scan.markers.postStack && scan.markers.dprCapLiteral == null) {
4842
- log.warn(
4843
- " Post stack detected with no devicePixelRatio cap \u2014 cap at 1.5 on phones (genex-threejs-adaptive-quality)."
4844
- );
4595
+ }
4596
+ return false;
4597
+ }
4598
+ function ctorArgs(content, ctor) {
4599
+ const out = [];
4600
+ const re = new RegExp(`new\\s+(?:THREE\\.)?${ctor}\\s*\\(`, "g");
4601
+ let m;
4602
+ while (m = re.exec(content)) {
4603
+ let i = re.lastIndex;
4604
+ let depth = 1;
4605
+ while (i < content.length && depth > 0) {
4606
+ const ch = content[i];
4607
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
4608
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
4609
+ i++;
4845
4610
  }
4846
- if (!scan.markers.contextLossHandler) {
4847
- log.warn(" No context-loss handler found \u2014 one GPU reset shows a dead canvas on phones.");
4611
+ out.push({ text: content.slice(re.lastIndex, i - 1), index: m.index });
4612
+ }
4613
+ return out;
4614
+ }
4615
+ function splitArgs(text) {
4616
+ const out = [];
4617
+ let depth = 0;
4618
+ let start = 0;
4619
+ for (let i = 0; i < text.length; i++) {
4620
+ const ch = text[i];
4621
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
4622
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
4623
+ else if (ch === "," && depth === 0) {
4624
+ out.push(text.slice(start, i).trim());
4625
+ start = i + 1;
4848
4626
  }
4849
- } catch {
4850
4627
  }
4628
+ out.push(text.slice(start).trim());
4629
+ return out;
4851
4630
  }
4852
- async function printUiAuditPreflight(log) {
4631
+ var lineOf = (content, index) => content.slice(0, index).split("\n").length;
4632
+ var DEPTH_RATIO_LIMIT = 1e6;
4633
+ async function detectSurfaceScan(cwd = process.cwd()) {
4634
+ const found = { guessedRepeat: [], squarePoints: [], depthRange: [] };
4635
+ const srcDir = path14.join(cwd, "src");
4636
+ let entries;
4853
4637
  try {
4854
- const findings = await collectUiAuditFindings("public/assets/hud", "src");
4855
- if (!findings || findings.length === 0) return;
4856
- log.warn(
4857
- `HUD wiring audit: ${findings.length} finding(s) \u2014 heuristics, check each (\`npx genex ui audit\` for the full report):`
4858
- );
4859
- for (const finding of findings.slice(0, 6)) log.warn(` [${finding.kind}] ${finding.message}`);
4860
- if (findings.length > 6) log.dim(` \u2026and ${findings.length - 6} more.`);
4638
+ entries = await fs14.readdir(srcDir, { recursive: true });
4861
4639
  } catch {
4640
+ return found;
4862
4641
  }
4863
- }
4864
- async function printPipelineStatePreflight(log, cwd = process.cwd()) {
4865
- try {
4866
- const ledger = await readLedger(cwd);
4867
- const design = await fs14.readFile(path14.join(cwd, "DESIGN.md"), "utf8").catch(() => "");
4868
- const fullPrompt = (e) => `${e.prompt} ${e.promptTail ?? ""}`;
4869
- const warnings = [];
4870
- const stage1Done = ledger.some(
4871
- (e) => e.kind === "image" && e.status === "completed" && e.prompt.startsWith(STAGE1_PROMPT_MARKER)
4872
- );
4873
- const stage2Enqueued = ledger.some(
4874
- (e) => e.kind === "image" && e.prompt.startsWith(STAGE2_PROMPT_MARKER)
4875
- );
4876
- if (stage1Done && !stage2Enqueued) warnings.push(STAGE1_LANDING_NUDGE);
4877
- const menuStillDone = ledger.some(
4878
- (e) => e.kind === "image" && e.status === "completed" && fullPrompt(e).includes(MENU_STILL_PROMPT_MARKER)
4879
- );
4880
- const videoEnqueued = ledger.some((e) => e.kind === "video");
4881
- const videoDeclined = /menu video:\s*declined/i.test(design);
4882
- if (menuStillDone && !videoEnqueued && !videoDeclined) {
4883
- warnings.push(
4884
- 'Menu video not enqueued \u2014 the animated menu backdrop is the default for every game. Enqueue it (genex-ai-menu), or record "Menu video: declined (reason)" in DESIGN.md if the player declined it.'
4885
- );
4886
- }
4887
- if (design.length > 0 && !/##\s*build plan & status/i.test(design)) {
4888
- warnings.push(
4889
- "DESIGN.md has no `## Build plan & status` section \u2014 numbered milestones with a `Now:` line are how a compacted or resumed session knows where the build is (genex-game-director's design contract has the shape)."
4890
- );
4642
+ for (const rel of entries) {
4643
+ if (rel.includes("node_modules")) continue;
4644
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
4645
+ const raw = await fs14.readFile(path14.join(srcDir, rel), "utf8").catch(() => "");
4646
+ if (!raw) continue;
4647
+ const content = raw.replace(/\/\*[\s\S]*?\*\//g, (m2) => m2.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m2, p1) => p1 + " ".repeat(m2.length - p1.length));
4648
+ const repeatRe = /\.repeat\.set\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
4649
+ let m;
4650
+ while (m = repeatRe.exec(content)) {
4651
+ const u = Number(m[1]);
4652
+ const v = Number(m[2]);
4653
+ if (u === v) continue;
4654
+ found.guessedRepeat.push({
4655
+ where: `${rel}:${lineOf(content, m.index)}`,
4656
+ detail: `repeat.set(${m[1]}, ${m[2]})`
4657
+ });
4891
4658
  }
4892
- if (!/hud lane:/i.test(design)) {
4893
- const hudDom = await hasHudLikeDom(cwd);
4894
- if (hudDom && !await hasExtractionSidecars(cwd)) {
4895
- warnings.push(
4896
- 'HUD appears hand-built with no recorded lane decision \u2014 the concept decides the lane: run the sprite pipeline (genex-ai-hud), or record "HUD lane: CSS (\u2026, one-line justification)" in DESIGN.md.'
4897
- );
4898
- }
4659
+ for (const call of ctorArgs(content, "PointsMaterial")) {
4660
+ if (/\b(map|alphaMap)\s*:/.test(call.text)) continue;
4661
+ found.squarePoints.push({
4662
+ where: `${rel}:${lineOf(content, call.index)}`,
4663
+ detail: "PointsMaterial with no map/alphaMap"
4664
+ });
4899
4665
  }
4900
- if (!/player character:/i.test(design)) {
4901
- const hasCharacter = await fs14.access(path14.join(cwd, "public", "assets", "meshy-character.json")).then(() => true, () => false);
4902
- if (!hasCharacter && await loadsPlayerBody(cwd)) {
4903
- warnings.push(
4904
- `Player character is the stock avatar \u2014 no generated character is wired. The game's own generated character is the player's body wherever a human body appears on screen, first-person included (genex-ai-character). Generate it, or record "Player character: VRM \u2014 <reason>" in DESIGN.md (no human body in this game / out of credits / player declined).`
4905
- );
4906
- }
4666
+ for (const call of ctorArgs(content, "PerspectiveCamera")) {
4667
+ const args = splitArgs(call.text);
4668
+ if (args.length < 4) continue;
4669
+ const near = Number(args[2]);
4670
+ const far = Number(args[3]);
4671
+ if (!isFinite(near) || !isFinite(far) || near <= 0 || far <= near) continue;
4672
+ const ratio = far / near;
4673
+ if (ratio <= DEPTH_RATIO_LIMIT) continue;
4674
+ found.depthRange.push({
4675
+ where: `${rel}:${lineOf(content, call.index)}`,
4676
+ detail: `near ${near} / far ${far}`,
4677
+ ratio
4678
+ });
4907
4679
  }
4908
- for (const w of warnings) log.warn(w);
4909
- } catch {
4910
4680
  }
4681
+ return found;
4911
4682
  }
4912
- async function hasHudLikeDom(cwd) {
4913
- const HUD_MARKERS = /\bhud\b|health-?bar|mana-?bar|stamina-?bar|ammo-?count/i;
4914
- const files = [];
4683
+ var WIRED_BY_URL = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "music", "texture", "video"]);
4684
+ var UNPICKED_AFTER_MS = 15 * 60 * 1e3;
4685
+ async function detectGenerationAudit(cwd = process.cwd()) {
4686
+ const ledger = await readLedger(cwd);
4687
+ const audited = ledger.filter((e) => WIRED_BY_URL.has(e.kind));
4688
+ if (audited.length === 0) return { unwired: [], unpicked: [], unresolved: [], deadWired: [] };
4689
+ let haystack = "";
4690
+ const read = async (file) => {
4691
+ try {
4692
+ haystack += await fs14.readFile(file, "utf8");
4693
+ } catch {
4694
+ }
4695
+ };
4915
4696
  try {
4916
4697
  for (const entry of await fs14.readdir(cwd, { withFileTypes: true })) {
4917
- if (entry.isFile() && /\.(html|css)$/.test(entry.name)) files.push(path14.join(cwd, entry.name));
4698
+ if (entry.isFile() && /\.(ts|tsx|js|jsx|css|html|json)$/.test(entry.name)) {
4699
+ await read(path14.join(cwd, entry.name));
4700
+ }
4918
4701
  }
4919
4702
  } catch {
4920
4703
  }
4921
- try {
4922
- const entries = await fs14.readdir(path14.join(cwd, "src"), { recursive: true });
4923
- for (const rel of entries) {
4924
- if (rel.includes("node_modules")) continue;
4925
- if (/\.(ts|tsx|js|jsx|html|css)$/.test(rel)) files.push(path14.join(cwd, "src", rel));
4704
+ for (const sub of ["src", "public"]) {
4705
+ try {
4706
+ const entries = await fs14.readdir(path14.join(cwd, sub), { recursive: true });
4707
+ for (const rel of entries) {
4708
+ if (rel.includes("node_modules")) continue;
4709
+ if (!/\.(ts|tsx|js|jsx|css|html|json|txt)$/.test(rel)) continue;
4710
+ await read(path14.join(cwd, sub, rel));
4711
+ }
4712
+ } catch {
4926
4713
  }
4927
- } catch {
4928
- }
4929
- for (const file of files) {
4930
- const text = await fs14.readFile(file, "utf8").catch(() => "");
4931
- if (HUD_MARKERS.test(text)) return true;
4932
4714
  }
4933
- return false;
4715
+ const ref = ({ id, kind, prompt }) => ({
4716
+ id,
4717
+ kind,
4718
+ prompt
4719
+ });
4720
+ const now = Date.now();
4721
+ const stale = (e) => e.status === "queued" && now - Date.parse(e.queuedAt) > UNPICKED_AFTER_MS;
4722
+ return {
4723
+ unwired: audited.filter((e) => e.status === "completed" && !haystack.includes(e.id)).map(ref),
4724
+ unpicked: audited.filter((e) => stale(e) && !haystack.includes(e.id)).map(ref),
4725
+ // Wired in but never picked up: the deterministic URL let the agent wire
4726
+ // the asset before it finished, so nobody ever learned whether it DID
4727
+ // finish — a provider-side failure (credits, moderation) leaves a dead URL
4728
+ // that renders as a silently missing asset. Kept separate from `unpicked`
4729
+ // because the fix is a status check, not wiring.
4730
+ unresolved: audited.filter((e) => stale(e) && haystack.includes(e.id)).map(ref),
4731
+ // Known-failed AND still wired: the worst case, and it must keep firing
4732
+ // AFTER the status check converges the ledger to "failed" (otherwise the
4733
+ // audit self-erases the moment the agent runs the recommended command and
4734
+ // the dead URL ships silently anyway).
4735
+ deadWired: audited.filter((e) => e.status === "failed" && haystack.includes(e.id)).map(ref)
4736
+ };
4934
4737
  }
4935
- async function loadsPlayerBody(cwd) {
4936
- const BODY_LOADERS = /\bloadPlayerCharacter\s*\(|\bloadVrm(?:Clone)?\s*\(/;
4937
- try {
4938
- const entries = await fs14.readdir(path14.join(cwd, "src"), { recursive: true });
4939
- for (const rel of entries) {
4940
- if (rel.includes("node_modules")) continue;
4941
- if (rel.split(path14.sep)[0] === "controllers" || rel.startsWith("controllers/")) continue;
4942
- if (!/\.(ts|tsx|js|jsx)$/.test(rel)) continue;
4943
- const text = await fs14.readFile(path14.join(cwd, "src", rel), "utf8").catch(() => "");
4944
- if (BODY_LOADERS.test(text)) return true;
4945
- }
4946
- } catch {
4947
- }
4948
- return false;
4738
+ async function detectFeatures(log, cwd = process.cwd()) {
4739
+ return {
4740
+ embedSdkVersion: await detectEmbedSdkVersion(cwd),
4741
+ multiplayer: await detectMultiplayer(cwd),
4742
+ matchmaking: await detectMatchmaking(log, cwd),
4743
+ gameStateUsed: await detectGameStateUsage(cwd),
4744
+ mobileControls: await detectMobileControls(cwd),
4745
+ surfaces: await detectSurfaceScan(cwd),
4746
+ generations: await detectGenerationAudit(cwd)
4747
+ };
4949
4748
  }
4950
- async function hasExtractionSidecars(cwd) {
4951
- try {
4952
- const entries = await fs14.readdir(path14.join(cwd, "public"), { recursive: true });
4953
- return entries.some(
4954
- (rel) => rel.endsWith(".annotated-progress.json") || rel.endsWith(".bbox.json")
4749
+ function advisoryNudges(log, d) {
4750
+ if (!d.embedSdkVersion) {
4751
+ log.warn(
4752
+ "This game has no player identity or saves \u2014 players lose everything on refresh. The genex-threejs-embed-auth skill wires it with one call (initEmbed) \u2014 load it before publishing."
4955
4753
  );
4956
- } catch {
4957
- return false;
4958
- }
4959
- }
4960
- var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
4961
- function isSecretEnvFile(name) {
4962
- return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
4963
- }
4964
- async function deployGame(ctx, opts, log) {
4965
- const cwd = process.cwd();
4966
- if (!opts.noBuild && await hasBuildScript(cwd)) {
4967
- log.step("Building the production bundle\u2026");
4968
- const built = await run("npm", ["run", "build"]);
4969
- if (built.code !== 0) {
4970
- log.error("Build failed \u2014 your game was NOT deployed.");
4971
- const tail = (built.err || built.out).trim().split("\n").slice(-12).join("\n");
4972
- if (tail) log.dim(tail);
4973
- return false;
4974
- }
4975
- log.success("Built.");
4976
- }
4977
- const distDir = path14.join(cwd, "dist");
4978
- const siteDir = await isDir2(distDir) ? distDir : cwd;
4979
- const rel = path14.relative(cwd, siteDir) || ".";
4980
- if (siteDir === cwd) await writeGitignore(cwd, log);
4981
- const files = await collectFiles(siteDir);
4982
- if (files.length === 0) {
4983
- log.warn("Nothing to deploy \u2014 the build produced no files.");
4984
- return false;
4985
- }
4986
- if (!files.some((f) => f.relPath === "index.html")) {
4987
- log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
4988
- return false;
4989
4754
  }
4990
- const lfsPointers = files.filter(
4991
- (f) => f.bytes.length < 1024 && f.bytes.subarray(0, 64).toString("latin1").startsWith("version https://git-lfs.github.com/spec/v")
4992
- );
4993
- if (lfsPointers.length > 0) {
4994
- log.error(
4995
- `${lfsPointers.length} file${lfsPointers.length > 1 ? "s" : ""} in ${rel} ${lfsPointers.length > 1 ? "are" : "is"} an unresolved Git LFS pointer, not the real asset \u2014 the game would fail to load at runtime. Run \`git lfs pull\` and rebuild. Nothing deployed.`
4755
+ if (d.multiplayer && !d.embedSdkVersion) {
4756
+ log.warn(
4757
+ "Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
4996
4758
  );
4997
- for (const p of lfsPointers.slice(0, 8)) log.dim(` ${p.relPath}`);
4998
- if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
4999
- return false;
5000
- }
5001
- printMobilePreflight(files, log);
5002
- await printUiAuditPreflight(log);
5003
- await printPipelineStatePreflight(log);
5004
- const commit = contentCommit(files);
5005
- log.step("Requesting an upload token\u2026");
5006
- const grant = await getUploadToken(ctx, commit, log);
5007
- if (!grant) return false;
5008
- const totalBytes = files.reduce((n, f) => n + f.bytes.length, 0);
5009
- const mb = (n) => (n / 1048576).toFixed(n < 1048576 ? 2 : 1);
5010
- if (files.length > grant.limits.maxFiles) {
5011
- log.error(`Too many files (${files.length} > ${grant.limits.maxFiles}). Trim the build and retry.`);
5012
- return false;
5013
- }
5014
- const oversized = files.find((f) => f.bytes.length > grant.limits.maxFileBytes);
5015
- if (oversized) {
5016
- log.error(`${oversized.relPath} is ${mb(oversized.bytes.length)} MB \u2014 over the ${mb(grant.limits.maxFileBytes)} MB per-file limit.`);
5017
- return false;
5018
- }
5019
- if (totalBytes > grant.limits.maxTotalBytes) {
5020
- log.error(`Build is ${mb(totalBytes)} MB \u2014 over the ${mb(grant.limits.maxTotalBytes)} MB limit.`);
5021
- return false;
5022
4759
  }
5023
- log.step(`Uploading ${files.length} files\u2026`);
5024
- if (!await uploadAll(grant, files, log)) {
5025
- log.error("Couldn't upload your game \u2014 please try again.");
5026
- return false;
4760
+ if (d.matchmaking && !d.multiplayer) {
4761
+ log.warn(
4762
+ "package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
4763
+ );
5027
4764
  }
5028
- if (!await pushSource(cwd, ctx, log)) return false;
5029
- log.step("Publishing\u2026");
5030
- if (!await callPublish(ctx, commit, opts, log)) return false;
5031
- const index = files.find((f) => f.relPath === "index.html");
5032
- await waitUntilLive(grant.playUrl, fingerprintOf(index.bytes.toString("utf8")), opts.liveTimeoutMs ?? 2e4, log);
5033
- return true;
4765
+ surfaceNudges(log, d.surfaces);
4766
+ generationNudges(log, d.generations);
5034
4767
  }
5035
- async function hasBuildScript(cwd) {
5036
- try {
5037
- const pkg = JSON.parse(await fs14.readFile(path14.join(cwd, "package.json"), "utf8"));
5038
- return Boolean(pkg.scripts?.build);
5039
- } catch {
5040
- return false;
4768
+ function generationNudges(log, g) {
4769
+ const describe = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
4770
+ if (g.unpicked.length) {
4771
+ log.warn(
4772
+ `${g.unpicked.length} generation(s) enqueued 15+ minutes ago were never picked up: ${describe(g.unpicked)}. Run npx genex wait <id> on each (npx genex wait --all shows every status at once) \u2014 landed assets get wired in before the last push of a session, never parked for "later".`
4773
+ );
5041
4774
  }
5042
- }
5043
- async function collectFiles(root) {
5044
- const out = [];
5045
- const walk2 = async (dir, prefix) => {
5046
- for (const e of await fs14.readdir(dir, { withFileTypes: true })) {
5047
- const relPath = prefix ? `${prefix}/${e.name}` : e.name;
5048
- if (e.isDirectory()) {
5049
- if (!EXCLUDE_DIRS.has(e.name)) await walk2(path14.join(dir, e.name), relPath);
5050
- } else if (e.isFile() && !isSecretEnvFile(e.name)) {
5051
- out.push({ relPath, bytes: await fs14.readFile(path14.join(dir, e.name)) });
5052
- }
5053
- }
5054
- };
5055
- await walk2(root, "");
5056
- return out;
5057
- }
5058
- function contentCommit(files) {
5059
- const h = crypto3.createHash("sha1");
5060
- for (const f of [...files].sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)) {
5061
- h.update(f.relPath, "utf8");
5062
- h.update("\0");
5063
- h.update(f.bytes);
4775
+ if (g.unresolved.length) {
4776
+ log.warn(
4777
+ `${g.unresolved.length} generation(s) are wired into the game but were never picked up, so their real status is unknown: ${describe(g.unresolved)}. A generation that failed server-side (credits, moderation) leaves a dead URL \u2014 the asset just silently never appears. One call checks them all: npx genex wait --all.`
4778
+ );
5064
4779
  }
5065
- return h.digest("hex");
5066
- }
5067
- function retryAfterHint(res) {
5068
- const raw = Number(res.headers.get("retry-after"));
5069
- if (!Number.isFinite(raw) || raw <= 0) return "";
5070
- const secs = Math.ceil(raw);
5071
- return ` Try again in ${secs < 90 ? `${secs}s` : `~${Math.ceil(secs / 60)} min`}.`;
5072
- }
5073
- async function getUploadToken(ctx, commit, log) {
5074
- let res;
5075
- try {
5076
- res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
5077
- method: "POST",
5078
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
5079
- body: JSON.stringify({ commit })
5080
- });
5081
- } catch (err) {
5082
- log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
5083
- return null;
4780
+ if (g.deadWired.length) {
4781
+ log.warn(
4782
+ `${g.deadWired.length} FAILED generation(s) are still wired into the game: ${describe(g.deadWired)}. Those URLs will never exist \u2014 each asset silently never appears. Replace the wiring or re-generate (that bills a new one), and tell the user what changed.`
4783
+ );
5084
4784
  }
5085
- if (res.status === 401) {
5086
- log.error("Not authorized (HTTP 401) \u2014 your sign-in expired or was revoked.");
5087
- log.plain(" Fix: run `genex link <your-game>` and sign in as the game's owner, then re-run this command.");
5088
- return null;
4785
+ if (g.unwired.length) {
4786
+ log.warn(
4787
+ `${g.unwired.length} finished generation(s) are wired nowhere in the game: ${describe(g.unwired)}. Each was paid for \u2014 wire it in (its URL embeds the id) or tell the user in one plain line why it stays unused.`
4788
+ );
5089
4789
  }
5090
- if (res.status === 404) {
5091
- log.error("This account doesn't own this game (HTTP 404) \u2014 you're signed in as a different account.");
5092
- log.plain(" Fix: run `genex link <your-game>` and sign in as the OWNER account, then re-run this command.");
5093
- return null;
4790
+ }
4791
+ function surfaceNudges(log, s) {
4792
+ if (s.guessedRepeat.length) {
4793
+ const list = s.guessedRepeat.map((d) => `${d.where} \u2014 ${d.detail}`).join("; ");
4794
+ log.warn(
4795
+ `Texture tiling picked by eye, not derived from the mesh: ${list}. A hand-chosen repeat is right for at most ONE face \u2014 a BoxGeometry gives all six faces 0..1 UVs whatever their world size, and repeat lives on the Texture, so every mesh sharing that texture inherits it. Derive the UVs from world size instead: genex-ai-texture ships worldUV(geometry, TILE_M), after which repeat stays (1, 1) and cannot be wrong.`
4796
+ );
5094
4797
  }
5095
- if (res.status === 429) {
5096
- log.error(`Rate limited requesting an upload token (HTTP 429).${retryAfterHint(res)}`);
5097
- return null;
4798
+ if (s.squarePoints.length) {
4799
+ const list = s.squarePoints.map((d) => d.where).join(", ");
4800
+ log.warn(
4801
+ `Particles will render as hard SQUARES: ${list} builds a PointsMaterial with no map/alphaMap, so every point is an opaque camera-facing quad. Give it a soft sprite (genex image --transparent) or a round gl_PointCoord cutout \u2014 and check the particle belongs there at all: genex-threejs-procedural-vfx decides that per moment.`
4802
+ );
5098
4803
  }
5099
- if (!res.ok) {
5100
- log.error(`Couldn't get an upload token (HTTP ${res.status}).`);
5101
- return null;
4804
+ for (const d of s.depthRange) {
4805
+ log.warn(
4806
+ `Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get. If the range is genuinely astronomical, that is what WebGLRenderer's logarithmicDepthBuffer is for: genex-threejs-camera-direction.`
4807
+ );
5102
4808
  }
5103
- return await res.json();
5104
4809
  }
5105
- async function uploadAll(grant, files, log) {
5106
- const base = grant.uploadUrl.replace(/\/+$/, "");
5107
- const threshold = grant.limits.singlePutMaxBytes ?? grant.limits.maxFileBytes;
5108
- const small = files.filter((f) => f.bytes.length <= threshold);
5109
- const large = files.filter((f) => f.bytes.length > threshold);
5110
- let failed = false;
5111
- let next = 0;
5112
- const worker = async () => {
5113
- while (!failed) {
5114
- const i = next++;
5115
- if (i >= small.length) return;
5116
- const f = small[i];
5117
- const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
5118
- let res = null;
5119
- try {
5120
- res = await fetch(`${base}/${encoded}`, {
5121
- method: "PUT",
5122
- headers: { Authorization: `Bearer ${grant.token}` },
5123
- body: f.bytes
5124
- });
5125
- } catch {
5126
- res = null;
5127
- }
5128
- if (!res || !res.ok) {
5129
- failed = true;
5130
- log.warn(`Upload failed for ${f.relPath}${res ? ` (HTTP ${res.status})` : " (network error)"}.`);
5131
- }
4810
+ async function exploreReuseNudge(log, meta, cwd = process.cwd()) {
4811
+ if (!meta.exploreRanAt || meta.reuseNudgedAt) return;
4812
+ if (await borrowEvidence(meta, cwd)) return;
4813
+ log.warn(
4814
+ "This project browsed the curated catalog (genex explore) but no borrowed catalog project is visible in the game. If the plan mapped systems to the catalog \u2014 grass, terrain, water, vehicles \u2014 land those borrows or cancel each in one plain line to the user: a planned borrow that silently becomes hand-rolled code is a scope cut."
4815
+ );
4816
+ meta.reuseNudgedAt = (/* @__PURE__ */ new Date()).toISOString();
4817
+ try {
4818
+ const onDisk = await readProject(cwd);
4819
+ if (onDisk) {
4820
+ onDisk.reuseNudgedAt = meta.reuseNudgedAt;
4821
+ await writeProject(onDisk, cwd);
5132
4822
  }
5133
- };
5134
- await Promise.all(Array.from({ length: Math.min(8, small.length) }, () => worker()));
5135
- if (failed) return false;
5136
- for (const f of large) {
5137
- if (!await uploadMultipart(base, grant.token, f, grant.limits.partSizeBytes, log)) return false;
4823
+ } catch {
5138
4824
  }
5139
- return true;
5140
4825
  }
5141
- async function uploadMultipart(base, uploadToken, f, partSize, log) {
5142
- const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
5143
- const auth = { Authorization: `Bearer ${uploadToken}` };
5144
- const totalParts = Math.ceil(f.bytes.length / partSize);
5145
- log.step(`Uploading ${f.relPath} (${(f.bytes.length / 1048576).toFixed(1)} MB in ${totalParts} parts)\u2026`);
5146
- let uploadId;
4826
+ async function borrowEvidence(meta, cwd) {
4827
+ let host;
4828
+ let ownPath;
5147
4829
  try {
5148
- const res = await fetch(`${base}/${encoded}?action=mpu-create`, { method: "POST", headers: auth });
5149
- if (!res.ok) {
5150
- log.warn(`Upload failed for ${f.relPath} (HTTP ${res.status} starting multipart).`);
5151
- return false;
5152
- }
5153
- uploadId = (await res.json()).uploadId;
4830
+ const own = new URL(meta.cloneUrl);
4831
+ host = own.host;
4832
+ ownPath = own.pathname;
5154
4833
  } catch {
5155
- log.warn(`Upload failed for ${f.relPath} (network error starting multipart).`);
5156
- return false;
4834
+ return true;
5157
4835
  }
5158
- const abort = async () => {
4836
+ const gitConfig = await fs14.readFile(path14.join(cwd, ".git", "config"), "utf8").catch(() => "");
4837
+ for (const m of gitConfig.matchAll(/url\s*=\s*(\S+)/g)) {
5159
4838
  try {
5160
- await fetch(`${base}/${encoded}?action=mpu-abort&uploadId=${encodeURIComponent(uploadId)}`, {
5161
- method: "DELETE",
5162
- headers: auth
5163
- });
4839
+ const u = new URL(m[1]);
4840
+ if (u.host === host && u.pathname !== ownPath) return true;
5164
4841
  } catch {
5165
4842
  }
5166
- };
5167
- const parts = [];
5168
- let failed = false;
5169
- let next = 0;
5170
- const partWorker = async () => {
5171
- while (!failed) {
5172
- const i = next++;
5173
- if (i >= totalParts) return;
5174
- const partNumber = i + 1;
5175
- const bytes = f.bytes.subarray(i * partSize, Math.min((i + 1) * partSize, f.bytes.length));
5176
- const part = await uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, bytes);
5177
- if (!part) {
5178
- failed = true;
5179
- log.warn(`Upload failed for ${f.relPath} (part ${partNumber}/${totalParts}).`);
5180
- return;
5181
- }
5182
- parts.push(part);
4843
+ }
4844
+ const readme = await fs14.readFile(path14.join(cwd, "README.md"), "utf8").catch(() => "");
4845
+ return readme.includes(host) || /\b(upstream|originally by|ported from|borrowed from)\b/i.test(readme);
4846
+ }
4847
+
4848
+ // src/commands/publish.ts
4849
+ async function runPublish(opts) {
4850
+ const log = createLogger({ quiet: opts.quiet });
4851
+ log.plain(c.bold("genex publish"));
4852
+ log.plain("");
4853
+ const token = opts.token ?? await readUserToken(opts.envPath);
4854
+ if (!token) {
4855
+ log.error("Not authorized. Run `genex init` first to sign in.");
4856
+ process.exitCode = 1;
4857
+ return;
4858
+ }
4859
+ const meta = await readProject();
4860
+ if (!meta) {
4861
+ log.error("No genex project here. Run `genex init` in this directory first.");
4862
+ process.exitCode = 1;
4863
+ return;
4864
+ }
4865
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
4866
+ const detections = await detectFeatures(log);
4867
+ advisoryNudges(log, detections);
4868
+ if (!opts.noPush) {
4869
+ const ok = await deployGame(
4870
+ { projectId: meta.id, apiUrl, token },
4871
+ {
4872
+ noBuild: opts.noBuild,
4873
+ matchmaking: detections.matchmaking,
4874
+ embedSdkVersion: detections.embedSdkVersion,
4875
+ multiplayer: detections.multiplayer,
4876
+ mobileControls: detections.mobileControls
4877
+ },
4878
+ log
4879
+ );
4880
+ if (!ok) {
4881
+ process.exitCode = 1;
4882
+ return;
5183
4883
  }
5184
- };
5185
- await Promise.all(Array.from({ length: Math.min(4, totalParts) }, () => partWorker()));
5186
- if (failed) {
5187
- await abort();
5188
- return false;
4884
+ const onDisk = await readProject();
4885
+ const merged = { ...onDisk ?? meta, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
4886
+ await writeProject(merged);
4887
+ meta.lastDeployAt = merged.lastDeployAt;
4888
+ } else {
4889
+ log.info("Skipping build + deploy (--no-push).");
4890
+ }
4891
+ log.step("Listing it in the gallery\u2026");
4892
+ let res;
4893
+ try {
4894
+ const body = {};
4895
+ if (opts.title) body.title = opts.title;
4896
+ if (opts.description) body.description = opts.description;
4897
+ if (opts.regenerateCover) body.regenerateCover = true;
4898
+ if (opts.categories?.length) body.categories = opts.categories;
4899
+ if (opts.sourceRepoUrl) body.sourceRepoUrl = opts.sourceRepoUrl;
4900
+ if (opts.sourceAuthor) body.sourceAuthor = opts.sourceAuthor;
4901
+ if (opts.license) body.license = opts.license;
4902
+ if (opts.purchaseUrl) body.purchaseUrl = opts.purchaseUrl;
4903
+ if (opts.productName) body.productName = opts.productName;
4904
+ if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
4905
+ body.multiplayer = detections.multiplayer;
4906
+ body.matchmaking = detections.matchmaking ?? null;
4907
+ body.gameStateUsed = detections.gameStateUsed;
4908
+ body.mobileControls = detections.mobileControls;
4909
+ res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
4910
+ method: "POST",
4911
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
4912
+ body: JSON.stringify(body)
4913
+ });
4914
+ } catch {
4915
+ log.error("Couldn't reach Genex \u2014 please try again.");
4916
+ process.exitCode = 1;
4917
+ return;
4918
+ }
4919
+ if (!res.ok) {
4920
+ log.error("Publish failed \u2014 please try again.");
4921
+ process.exitCode = 1;
4922
+ return;
4923
+ }
4924
+ log.plain("");
4925
+ log.success("Published. \u{1F389}");
4926
+ const dashboard = meta.dashboardOrigins?.[0];
4927
+ if (dashboard) log.plain(` world page (share this link): ${c.cyan(`${dashboard}/world/${meta.slug}`)}`);
4928
+ if (meta.playUrl) log.dim(` play: ${meta.playUrl}`);
4929
+ }
4930
+
4931
+ // src/commands/preview.ts
4932
+ async function runPreview(opts) {
4933
+ const log = createLogger({ quiet: opts.quiet });
4934
+ log.plain(c.bold("genex preview"));
4935
+ log.plain("");
4936
+ const meta = await readProject();
4937
+ if (!meta) {
4938
+ log.error("No genex project here. Run `genex init` in this directory first.");
4939
+ process.exitCode = 1;
4940
+ return;
4941
+ }
4942
+ const token = await readUserToken(opts.envPath);
4943
+ if (!token) {
4944
+ log.error("Not authorized. Run `genex init` first to sign in.");
4945
+ process.exitCode = 1;
4946
+ return;
4947
+ }
4948
+ const detections = await detectFeatures(log);
4949
+ advisoryNudges(log, detections);
4950
+ await exploreReuseNudge(log, meta);
4951
+ const apiUrl = getApiUrl(meta.apiUrl);
4952
+ const ok = await deployGame(
4953
+ { projectId: meta.id, apiUrl, token },
4954
+ // Detections reach the server on every preview too: matchmaking so a draft's
4955
+ // declared preset doesn't silently run the default (null clears a removed
4956
+ // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
4957
+ // list the draft without wrongly treating it as a pre-embed-auth bundle.
4958
+ {
4959
+ noBuild: opts.noBuild,
4960
+ matchmaking: detections.matchmaking,
4961
+ embedSdkVersion: detections.embedSdkVersion,
4962
+ multiplayer: detections.multiplayer,
4963
+ mobileControls: detections.mobileControls
4964
+ },
4965
+ log
4966
+ );
4967
+ if (!ok) {
4968
+ process.exitCode = 1;
4969
+ return;
4970
+ }
4971
+ const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
4972
+ if (freshStatus && freshStatus !== meta.status) {
4973
+ meta.status = freshStatus;
5189
4974
  }
5190
- parts.sort((a, b) => a.partNumber - b.partNumber);
5191
- try {
5192
- const res = await fetch(`${base}/${encoded}?action=mpu-complete&uploadId=${encodeURIComponent(uploadId)}`, {
5193
- method: "POST",
5194
- headers: { ...auth, "Content-Type": "application/json" },
5195
- body: JSON.stringify({ parts })
5196
- });
5197
- if (!res.ok) {
5198
- log.warn(`Upload failed for ${f.relPath} (HTTP ${res.status} completing multipart).`);
5199
- await abort();
5200
- return false;
5201
- }
5202
- } catch {
5203
- log.warn(`Upload failed for ${f.relPath} (network error completing multipart).`);
5204
- await abort();
5205
- return false;
4975
+ const onDisk = await readProject();
4976
+ const merged = { ...onDisk ?? meta, status: meta.status, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
4977
+ await writeProject(merged);
4978
+ meta.lastDeployAt = merged.lastDeployAt;
4979
+ const published = meta.status === "published";
4980
+ log.plain("");
4981
+ if (published) {
4982
+ log.success("Update deployed \u2014 this game is PUBLISHED, so the new build is already live for everyone.");
4983
+ } else {
4984
+ log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
4985
+ }
4986
+ const dashboard = meta.dashboardOrigins?.[0];
4987
+ if (dashboard) {
4988
+ const page = published ? "world" : "draft";
4989
+ log.plain(` your game's page (share this link): ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
5206
4990
  }
5207
- return true;
5208
4991
  }
5209
- async function uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, bytes) {
5210
- for (let attempt = 0; ; attempt++) {
5211
- let res = null;
5212
- try {
5213
- res = await fetch(
5214
- `${base}/${encoded}?action=mpu-part&uploadId=${encodeURIComponent(uploadId)}&partNumber=${partNumber}`,
5215
- { method: "PUT", headers: auth, body: bytes }
5216
- );
5217
- if (res.ok) return await res.json();
5218
- } catch {
5219
- res = null;
4992
+
4993
+ // src/commands/generate.ts
4994
+ import fs15 from "fs/promises";
4995
+ import path15 from "path";
4996
+ import { PNG as PNG4 } from "pngjs";
4997
+
4998
+ // src/lib/glass.ts
4999
+ import { PNG as PNG3 } from "pngjs";
5000
+ var GLASS_PROMPT_SUFFIX = " Rendered on a perfectly flat, uniform, solid pure magenta background, hex #FF00FF, like a studio chroma-key screen: absolutely no shading, no gradient, no vignette, no glow, no shadows on the background. Subject centered, sharp edge silhouette.";
5001
+ var GLASS_MIN_FLATNESS = 0.99;
5002
+ var GLASS_MIN_KEY_STRENGTH = 180;
5003
+ var FLATNESS_DEV_MAX = 12;
5004
+ var clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
5005
+ function median(values) {
5006
+ const s = values.slice().sort((a, b) => a - b);
5007
+ return s[Math.floor(s.length / 2)] ?? 0;
5008
+ }
5009
+ function solveMagentaGlass(source) {
5010
+ const { width: W, height: H, data } = source;
5011
+ const ringW = Math.max(2, Math.round(Math.min(W, H) * 0.04));
5012
+ const rs = [];
5013
+ const gs = [];
5014
+ const bs = [];
5015
+ const ringIdx = [];
5016
+ for (let y = 0; y < H; y++) {
5017
+ for (let x = 0; x < W; x++) {
5018
+ const inRing = x < ringW || y < ringW || x >= W - ringW || y >= H - ringW;
5019
+ if (!inRing) continue;
5020
+ const i = (y * W + x) * 4;
5021
+ ringIdx.push(i);
5022
+ rs.push(data[i]);
5023
+ gs.push(data[i + 1]);
5024
+ bs.push(data[i + 2]);
5220
5025
  }
5221
- if (res && res.status < 500) return null;
5222
- if (attempt >= 2) return null;
5223
- await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 500 : 2e3));
5224
5026
  }
5225
- }
5226
- async function callPublish(ctx, commit, opts, log) {
5227
- let res;
5228
- try {
5229
- res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
5230
- method: "POST",
5231
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
5232
- // Detections ride every go-live (preview AND publish): matchmaking is
5233
- // tri-state (object = upsert, null = clear); embedSdkVersion/multiplayer
5234
- // are sent only when detected (absent = server keeps the stored value).
5235
- body: JSON.stringify({
5236
- commit,
5237
- matchmaking: opts.matchmaking ?? null,
5238
- ...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
5239
- ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
5240
- ...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
5241
- })
5242
- });
5243
- } catch (err) {
5244
- log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
5245
- return false;
5027
+ const K = { r: median(rs), g: median(gs), b: median(bs) };
5028
+ const keyHex = `#${[K.r, K.g, K.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
5029
+ const keyStrength = Math.min(K.r, K.b) - K.g;
5030
+ let flat = 0;
5031
+ for (const i of ringIdx) {
5032
+ const dev = Math.max(
5033
+ Math.abs(data[i] - K.r),
5034
+ Math.abs(data[i + 1] - K.g),
5035
+ Math.abs(data[i + 2] - K.b)
5036
+ );
5037
+ if (dev < FLATNESS_DEV_MAX) flat++;
5246
5038
  }
5247
- if (res.status === 429) {
5248
- log.error(`Rate limited publishing (HTTP 429).${retryAfterHint(res)}`);
5249
- return false;
5039
+ const flatness = ringIdx.length === 0 ? 0 : flat / ringIdx.length;
5040
+ const base = { keyColor: K, keyHex, keyStrength, flatness };
5041
+ if (flatness < GLASS_MIN_FLATNESS || keyStrength < GLASS_MIN_KEY_STRENGTH) {
5042
+ return {
5043
+ ...base,
5044
+ png: source,
5045
+ ok: false,
5046
+ rejected: `backdrop not keyable (flatness ${flatness.toFixed(3)}, key strength ${keyStrength} \u2014 want \u2265${GLASS_MIN_FLATNESS}/${GLASS_MIN_KEY_STRENGTH}): the model shaded the magenta screen instead of painting it flat. Regenerate (same prompt \u2014 this is a rare roll), or steer harder: keep the --glass suffix intact and avoid magenta/pink art in the subject.`
5047
+ };
5250
5048
  }
5251
- if (!res.ok) {
5252
- const detail = await res.text().catch(() => "");
5253
- log.error(`Publish failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}.`);
5254
- return false;
5049
+ const out = new PNG3({ width: W, height: H });
5050
+ const mKey = Math.max(1, keyStrength);
5051
+ for (let p = 0; p < W * H; p++) {
5052
+ const i = p * 4;
5053
+ const R = data[i];
5054
+ const G = data[i + 1];
5055
+ const B = data[i + 2];
5056
+ const m = Math.max(0, Math.min(R, B) - G);
5057
+ let a = 1 - m / mKey;
5058
+ if (a < 0) a = 0;
5059
+ if (a > 1) a = 1;
5060
+ if (a <= 0.02) {
5061
+ out.data[i] = 0;
5062
+ out.data[i + 1] = 0;
5063
+ out.data[i + 2] = 0;
5064
+ out.data[i + 3] = 0;
5065
+ continue;
5066
+ }
5067
+ out.data[i] = clamp255((R - (1 - a) * K.r) / a);
5068
+ out.data[i + 1] = clamp255((G - (1 - a) * K.g) / a);
5069
+ out.data[i + 2] = clamp255((B - (1 - a) * K.b) / a);
5070
+ out.data[i + 3] = Math.round(a * 255);
5255
5071
  }
5256
- return true;
5257
- }
5258
- async function pushSource(cwd, ctx, log) {
5259
- const target = await fetchPushUrl(ctx, log);
5260
- if (!target) return false;
5261
- if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
5262
- if (!target.managed) return false;
5263
- log.info("Retrying the source push\u2026");
5264
- await new Promise((r) => setTimeout(r, 2e3));
5265
- const fresh = await fetchPushUrl(ctx, log);
5266
- if (!fresh) return false;
5267
- return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
5072
+ return { ...base, png: out, ok: true };
5268
5073
  }
5269
- function urlHasEmbeddedCredentials(pushUrl) {
5270
- return /^[a-z][a-z0-9+.-]*:\/\/[^/@]+@/i.test(pushUrl);
5074
+
5075
+ // src/lib/open.ts
5076
+ import { spawn as spawn4 } from "child_process";
5077
+ function tokenize(cmd) {
5078
+ return cmd.trim().split(/\s+/).filter(Boolean);
5271
5079
  }
5272
- async function pushWorktree(cwd, pushUrl, managed, log) {
5273
- const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
5274
- const failed = () => {
5275
- log.error("Couldn't save your game's source \u2014 please try again.");
5276
- return false;
5277
- };
5278
- const gitDir = await fs14.mkdtemp(path14.join(os4.tmpdir(), "genex-source-"));
5279
- const base = { GIT_DIR: gitDir };
5280
- if (urlHasEmbeddedCredentials(pushUrl)) {
5281
- base.GIT_CONFIG_COUNT = "1";
5282
- base.GIT_CONFIG_KEY_0 = "credential.helper";
5283
- base.GIT_CONFIG_VALUE_0 = "";
5284
- }
5285
- const ident = {
5286
- GIT_AUTHOR_NAME: "genex",
5287
- GIT_AUTHOR_EMAIL: "agent@genex.local",
5288
- GIT_COMMITTER_NAME: "genex",
5289
- GIT_COMMITTER_EMAIL: "agent@genex.local"
5290
- };
5291
- try {
5292
- if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
5293
- await fs14.writeFile(
5294
- path14.join(gitDir, "info", "exclude"),
5295
- // .env* are secrets — never publish them; `!` keeps the non-secret template.
5296
- ["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
5297
- );
5298
- const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path14.join(gitDir, "index-source") };
5299
- let lfs = (await run("git", ["lfs", "version"], base)).code !== 0 ? false : true;
5300
- if (!lfs) {
5301
- log.step("Installing git-lfs (keeps large binary assets out of the source push)\u2026");
5302
- const [bin, args] = process.platform === "darwin" ? ["brew", ["install", "git-lfs"]] : ["sudo", ["-n", "apt-get", "install", "-y", "git-lfs"]];
5303
- await run(bin, args, base);
5304
- lfs = (await run("git", ["lfs", "version"], base)).code === 0;
5305
- if (!lfs) {
5306
- log.warn(
5307
- "Couldn't install git-lfs \u2014 large binary assets won't be saved to your source (the game still publishes)."
5308
- );
5309
- }
5310
- }
5311
- if (lfs) {
5312
- await writeGitattributes(cwd, log);
5313
- const filters = [
5314
- ["filter.lfs.clean", "git-lfs clean -- %f"],
5315
- ["filter.lfs.smudge", "git-lfs smudge -- %f"],
5316
- ["filter.lfs.process", "git-lfs filter-process"],
5317
- ["filter.lfs.required", "true"]
5318
- ];
5319
- for (const [key, value] of filters) await run("git", ["config", key, value], base);
5320
- }
5321
- await run("git", ["add", "-A"], env);
5322
- const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
5323
- if (!tree || tree === EMPTY_TREE) {
5324
- log.error("Nothing to publish \u2014 the project has no files.");
5325
- return false;
5326
- }
5327
- const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
5328
- await run("git", ["update-ref", "refs/heads/main", commit], base);
5329
- if (lfs && (await run("git", ["lfs", "push", "--all", pushUrl, "main"], base)).code !== 0) {
5330
- log.error("Couldn't upload your game's binary assets to the source store.");
5331
- return false;
5332
- }
5333
- const push = await run(
5334
- "git",
5335
- ["-c", "http.postBuffer=536870912", "push", "-q", pushUrl, "+refs/heads/main:main"],
5336
- base
5337
- );
5338
- if (push.code === 0) return true;
5339
- if (!managed) {
5340
- log.error(
5341
- "Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
5342
- );
5343
- return false;
5080
+ function openUrl(url) {
5081
+ let command;
5082
+ let args;
5083
+ const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
5084
+ const customParts = custom ? tokenize(custom) : [];
5085
+ if (customParts.length > 0) {
5086
+ command = customParts[0];
5087
+ args = [...customParts.slice(1), url];
5088
+ } else {
5089
+ switch (process.platform) {
5090
+ case "darwin":
5091
+ command = "open";
5092
+ args = [url];
5093
+ break;
5094
+ case "win32":
5095
+ command = "cmd";
5096
+ args = ["/c", "start", "", url];
5097
+ break;
5098
+ default:
5099
+ command = "xdg-open";
5100
+ args = [url];
5101
+ break;
5344
5102
  }
5345
- return failed();
5346
- } catch {
5347
- return failed();
5348
- } finally {
5349
- await fs14.rm(gitDir, { recursive: true, force: true }).catch(() => {
5350
- });
5351
5103
  }
5352
- }
5353
- async function fetchPushUrl(ctx, log) {
5354
- let res;
5355
5104
  try {
5356
- res = await apiFetch(`${ctx.apiUrl}/api/projects/${ctx.projectId}/push-token`, {
5357
- method: "POST",
5358
- headers: { Authorization: `Bearer ${ctx.token}` }
5105
+ const child = spawn4(command, args, { stdio: "ignore", detached: true });
5106
+ child.on("error", () => {
5359
5107
  });
5360
- } catch (err) {
5361
- log.error(`Couldn't reach the API to authorize the source push: ${String(err)}`);
5362
- return null;
5363
- }
5364
- if (res.status === 401) {
5365
- log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
5366
- return null;
5367
- }
5368
- if (res.status === 429) {
5369
- log.error(`Rate limited authorizing the source push (HTTP 429).${retryAfterHint(res)}`);
5370
- return null;
5371
- }
5372
- if (!res.ok) {
5373
- log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
5374
- return null;
5108
+ child.unref();
5109
+ } catch {
5375
5110
  }
5376
- const data = await res.json().catch(() => null);
5377
- if (!data?.pushUrl) {
5378
- log.error("The API didn't return a push URL.");
5379
- return null;
5111
+ }
5112
+
5113
+ // src/lib/sse.ts
5114
+ async function* readSSE(body) {
5115
+ const decoder = new TextDecoder();
5116
+ const reader = body.getReader();
5117
+ let buf = "";
5118
+ let event = "";
5119
+ let data = [];
5120
+ try {
5121
+ while (true) {
5122
+ const { done, value } = await reader.read();
5123
+ if (done) break;
5124
+ buf += decoder.decode(value, { stream: true });
5125
+ while (true) {
5126
+ const nl = buf.search(/[\r\n]/);
5127
+ if (nl === -1) break;
5128
+ if (buf[nl] === "\r" && nl === buf.length - 1) break;
5129
+ const sep = buf[nl] === "\r" && buf[nl + 1] === "\n" ? 2 : 1;
5130
+ const line = buf.slice(0, nl);
5131
+ buf = buf.slice(nl + sep);
5132
+ if (line === "") {
5133
+ if (data.length > 0) yield { event: event || "message", data: data.join("\n") };
5134
+ event = "";
5135
+ data = [];
5136
+ continue;
5137
+ }
5138
+ if (line.startsWith(":")) continue;
5139
+ const colon = line.indexOf(":");
5140
+ const field = colon === -1 ? line : line.slice(0, colon);
5141
+ let value2 = colon === -1 ? "" : line.slice(colon + 1);
5142
+ if (value2.startsWith(" ")) value2 = value2.slice(1);
5143
+ if (field === "event") event = value2;
5144
+ else if (field === "data") data.push(value2);
5145
+ }
5146
+ }
5147
+ } finally {
5148
+ reader.releaseLock();
5380
5149
  }
5381
- return { pushUrl: data.pushUrl, managed: data.managed !== false };
5382
5150
  }
5383
- async function isDir2(p) {
5151
+
5152
+ // src/commands/generate.ts
5153
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
5154
+ var INLINE_IMAGE_LIMIT_BYTES = 4 * 1024 * 1024;
5155
+ var IMAGE_MIME_BY_EXT = {
5156
+ ".png": "image/png",
5157
+ ".jpg": "image/jpeg",
5158
+ ".jpeg": "image/jpeg",
5159
+ ".webp": "image/webp"
5160
+ };
5161
+ var isRemoteRef = (value) => /^(https?:\/\/|data:)/i.test(value);
5162
+ async function inlineLocalImage(filePath, flag) {
5163
+ let bytes;
5384
5164
  try {
5385
- return (await fs14.stat(p)).isDirectory();
5165
+ bytes = await fs15.readFile(filePath);
5386
5166
  } catch {
5387
- return false;
5167
+ return { ok: false, error: `Couldn't read the ${flag} file at ${filePath}.` };
5168
+ }
5169
+ if (bytes.length > INLINE_IMAGE_LIMIT_BYTES) {
5170
+ return {
5171
+ ok: false,
5172
+ error: `${flag} file is ${(bytes.length / 1048576).toFixed(1)} MB \u2014 over the ~4 MB inline limit. Downscale/compress it first, or pass an asset URL instead.`
5173
+ };
5388
5174
  }
5175
+ const mime = IMAGE_MIME_BY_EXT[path15.extname(filePath).toLowerCase()] ?? "image/png";
5176
+ return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
5389
5177
  }
5390
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
5391
- function fingerprintOf(html) {
5392
- const m = html.match(/[A-Za-z0-9_]+-[A-Za-z0-9_-]{8}\.(?:js|css)/);
5393
- return m ? m[0] : null;
5178
+ var SKYBOX_ENVIRONMENT_SUFFIX = ". The image contains ONLY sky: cloud, atmosphere, light, weather and distant haze at the horizon. Every structure, object, plant and ground surface is outside the frame.";
5179
+ var SKYBOX_PROMPT_MAX = 2e3;
5180
+ var SKYBOX_STRUCTURE_NOUNS = [
5181
+ "building",
5182
+ "buildings",
5183
+ "house",
5184
+ "houses",
5185
+ "hut",
5186
+ "huts",
5187
+ "cabin",
5188
+ "cabins",
5189
+ "tower",
5190
+ "towers",
5191
+ "castle",
5192
+ "castles",
5193
+ "cathedral",
5194
+ "cathedrals",
5195
+ "church",
5196
+ "churches",
5197
+ "temple",
5198
+ "temples",
5199
+ "shrine",
5200
+ "shrines",
5201
+ "monastery",
5202
+ "palace",
5203
+ "fortress",
5204
+ "citadel",
5205
+ "ruin",
5206
+ "ruins",
5207
+ "city",
5208
+ "cityscape",
5209
+ "skyline",
5210
+ "town",
5211
+ "village",
5212
+ "settlement",
5213
+ "street",
5214
+ "streets",
5215
+ "road",
5216
+ "roads",
5217
+ "courtyard",
5218
+ "plaza",
5219
+ "bridge",
5220
+ "bridges",
5221
+ "wall",
5222
+ "walls",
5223
+ "gate",
5224
+ "gates",
5225
+ "fence",
5226
+ "fences",
5227
+ "pillar",
5228
+ "pillars",
5229
+ "column",
5230
+ "columns",
5231
+ "statue",
5232
+ "statues",
5233
+ "monument",
5234
+ "obelisk",
5235
+ "spire",
5236
+ "spires",
5237
+ "rooftop",
5238
+ "rooftops",
5239
+ "tree",
5240
+ "trees",
5241
+ "forest",
5242
+ "jungle",
5243
+ "ship",
5244
+ "ships",
5245
+ "boat",
5246
+ "boats",
5247
+ "car",
5248
+ "cars",
5249
+ "vehicle",
5250
+ "vehicles",
5251
+ "person",
5252
+ "people",
5253
+ "figure",
5254
+ "figures",
5255
+ "character",
5256
+ "characters",
5257
+ "creature",
5258
+ "creatures",
5259
+ "dragon",
5260
+ "banner",
5261
+ "banners",
5262
+ "flag",
5263
+ "flags",
5264
+ "torch",
5265
+ "torches",
5266
+ "lamp",
5267
+ "lamps"
5268
+ ];
5269
+ var SKYBOX_NOUN_RE = new RegExp(`\\b(${SKYBOX_STRUCTURE_NOUNS.join("|")})\\b`, "gi");
5270
+ function skyboxStructureNouns(prompt) {
5271
+ const found = /* @__PURE__ */ new Set();
5272
+ for (const m of prompt.matchAll(SKYBOX_NOUN_RE)) found.add(m[1].toLowerCase());
5273
+ return [...found];
5394
5274
  }
5395
- async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
5396
- if (!fingerprint) {
5397
- log.success(`Live. \u{1F310} ${playUrl}`);
5398
- return;
5275
+ function applySkyboxGuard(prompt, raw) {
5276
+ if (raw) return { prompt, guarded: false, nouns: [] };
5277
+ const nouns = skyboxStructureNouns(prompt);
5278
+ const guarded = `${prompt}${SKYBOX_ENVIRONMENT_SUFFIX}`;
5279
+ if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false, nouns };
5280
+ return { prompt: guarded, guarded: true, nouns };
5281
+ }
5282
+ function buildGenOptions(kind, opts) {
5283
+ const options = { ...opts.generationOptions };
5284
+ if (kind === "texture" && opts.terrain) options.terrain = true;
5285
+ if ((kind === "sfx" || kind === "music") && opts.duration) options.durationSeconds = opts.duration;
5286
+ if (kind === "voice") {
5287
+ if (opts.voice) options.voice = opts.voice;
5288
+ if (opts.voiceId) options.voiceId = opts.voiceId;
5399
5289
  }
5400
- const deadline = Date.now() + timeoutMs;
5401
- let n = 0;
5402
- while (Date.now() < deadline) {
5403
- try {
5404
- const res = await fetch(`${playUrl}?_genexlive=${n++}`, { cache: "no-store" });
5405
- if (res.ok && (await res.text()).includes(fingerprint)) {
5406
- log.success(`Live. \u{1F310} ${playUrl}`);
5407
- return;
5408
- }
5409
- } catch {
5290
+ if (kind === "image") {
5291
+ if (opts.transparent) options.transparent = true;
5292
+ if (opts.aspect) options.aspect = opts.aspect;
5293
+ if (opts.quality) options.quality = opts.quality;
5294
+ if (opts.candidates !== void 0 && opts.candidates > 1) options.candidates = opts.candidates;
5295
+ if (opts.width && opts.height) {
5296
+ options.width = opts.width;
5297
+ options.height = opts.height;
5298
+ }
5299
+ if (opts.editUrl) {
5300
+ options.sourceUrl = opts.editUrl;
5301
+ options.op = "edit";
5302
+ }
5303
+ if (opts.cleanUrl) {
5304
+ options.sourceUrl = opts.cleanUrl;
5305
+ options.op = "remove-bg";
5306
+ options.bgMode = opts.bgMode ?? "sheet";
5307
+ }
5308
+ if (opts.upscaleUrl) {
5309
+ options.sourceUrl = opts.upscaleUrl;
5310
+ options.op = "upscale";
5410
5311
  }
5411
- await sleep2(1500);
5312
+ if (opts.inpaintUrl) options.maskUrl = opts.inpaintUrl;
5313
+ if (opts.removeBackground) options.removeBackground = true;
5314
+ if (opts.bgMode) options.bgMode = opts.bgMode;
5315
+ }
5316
+ if (kind === "video") {
5317
+ if (opts.duration) options.durationSeconds = opts.duration;
5318
+ if (opts.resolution) options.resolution = opts.resolution;
5319
+ if (opts.loop) options.loop = true;
5320
+ if (opts.frameUrl) options.frameUrl = opts.frameUrl;
5321
+ if (opts.firstFrameUrl) options.firstFrameUrl = opts.firstFrameUrl;
5322
+ if (opts.lastFrameUrl) options.lastFrameUrl = opts.lastFrameUrl;
5412
5323
  }
5413
- log.success(`Published. \u{1F310} ${playUrl}`);
5414
- log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
5324
+ return options;
5415
5325
  }
5416
-
5417
- // src/commands/make-remixable.ts
5418
- async function runMakeRemixable(opts) {
5419
- const log = createLogger({ quiet: opts.quiet });
5420
- log.plain(c.bold("genex make-remixable"));
5421
- log.plain("");
5422
- const meta = await readProject();
5423
- if (!meta) {
5424
- log.error("No genex project here. Run `genex init` in this directory first.");
5326
+ async function runGenerate(kind, opts) {
5327
+ const log = createLogger({ quiet: opts.quiet || opts.json });
5328
+ const typedPrompt = opts.prompt?.trim();
5329
+ if (!typedPrompt) {
5330
+ log.error(`Missing prompt. Usage: ${c.cyan(`genex ${kind} "<prompt>"`)}`);
5425
5331
  process.exitCode = 1;
5426
5332
  return;
5427
5333
  }
5428
- const token = await readUserToken(opts.envPath);
5429
- if (!token) {
5430
- log.error("Not authorized. Run `genex init` first to sign in.");
5334
+ const skyboxGuard = kind === "skybox" ? applySkyboxGuard(typedPrompt, opts.raw) : void 0;
5335
+ if (skyboxGuard && skyboxGuard.nouns.length > 0) {
5336
+ log.error(
5337
+ `A skybox is sky only \u2014 this prompt names ${skyboxGuard.nouns.join(", ")}.`
5338
+ );
5339
+ log.plain(
5340
+ [
5341
+ " Structures painted into a sky render at infinite distance: they never get closer",
5342
+ " as the player moves, and they read as broken the moment the camera strafes.",
5343
+ ` ${c.cyan("\u2192")} Build them as scene geometry (${c.cyan("npx genex model")}); describe only light, colour,`,
5344
+ " cloud, weather and time of day here.",
5345
+ ` ${c.cyan("\u2192")} Genuinely want it painted into the sky (a space-station panorama for a scene`,
5346
+ ` with no world geometry)? Re-run with ${c.cyan("--raw")}.`
5347
+ ].join("\n")
5348
+ );
5431
5349
  process.exitCode = 1;
5432
5350
  return;
5433
5351
  }
5434
- const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
5435
- log.step("Preparing a public repo for your game\u2026");
5436
- let res;
5437
- try {
5438
- res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/make-remixable`, {
5439
- method: "POST",
5440
- headers: { Authorization: `Bearer ${token}` }
5441
- });
5442
- } catch (err) {
5443
- log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
5444
- process.exitCode = 1;
5445
- return;
5352
+ let prompt = skyboxGuard?.prompt ?? typedPrompt;
5353
+ if (kind === "image" && opts.glass) {
5354
+ const conflict = opts.transparent ? "--transparent" : opts.removeBackground ? "--remove-bg" : opts.editUrl ? "--edit" : opts.cleanUrl ? "--clean" : opts.upscaleUrl ? "--upscale" : opts.noWait ? "--no-wait" : void 0;
5355
+ if (conflict) {
5356
+ log.error(
5357
+ conflict === "--no-wait" ? "--glass runs a local key-solve on the finished image \u2014 it has to wait. Drop --no-wait." : `--glass generates a NEW panel on a magenta key screen \u2014 it cannot combine with ${conflict}. The solved RGBA ships real translucency; no removal pass applies.`
5358
+ );
5359
+ process.exitCode = 1;
5360
+ return;
5361
+ }
5362
+ prompt = `${prompt}${GLASS_PROMPT_SUFFIX}`;
5446
5363
  }
5447
- if (res.status === 401) {
5448
- log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
5449
- process.exitCode = 1;
5450
- return;
5364
+ if (kind === "image" && opts.inpaintUrl && !/^https?:\/\//i.test(opts.inpaintUrl)) {
5365
+ if (!opts.editUrl) {
5366
+ log.error("--inpaint targets an edit \u2014 pass --edit <asset-url> alongside it.");
5367
+ process.exitCode = 1;
5368
+ return;
5369
+ }
5370
+ try {
5371
+ const bytes = await fs15.readFile(opts.inpaintUrl);
5372
+ opts = { ...opts, inpaintUrl: `data:image/png;base64,${bytes.toString("base64")}` };
5373
+ } catch {
5374
+ log.error(`Couldn't read the --inpaint mask at ${opts.inpaintUrl}.`);
5375
+ process.exitCode = 1;
5376
+ return;
5377
+ }
5451
5378
  }
5452
- if (res.status === 404) {
5453
- log.error("That game wasn't found on your account. Re-run `genex init` or `genex link <slug>`.");
5454
- process.exitCode = 1;
5455
- return;
5379
+ if (kind === "image" && opts.editUrl && !isRemoteRef(opts.editUrl)) {
5380
+ const inlined = await inlineLocalImage(opts.editUrl, "--edit");
5381
+ if (!inlined.ok) {
5382
+ log.error(inlined.error);
5383
+ process.exitCode = 1;
5384
+ return;
5385
+ }
5386
+ opts = { ...opts, editUrl: inlined.dataUri };
5456
5387
  }
5457
- if (!res.ok) {
5458
- log.error(`Couldn't make the game public (HTTP ${res.status}).`);
5459
- process.exitCode = 1;
5460
- return;
5388
+ if (kind === "video" && opts.frameUrl && !isRemoteRef(opts.frameUrl)) {
5389
+ const inlined = await inlineLocalImage(opts.frameUrl, "--frame");
5390
+ if (!inlined.ok) {
5391
+ log.error(inlined.error);
5392
+ process.exitCode = 1;
5393
+ return;
5394
+ }
5395
+ opts = { ...opts, frameUrl: inlined.dataUri };
5461
5396
  }
5462
- const data = await res.json().catch(() => null);
5463
- if (!data?.pushUrl) {
5464
- log.error("The API didn't return a push URL.");
5465
- process.exitCode = 1;
5466
- return;
5397
+ if (kind === "voice" && prompt.length > 1e3) {
5398
+ log.warn(
5399
+ `Voice text is ${prompt.length} chars \u2014 the server clamps at 1000 (billing follows the clamp). Split longer copy into separate lines.`
5400
+ );
5467
5401
  }
5468
- log.step("Copying your game's source to the public repo\u2026");
5469
- if (!await pushWorktree(process.cwd(), data.pushUrl, true, log)) {
5402
+ const token = opts.token ?? await readUserToken(opts.envPath);
5403
+ if (!token) {
5404
+ log.error("Not authorized. Run `genex init` first to sign in.");
5470
5405
  process.exitCode = 1;
5471
5406
  return;
5472
5407
  }
5473
- const newCloneUrl = data.project?.cloneUrl;
5474
- if (newCloneUrl && newCloneUrl !== meta.cloneUrl) {
5475
- await writeProject({ ...meta, cloneUrl: newCloneUrl });
5476
- }
5408
+ const meta = await readProject();
5409
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
5410
+ const options = buildGenOptions(kind, opts);
5411
+ log.plain(c.bold(`genex ${kind}`));
5412
+ log.dim(` ${prompt}`);
5413
+ if (skyboxGuard?.guarded) log.dim(" \u21B3 environment-only guard applied (--raw to disable)");
5477
5414
  log.plain("");
5478
- log.success("Your game is public \u2014 anyone can remix it now. \u{1F310}");
5479
- const dashboard = meta.dashboardOrigins?.[0];
5480
- if (dashboard) {
5481
- const page = meta.status === "published" ? "world" : "draft";
5482
- log.plain(` your game's page: ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
5483
- }
5484
- }
5485
-
5486
- // src/lib/detect-features.ts
5487
- import fs15 from "fs/promises";
5488
- import path15 from "path";
5489
- async function detectEmbedSdkVersion(cwd = process.cwd()) {
5490
- try {
5491
- const raw = await fs15.readFile(path15.join(cwd, "package.json"), "utf8");
5492
- const pkg = JSON.parse(raw);
5493
- const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
5494
- return typeof version === "string" && version ? version : null;
5495
- } catch {
5496
- return null;
5497
- }
5498
- }
5499
- async function detectMultiplayer(cwd = process.cwd()) {
5500
- try {
5501
- const raw = await fs15.readFile(path15.join(cwd, "package.json"), "utf8");
5502
- const pkg = JSON.parse(raw);
5503
- return Boolean(
5504
- pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
5505
- );
5506
- } catch {
5507
- return false;
5508
- }
5509
- }
5510
- async function detectMatchmaking(log, cwd = process.cwd()) {
5511
- let pkg;
5512
- try {
5513
- pkg = JSON.parse(await fs15.readFile(path15.join(cwd, "package.json"), "utf8"));
5514
- } catch (err) {
5515
- log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
5516
- return null;
5517
- }
5518
- const mm = pkg.genex?.matchmaking;
5519
- if (mm && typeof mm.preset === "string" && mm.preset) {
5520
- return {
5521
- preset: mm.preset,
5522
- ...mm.winCondition ? { winCondition: mm.winCondition } : {},
5523
- ...mm.config ? { config: mm.config } : {}
5524
- };
5525
- }
5526
- return null;
5527
- }
5528
- var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
5529
- async function detectMobileControls(cwd = process.cwd()) {
5530
- try {
5531
- const raw = await fs15.readFile(path15.join(cwd, "package.json"), "utf8");
5532
- const pkg = JSON.parse(raw);
5533
- if (pkg.genex?.mobileControls === true) return true;
5534
- } catch {
5535
- }
5536
- const srcDir = path15.join(cwd, "src");
5537
- let entries;
5415
+ let id;
5538
5416
  try {
5539
- entries = await fs15.readdir(srcDir, { recursive: true });
5540
- } catch {
5541
- return false;
5542
- }
5543
- for (const rel of entries) {
5544
- if (rel.includes("node_modules")) continue;
5545
- if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
5546
- try {
5547
- const content = await fs15.readFile(path15.join(srcDir, rel), "utf8");
5548
- if (TOUCH_KIT_MARKERS.test(content)) return true;
5549
- } catch {
5417
+ const res = await apiFetch(`${apiUrl}/api/generations`, {
5418
+ method: "POST",
5419
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
5420
+ body: JSON.stringify({ kind, prompt, options })
5421
+ });
5422
+ if (res.status === 401) {
5423
+ log.error("Unauthorized \u2014 your token may have expired. Re-run `genex init`.");
5424
+ process.exitCode = 1;
5425
+ return;
5550
5426
  }
5551
- }
5552
- return false;
5553
- }
5554
- var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
5555
- async function detectGameStateUsage(cwd = process.cwd()) {
5556
- const srcDir = path15.join(cwd, "src");
5557
- let entries;
5558
- try {
5559
- entries = await fs15.readdir(srcDir, { recursive: true });
5560
- } catch {
5561
- return false;
5562
- }
5563
- for (const rel of entries) {
5564
- if (rel.includes("node_modules")) continue;
5565
- if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
5566
- try {
5567
- const content = await fs15.readFile(path15.join(srcDir, rel), "utf8");
5568
- if (GAME_STATE_CALLS.test(content)) return true;
5569
- } catch {
5427
+ if (printedStructuredError(res)) {
5428
+ process.exitCode = 1;
5429
+ return;
5570
5430
  }
5571
- }
5572
- return false;
5573
- }
5574
- var UI_PHASE_MARKERS = /data-phase|setPhase/;
5575
- async function detectUiPhases(cwd = process.cwd()) {
5576
- try {
5577
- const html = await fs15.readFile(path15.join(cwd, "index.html"), "utf8").catch(() => "");
5578
- if (UI_PHASE_MARKERS.test(html)) return true;
5579
- const srcDir = path15.join(cwd, "src");
5580
- const entries = await fs15.readdir(srcDir, { recursive: true });
5581
- for (const rel of entries) {
5582
- if (rel.includes("node_modules")) continue;
5583
- if (!/\.(ts|js|mts|mjs|tsx|jsx|html|css)$/.test(rel)) continue;
5584
- const content = await fs15.readFile(path15.join(srcDir, rel), "utf8").catch(() => "");
5585
- if (UI_PHASE_MARKERS.test(content)) return true;
5431
+ if (!res.ok) {
5432
+ let message;
5433
+ if (res.status >= 400 && res.status < 500) {
5434
+ try {
5435
+ const body = await res.json();
5436
+ if (typeof body.message === "string" && body.message.trim()) message = body.message;
5437
+ } catch {
5438
+ }
5439
+ }
5440
+ log.error(message ?? `Generation request failed (HTTP ${res.status}).`);
5441
+ process.exitCode = 1;
5442
+ return;
5586
5443
  }
5587
- return false;
5588
- } catch {
5589
- return true;
5590
- }
5591
- }
5592
- function ctorArgs(content, ctor) {
5593
- const out = [];
5594
- const re = new RegExp(`new\\s+(?:THREE\\.)?${ctor}\\s*\\(`, "g");
5595
- let m;
5596
- while (m = re.exec(content)) {
5597
- let i = re.lastIndex;
5598
- let depth = 1;
5599
- while (i < content.length && depth > 0) {
5600
- const ch = content[i];
5601
- if (ch === "(" || ch === "{" || ch === "[") depth++;
5602
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
5603
- i++;
5444
+ ({ id } = await res.json());
5445
+ } catch (err) {
5446
+ log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
5447
+ process.exitCode = 1;
5448
+ return;
5449
+ }
5450
+ await recordQueued(id, kind, prompt);
5451
+ if (opts.noWait) {
5452
+ if (opts.json) {
5453
+ writeJson({
5454
+ kind,
5455
+ id,
5456
+ status: "pending",
5457
+ nextCommand: `genex wait ${id} --json`
5458
+ });
5459
+ } else {
5460
+ log.success(`Queued (${id}).`);
5461
+ log.plain(` Pick it up any time with: ${c.cyan(`npx genex wait ${id}`)}`);
5462
+ log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
5604
5463
  }
5605
- out.push({ text: content.slice(re.lastIndex, i - 1), index: m.index });
5464
+ return id;
5606
5465
  }
5607
- return out;
5608
- }
5609
- function splitArgs(text) {
5610
- const out = [];
5611
- let depth = 0;
5612
- let start = 0;
5613
- for (let i = 0; i < text.length; i++) {
5614
- const ch = text[i];
5615
- if (ch === "(" || ch === "{" || ch === "[") depth++;
5616
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
5617
- else if (ch === "," && depth === 0) {
5618
- out.push(text.slice(start, i).trim());
5619
- start = i + 1;
5466
+ if (kind === "image" && opts.glass) {
5467
+ if (!opts.json) log.step("Generating\u2026 (this can take up to a minute)");
5468
+ const view = await waitForTerminalView(
5469
+ apiUrl,
5470
+ token,
5471
+ id,
5472
+ kind,
5473
+ opts.json ? () => {
5474
+ } : (p) => log.dim(` ${p}%`)
5475
+ );
5476
+ if (!view) {
5477
+ if (opts.json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
5478
+ else log.error("Timed out waiting for the generation.");
5479
+ process.exitCode = 1;
5480
+ return id;
5620
5481
  }
5482
+ await reportGlassTerminal(view, opts.outDir ?? ".", log, opts.json ?? false);
5483
+ return id;
5621
5484
  }
5622
- out.push(text.slice(start).trim());
5623
- return out;
5485
+ await awaitAndReport(apiUrl, token, id, kind, log, opts.open, opts.json);
5486
+ return id;
5624
5487
  }
5625
- var lineOf = (content, index) => content.slice(0, index).split("\n").length;
5626
- var DEPTH_RATIO_LIMIT = 1e6;
5627
- async function detectSurfaceScan(cwd = process.cwd()) {
5628
- const found = { guessedRepeat: [], squarePoints: [], depthRange: [] };
5629
- const srcDir = path15.join(cwd, "src");
5630
- let entries;
5631
- try {
5632
- entries = await fs15.readdir(srcDir, { recursive: true });
5633
- } catch {
5634
- return found;
5488
+ async function reportGlassTerminal(view, outDir, log, json) {
5489
+ const files = (view.files ?? []).filter((f) => /^image-(main|alt-\d+)$/.test(f.role));
5490
+ if (view.status !== "completed" || files.length === 0) {
5491
+ await reportTerminal("image", view, log, false, json);
5492
+ return;
5635
5493
  }
5636
- for (const rel of entries) {
5637
- if (rel.includes("node_modules")) continue;
5638
- if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
5639
- const raw = await fs15.readFile(path15.join(srcDir, rel), "utf8").catch(() => "");
5640
- if (!raw) continue;
5641
- const content = raw.replace(/\/\*[\s\S]*?\*\//g, (m2) => m2.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m2, p1) => p1 + " ".repeat(m2.length - p1.length));
5642
- const repeatRe = /\.repeat\.set\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
5643
- let m;
5644
- while (m = repeatRe.exec(content)) {
5645
- const u = Number(m[1]);
5646
- const v = Number(m[2]);
5647
- if (u === v) continue;
5648
- found.guessedRepeat.push({
5649
- where: `${rel}:${lineOf(content, m.index)}`,
5650
- detail: `repeat.set(${m[1]}, ${m[2]})`
5651
- });
5652
- }
5653
- for (const call of ctorArgs(content, "PointsMaterial")) {
5654
- if (/\b(map|alphaMap)\s*:/.test(call.text)) continue;
5655
- found.squarePoints.push({
5656
- where: `${rel}:${lineOf(content, call.index)}`,
5657
- detail: "PointsMaterial with no map/alphaMap"
5494
+ await recordTerminal(view.id, "completed", files.map((f) => f.url));
5495
+ await fs15.mkdir(outDir, { recursive: true });
5496
+ const solved = [];
5497
+ for (let i = 0; i < files.length; i++) {
5498
+ const f = files[i];
5499
+ let png;
5500
+ try {
5501
+ const res = await fetch(f.url);
5502
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
5503
+ png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
5504
+ } catch (err) {
5505
+ solved.push({
5506
+ path: null,
5507
+ url: f.url,
5508
+ keyHex: "",
5509
+ keyStrength: 0,
5510
+ flatness: 0,
5511
+ rejected: `couldn't fetch the candidate (${String(err)}) \u2014 re-run later; the URL stays live.`
5658
5512
  });
5513
+ continue;
5659
5514
  }
5660
- for (const call of ctorArgs(content, "PerspectiveCamera")) {
5661
- const args = splitArgs(call.text);
5662
- if (args.length < 4) continue;
5663
- const near = Number(args[2]);
5664
- const far = Number(args[3]);
5665
- if (!isFinite(near) || !isFinite(far) || near <= 0 || far <= near) continue;
5666
- const ratio = far / near;
5667
- if (ratio <= DEPTH_RATIO_LIMIT) continue;
5668
- found.depthRange.push({
5669
- where: `${rel}:${lineOf(content, call.index)}`,
5670
- detail: `near ${near} / far ${far}`,
5671
- ratio
5515
+ const r = solveMagentaGlass(png);
5516
+ if (!r.ok) {
5517
+ solved.push({
5518
+ path: null,
5519
+ url: f.url,
5520
+ keyHex: r.keyHex,
5521
+ keyStrength: r.keyStrength,
5522
+ flatness: r.flatness,
5523
+ rejected: r.rejected
5672
5524
  });
5525
+ continue;
5673
5526
  }
5527
+ const outPath = path15.join(outDir, `glass-${i + 1}.png`);
5528
+ await fs15.writeFile(outPath, PNG4.sync.write(r.png));
5529
+ solved.push({
5530
+ path: outPath,
5531
+ url: f.url,
5532
+ keyHex: r.keyHex,
5533
+ keyStrength: r.keyStrength,
5534
+ flatness: r.flatness
5535
+ });
5674
5536
  }
5675
- return found;
5676
- }
5677
- var WIRED_BY_URL = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "music", "texture", "video"]);
5678
- var UNPICKED_AFTER_MS = 15 * 60 * 1e3;
5679
- async function detectGenerationAudit(cwd = process.cwd()) {
5680
- const ledger = await readLedger(cwd);
5681
- const audited = ledger.filter((e) => WIRED_BY_URL.has(e.kind));
5682
- if (audited.length === 0) return { unwired: [], unpicked: [], unresolved: [], deadWired: [] };
5683
- let haystack = "";
5684
- const read = async (file) => {
5685
- try {
5686
- haystack += await fs15.readFile(file, "utf8");
5687
- } catch {
5688
- }
5689
- };
5690
- try {
5691
- for (const entry of await fs15.readdir(cwd, { withFileTypes: true })) {
5692
- if (entry.isFile() && /\.(ts|tsx|js|jsx|css|html|json)$/.test(entry.name)) {
5693
- await read(path15.join(cwd, entry.name));
5694
- }
5695
- }
5696
- } catch {
5537
+ const shipped = solved.filter((s) => s.path !== null);
5538
+ if (json) {
5539
+ writeJson({ kind: "image", id: view.id, status: view.status, glass: solved });
5540
+ if (shipped.length === 0) process.exitCode = 1;
5541
+ return;
5697
5542
  }
5698
- for (const sub of ["src", "public"]) {
5699
- try {
5700
- const entries = await fs15.readdir(path15.join(cwd, sub), { recursive: true });
5701
- for (const rel of entries) {
5702
- if (rel.includes("node_modules")) continue;
5703
- if (!/\.(ts|tsx|js|jsx|css|html|json|txt)$/.test(rel)) continue;
5704
- await read(path15.join(cwd, sub, rel));
5705
- }
5706
- } catch {
5543
+ log.plain("");
5544
+ if (shipped.length > 0) {
5545
+ log.success(`Solved ${shipped.length} glass panel(s) \u2014 real translucency baked in the alpha:`);
5546
+ }
5547
+ for (const s of solved) {
5548
+ if (s.path) {
5549
+ log.plain(` ${s.path} ${c.dim(`key ${s.keyHex}, strength ${s.keyStrength}, flatness ${s.flatness.toFixed(3)}`)}`);
5550
+ } else {
5551
+ log.error(`Candidate rejected: ${s.rejected}`);
5552
+ log.dim(` raw candidate stays at ${s.url}`);
5707
5553
  }
5708
5554
  }
5709
- const ref = ({ id, kind, prompt }) => ({
5555
+ if (shipped.length > 0) {
5556
+ log.plain("");
5557
+ log.dim(
5558
+ " Wire the PNG like any sprite (TextureLoader / <img>): its alpha IS the glass \u2014 no CSS opacity, no plate underneath. See genex-ai-image (--glass) and genex-ai-menu."
5559
+ );
5560
+ } else {
5561
+ process.exitCode = 1;
5562
+ }
5563
+ }
5564
+ async function awaitAndReport(apiUrl, token, id, kind, log, open = false, json = false) {
5565
+ if (!json) log.step("Generating\u2026 (this can take up to a minute)");
5566
+ const view = await waitForTerminalView(
5567
+ apiUrl,
5568
+ token,
5710
5569
  id,
5711
5570
  kind,
5712
- prompt
5713
- });
5714
- const now = Date.now();
5715
- const stale = (e) => e.status === "queued" && now - Date.parse(e.queuedAt) > UNPICKED_AFTER_MS;
5716
- return {
5717
- unwired: audited.filter((e) => e.status === "completed" && !haystack.includes(e.id)).map(ref),
5718
- unpicked: audited.filter((e) => stale(e) && !haystack.includes(e.id)).map(ref),
5719
- // Wired in but never picked up: the deterministic URL let the agent wire
5720
- // the asset before it finished, so nobody ever learned whether it DID
5721
- // finish a provider-side failure (credits, moderation) leaves a dead URL
5722
- // that renders as a silently missing asset. Kept separate from `unpicked`
5723
- // because the fix is a status check, not wiring.
5724
- unresolved: audited.filter((e) => stale(e) && haystack.includes(e.id)).map(ref),
5725
- // Known-failed AND still wired: the worst case, and it must keep firing
5726
- // AFTER the status check converges the ledger to "failed" (otherwise the
5727
- // audit self-erases the moment the agent runs the recommended command and
5728
- // the dead URL ships silently anyway).
5729
- deadWired: audited.filter((e) => e.status === "failed" && haystack.includes(e.id)).map(ref)
5730
- };
5571
+ json ? () => {
5572
+ } : (p) => log.dim(` ${p}%`)
5573
+ );
5574
+ if (!view) {
5575
+ if (json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
5576
+ else log.error("Timed out waiting for the generation.");
5577
+ process.exitCode = 1;
5578
+ return;
5579
+ }
5580
+ await reportTerminal(kind, view, log, open, json);
5731
5581
  }
5732
- async function detectFeatures(log, cwd = process.cwd()) {
5733
- return {
5734
- embedSdkVersion: await detectEmbedSdkVersion(cwd),
5735
- multiplayer: await detectMultiplayer(cwd),
5736
- matchmaking: await detectMatchmaking(log, cwd),
5737
- gameStateUsed: await detectGameStateUsage(cwd),
5738
- mobileControls: await detectMobileControls(cwd),
5739
- uiPhases: await detectUiPhases(cwd),
5740
- surfaces: await detectSurfaceScan(cwd),
5741
- generations: await detectGenerationAudit(cwd)
5742
- };
5582
+ async function waitForTerminalView(apiUrl, token, id, kind, onProgress = () => {
5583
+ }) {
5584
+ const timeoutMs = waitTimeoutFor(kind);
5585
+ const deadline = Date.now() + timeoutMs;
5586
+ const streamed = await waitViaSSE(apiUrl, token, id, onProgress, deadline);
5587
+ return streamed === "unsupported" ? poll(apiUrl, token, id, onProgress, timeoutMs) : streamed;
5743
5588
  }
5744
- function advisoryNudges(log, d) {
5745
- if (!d.embedSdkVersion) {
5746
- log.warn(
5747
- "This game has no player identity or saves \u2014 players lose everything on refresh. The genex-threejs-embed-auth skill wires it with one call (initEmbed) \u2014 load it before publishing."
5748
- );
5749
- }
5750
- if (d.multiplayer && !d.embedSdkVersion) {
5751
- log.warn(
5752
- "Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
5753
- );
5589
+ async function reportTerminal(kind, view, log, open = false, json = false) {
5590
+ if (view.status !== "completed") {
5591
+ if (json) writeJson({ kind, id: view.id, status: view.status, error: view.error ?? null });
5592
+ else log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
5593
+ if (view.status === "failed") {
5594
+ await recordTerminal(view.id, "failed");
5595
+ if (kind === "video") {
5596
+ const failures = await countFailed("video");
5597
+ (failures >= 2 ? log.plain : log.dim)(videoFailureAdvice(failures));
5598
+ }
5599
+ }
5600
+ process.exitCode = 1;
5601
+ return;
5754
5602
  }
5755
- if (d.matchmaking && !d.multiplayer) {
5756
- log.warn(
5757
- "package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
5758
- );
5603
+ const files = view.files ?? [];
5604
+ if (files.length === 0) {
5605
+ if (json) writeJson({ kind, id: view.id, status: "failed", error: "Generation completed but produced no files." });
5606
+ else log.error("Generation completed but produced no files.");
5607
+ process.exitCode = 1;
5608
+ return;
5759
5609
  }
5760
- if (!d.uiPhases) {
5761
- log.warn(
5762
- "No menu/loader screens detected (no data-phase/setPhase) \u2014 players get a bare HUD with no title screen and no styled loading. Run the genex-threejs-game-ui plan (loader, menu, screen tiers); a cinematic menu is two commands away (genex-ai-menu)."
5763
- );
5610
+ await recordTerminal(view.id, "completed", files.map((f) => f.url));
5611
+ if (kind === "character_concept") {
5612
+ reportCharacterConceptTerminal(view, log, { json, open });
5613
+ return;
5764
5614
  }
5765
- surfaceNudges(log, d.surfaces);
5766
- generationNudges(log, d.generations);
5767
- }
5768
- function generationNudges(log, g) {
5769
- const describe = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
5770
- if (g.unpicked.length) {
5771
- log.warn(
5772
- `${g.unpicked.length} generation(s) enqueued 15+ minutes ago were never picked up: ${describe(g.unpicked)}. Run npx genex wait <id> on each (npx genex wait --all shows every status at once) \u2014 landed assets get wired in before the last push of a session, never parked for "later".`
5773
- );
5615
+ if (kind === "character_preview") {
5616
+ reportCharacterPreviewTerminal(view, log, { json, open });
5617
+ return;
5774
5618
  }
5775
- if (g.unresolved.length) {
5776
- log.warn(
5777
- `${g.unresolved.length} generation(s) are wired into the game but were never picked up, so their real status is unknown: ${describe(g.unresolved)}. A generation that failed server-side (credits, moderation) leaves a dead URL \u2014 the asset just silently never appears. One call checks them all: npx genex wait --all.`
5778
- );
5619
+ if (json) {
5620
+ writeJson({
5621
+ kind,
5622
+ id: view.id,
5623
+ status: view.status,
5624
+ files,
5625
+ ...kind === "character" ? {
5626
+ characterId: view.id,
5627
+ nextCommand: `genex controller character --character ${view.id}`
5628
+ } : {}
5629
+ });
5630
+ return;
5779
5631
  }
5780
- if (g.deadWired.length) {
5781
- log.warn(
5782
- `${g.deadWired.length} FAILED generation(s) are still wired into the game: ${describe(g.deadWired)}. Those URLs will never exist \u2014 each asset silently never appears. Replace the wiring or re-generate (that bills a new one), and tell the user what changed.`
5783
- );
5632
+ log.plain("");
5633
+ log.success("Done \u2014 live on R2 (nothing downloaded or committed):");
5634
+ for (const f of files) {
5635
+ const label = files.length > 1 ? `${c.dim(`${f.role.replace(/^texture-/, "")}: `)}` : "";
5636
+ log.plain(` ${label}${f.url}`);
5784
5637
  }
5785
- if (g.unwired.length) {
5786
- log.warn(
5787
- `${g.unwired.length} finished generation(s) are wired nowhere in the game: ${describe(g.unwired)}. Each was paid for \u2014 wire it in (its URL embeds the id) or tell the user in one plain line why it stays unused.`
5638
+ log.plain("");
5639
+ if (kind === "texture" && files[0]) await reportTextureSeam(files[0].url, log);
5640
+ if (open && files[0]) {
5641
+ openUrl(files[0].url);
5642
+ log.plain(
5643
+ ` ${c.bold("\u{1F441} Show this to the user")} \u2014 it just opened in their browser; paste this link in chat too so they can open it themselves:`
5788
5644
  );
5645
+ log.plain(` ${files[0].url}`);
5646
+ log.plain("");
5789
5647
  }
5648
+ printHint(kind, view, files, log);
5790
5649
  }
5791
- function surfaceNudges(log, s) {
5792
- if (s.guessedRepeat.length) {
5793
- const list = s.guessedRepeat.map((d) => `${d.where} \u2014 ${d.detail}`).join("; ");
5794
- log.warn(
5795
- `Texture tiling picked by eye, not derived from the mesh: ${list}. A hand-chosen repeat is right for at most ONE face \u2014 a BoxGeometry gives all six faces 0..1 UVs whatever their world size, and repeat lives on the Texture, so every mesh sharing that texture inherits it. Derive the UVs from world size instead: genex-ai-texture ships worldUV(geometry, TILE_M), after which repeat stays (1, 1) and cannot be wrong.`
5796
- );
5797
- }
5798
- if (s.squarePoints.length) {
5799
- const list = s.squarePoints.map((d) => d.where).join(", ");
5800
- log.warn(
5801
- `Particles will render as hard SQUARES: ${list} builds a PointsMaterial with no map/alphaMap, so every point is an opaque camera-facing quad. Give it a soft sprite (genex image --transparent) or a round gl_PointCoord cutout \u2014 and check the particle belongs there at all: genex-threejs-procedural-vfx decides that per moment.`
5802
- );
5803
- }
5804
- for (const d of s.depthRange) {
5805
- log.warn(
5806
- `Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get. If the range is genuinely astronomical, that is what WebGLRenderer's logarithmicDepthBuffer is for: genex-threejs-camera-direction.`
5807
- );
5650
+ function videoFailureAdvice(failures) {
5651
+ if (failures >= 2) {
5652
+ return ` ${c.bold(`\u270B ${failures} failed videos in this project \u2014 don't queue another attempt of the same clip.`)} One retry per clip is the budget. Use its key-art still as the backdrop instead (the genex-ai-menu skill's built-in fallback; a slow CSS pan gives it life), tell the user in one plain line, and spend the minutes in the game.`;
5808
5653
  }
5654
+ return " Video renders fail server-side sometimes. One retry is fair \u2014 shorter clip (4\u20136 s), simpler motion. After a second failure, switch to the key-art still (genex-ai-menu's fallback) instead of a third attempt.";
5809
5655
  }
5810
- async function exploreReuseNudge(log, meta, cwd = process.cwd()) {
5811
- if (!meta.exploreRanAt || meta.reuseNudgedAt) return;
5812
- if (await borrowEvidence(meta, cwd)) return;
5813
- log.warn(
5814
- "This project browsed the curated catalog (genex explore) but no borrowed catalog project is visible in the game. If the plan mapped systems to the catalog \u2014 grass, terrain, water, vehicles \u2014 land those borrows or cancel each in one plain line to the user: a planned borrow that silently becomes hand-rolled code is a scope cut."
5815
- );
5816
- meta.reuseNudgedAt = (/* @__PURE__ */ new Date()).toISOString();
5817
- try {
5818
- const onDisk = await readProject(cwd);
5819
- if (onDisk) {
5820
- onDisk.reuseNudgedAt = meta.reuseNudgedAt;
5821
- await writeProject(onDisk, cwd);
5656
+ var WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
5657
+ var KIND_WAIT_TIMEOUT_MS = {
5658
+ video: 15 * 60 * 1e3,
5659
+ // 15 min
5660
+ motion: 20 * 60 * 1e3,
5661
+ // 8 takes ≈ 8-9 GPU min + a serverless cold start
5662
+ character_concept: 15 * 60 * 1e3,
5663
+ character_preview: 30 * 60 * 1e3,
5664
+ character: 30 * 60 * 1e3,
5665
+ character_animation: 20 * 60 * 1e3,
5666
+ // Per clip this lane is fast (locomotion ~2s, video ~51s, described ~130s) —
5667
+ // the BATCH is what takes time. A 20-clip standard set is the sizing case.
5668
+ character_motion: 30 * 60 * 1e3
5669
+ };
5670
+ var waitTimeoutFor = (kind) => KIND_WAIT_TIMEOUT_MS[kind] ?? WAIT_TIMEOUT_MS;
5671
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed"]);
5672
+ async function waitViaSSE(apiUrl, token, id, onProgress, deadline) {
5673
+ let connectFailures = 0;
5674
+ let last = -1;
5675
+ while (Date.now() < deadline) {
5676
+ const abort = new AbortController();
5677
+ const timer = setTimeout(() => abort.abort(), deadline - Date.now());
5678
+ try {
5679
+ let res;
5680
+ try {
5681
+ res = await apiFetch(`${apiUrl}/api/generations/${id}/events`, {
5682
+ headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
5683
+ signal: abort.signal
5684
+ });
5685
+ } catch {
5686
+ if (++connectFailures >= 3) return "unsupported";
5687
+ await sleep2(2e3);
5688
+ continue;
5689
+ }
5690
+ if (res.status === 404 || res.status === 405) return "unsupported";
5691
+ const contentType = res.headers.get("content-type") ?? "";
5692
+ if (!res.ok || !res.body || !contentType.includes("text/event-stream")) {
5693
+ if (++connectFailures >= 3) return "unsupported";
5694
+ await sleep2(2e3);
5695
+ continue;
5696
+ }
5697
+ connectFailures = 0;
5698
+ try {
5699
+ for await (const ev of readSSE(res.body)) {
5700
+ if (ev.event !== "status") continue;
5701
+ let view;
5702
+ try {
5703
+ view = JSON.parse(ev.data);
5704
+ } catch {
5705
+ continue;
5706
+ }
5707
+ if (view.progress !== last) {
5708
+ last = view.progress;
5709
+ onProgress(view.progress);
5710
+ }
5711
+ if (TERMINAL.has(view.status)) return view;
5712
+ }
5713
+ } catch {
5714
+ }
5715
+ await sleep2(1e3);
5716
+ } finally {
5717
+ clearTimeout(timer);
5718
+ abort.abort();
5822
5719
  }
5823
- } catch {
5824
5720
  }
5721
+ return null;
5825
5722
  }
5826
- async function borrowEvidence(meta, cwd) {
5827
- let host;
5828
- let ownPath;
5829
- try {
5830
- const own = new URL(meta.cloneUrl);
5831
- host = own.host;
5832
- ownPath = own.pathname;
5833
- } catch {
5834
- return true;
5835
- }
5836
- const gitConfig = await fs15.readFile(path15.join(cwd, ".git", "config"), "utf8").catch(() => "");
5837
- for (const m of gitConfig.matchAll(/url\s*=\s*(\S+)/g)) {
5723
+ async function poll(apiUrl, token, id, onProgress, timeoutMs) {
5724
+ const deadline = Date.now() + timeoutMs;
5725
+ let last = -1;
5726
+ while (Date.now() < deadline) {
5838
5727
  try {
5839
- const u = new URL(m[1]);
5840
- if (u.host === host && u.pathname !== ownPath) return true;
5728
+ const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
5729
+ headers: { Authorization: `Bearer ${token}` }
5730
+ });
5731
+ if (res.ok) {
5732
+ const { generation } = await res.json();
5733
+ if (generation.progress !== last) {
5734
+ last = generation.progress;
5735
+ onProgress(generation.progress);
5736
+ }
5737
+ if (generation.status === "completed" || generation.status === "failed") {
5738
+ return generation;
5739
+ }
5740
+ }
5841
5741
  } catch {
5842
5742
  }
5743
+ await sleep2(3e3);
5843
5744
  }
5844
- const readme = await fs15.readFile(path15.join(cwd, "README.md"), "utf8").catch(() => "");
5845
- return readme.includes(host) || /\b(upstream|originally by|ported from|borrowed from)\b/i.test(readme);
5745
+ return null;
5846
5746
  }
5847
-
5848
- // src/commands/publish.ts
5849
- async function runPublish(opts) {
5850
- const log = createLogger({ quiet: opts.quiet });
5851
- log.plain(c.bold("genex publish"));
5852
- log.plain("");
5853
- const token = opts.token ?? await readUserToken(opts.envPath);
5854
- if (!token) {
5855
- log.error("Not authorized. Run `genex init` first to sign in.");
5856
- process.exitCode = 1;
5857
- return;
5747
+ async function reportTextureSeam(url, log) {
5748
+ try {
5749
+ const res = await fetch(url);
5750
+ if (!res.ok) return;
5751
+ const png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
5752
+ if (png.width < 4 || png.height < 4) return;
5753
+ const r = measureSeam(png);
5754
+ if (r.seamless) {
5755
+ log.dim(` Tiling check: seam ${r.worstRatio}\xD7 this texture's own detail \u2014 tiles cleanly.`);
5756
+ return;
5757
+ }
5758
+ log.error(
5759
+ `Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
5760
+ );
5761
+ log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
5762
+ log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground).');
5763
+ log.plain(" Do NOT hide it by tiling less: a hand-picked repeat trades a seam you can name for a");
5764
+ log.plain(" stretched texture you can't. Scale belongs in the UVs (worldUV \u2014 genex-ai-texture).");
5765
+ log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
5766
+ } catch {
5767
+ }
5768
+ }
5769
+ function printHint(kind, view, files, log) {
5770
+ const url = files[0]?.url ?? "";
5771
+ const hint = {
5772
+ model: `Standard GLB \u2014 load with GLTFLoader straight from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
5773
+ skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
5774
+ sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. url = "${url}"`,
5775
+ music: `Load with AudioLoader into a non-positional THREE.Audio: setLoop(true), setVolume(0.30), start on the first user gesture \u2014 and the settings screen MUST have Music + SFX volume sliders \u2014 see genex-ai-music. url = "${url}"`,
5776
+ voice: `Load with AudioLoader into a THREE.Audio (or PositionalAudio at the speaker); voice lines are content \u2014 keep them short, SUBTITLED, and skippable \u2014 see genex-ai-voice. url = "${url}"`,
5777
+ texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`,
5778
+ image: `Load with TextureLoader (set colorSpace = SRGBColorSpace) onto any mesh/plane/sprite \u2014 see genex-ai-image. url = "${url}"`,
5779
+ video: `Wire an HTMLVideoElement (crossOrigin="anonymous", muted, loop, playsInline) into a THREE.VideoTexture \u2014 see genex-ai-video. url = "${url}"`,
5780
+ motion: `Raw ARDY takes + verify report \u2014 download the takes, rename them meaningfully (routing is by filename), then: genex motion verify \u2192 compile \u2192 install. Full procedure: the genex-ai-character skill's motion reference.`,
5781
+ character_concept: "Show all three concept candidates to the user and wait for an explicit selection.",
5782
+ character_preview: "Show all four preview views to the user and wait for explicit approval before finalizing.",
5783
+ character: `Install the controller and current manifest with genex controller character --character ${view.id}.`,
5784
+ character_animation: "The action is now part of the character's current manifest; refresh the local controller manifest before testing.",
5785
+ character_motion: `The clips are part of the character's current manifest \u2014 install them with genex controller character --character ${view.id}, then play the game and watch the motion on the real character before calling it done.`
5786
+ };
5787
+ log.dim(` ${hint[kind]}`);
5788
+ log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
5789
+ }
5790
+ var CONCEPT_STOP = "STOP \u2014 show all three concept images to the user and wait for an explicit selection. Do not create the 3D preview until they choose candidate 1, 2, or 3.";
5791
+ function metadataOf(view) {
5792
+ return view.metadata && typeof view.metadata === "object" ? view.metadata : {};
5793
+ }
5794
+ function stringValue(value) {
5795
+ return typeof value === "string" && value.length > 0 ? value : void 0;
5796
+ }
5797
+ function finiteNumber(value) {
5798
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
5799
+ }
5800
+ function conceptCandidates(view) {
5801
+ const candidates = [];
5802
+ for (const file of view.files ?? []) {
5803
+ const match = /^(?:concept-candidate|character-concept)-([123])(?:\.[a-z0-9]+)?$/i.exec(file.role);
5804
+ if (!match) continue;
5805
+ candidates.push({ candidate: Number(match[1]), role: file.role, url: file.url });
5858
5806
  }
5859
- const meta = await readProject();
5860
- if (!meta) {
5861
- log.error("No genex project here. Run `genex init` in this directory first.");
5807
+ return candidates.sort((a, b) => a.candidate - b.candidate);
5808
+ }
5809
+ function conceptContactSheet(view) {
5810
+ return (view.files ?? []).find(
5811
+ (file) => /^(?:concept-gallery|character-concepts-contact-sheet)(?:\.[a-z0-9]+)?$/i.test(file.role)
5812
+ );
5813
+ }
5814
+ function requestedBuildOf(view) {
5815
+ const metadata = metadataOf(view);
5816
+ const raw = metadata.requestedBuild && typeof metadata.requestedBuild === "object" ? metadata.requestedBuild : {};
5817
+ const actionIds = Array.isArray(raw.actionIds) ? raw.actionIds.filter((id) => typeof id === "number" && Number.isInteger(id)) : [];
5818
+ return {
5819
+ actionIds,
5820
+ controllerPack: raw.controllerPack !== false,
5821
+ ...finiteNumber(raw.heightMeters) === void 0 ? {} : { heightMeters: finiteNumber(raw.heightMeters) }
5822
+ };
5823
+ }
5824
+ function characterFinalizeCommand(previewId, intent) {
5825
+ const args = [
5826
+ "genex",
5827
+ "character",
5828
+ "finalize",
5829
+ previewId,
5830
+ "--user-approved",
5831
+ "--approve-remesh",
5832
+ "10000"
5833
+ ];
5834
+ if (intent.heightMeters !== void 0) args.push("--height", String(intent.heightMeters));
5835
+ for (const actionId of intent.actionIds) args.push("--animation", String(actionId));
5836
+ return args.join(" ");
5837
+ }
5838
+ function reportCharacterConceptTerminal(view, log, opts = {}) {
5839
+ const candidates = conceptCandidates(view);
5840
+ if (candidates.length !== 3 || candidates.some((entry, index) => entry.candidate !== index + 1)) {
5841
+ const error = "Character concept generation completed without exactly candidates 1, 2, and 3.";
5842
+ if (opts.json) writeJson({ kind: "character_concept", id: view.id, status: "failed", error });
5843
+ else log.error(error);
5862
5844
  process.exitCode = 1;
5863
5845
  return;
5864
5846
  }
5865
- const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
5866
- const detections = await detectFeatures(log);
5867
- advisoryNudges(log, detections);
5868
- if (!opts.noPush) {
5869
- const ok = await deployGame(
5870
- { projectId: meta.id, apiUrl, token },
5871
- {
5872
- noBuild: opts.noBuild,
5873
- matchmaking: detections.matchmaking,
5874
- embedSdkVersion: detections.embedSdkVersion,
5875
- multiplayer: detections.multiplayer,
5876
- mobileControls: detections.mobileControls
5877
- },
5878
- log
5879
- );
5880
- if (!ok) {
5881
- process.exitCode = 1;
5882
- return;
5883
- }
5884
- const onDisk = await readProject();
5885
- const merged = { ...onDisk ?? meta, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
5886
- await writeProject(merged);
5887
- meta.lastDeployAt = merged.lastDeployAt;
5888
- } else {
5889
- log.info("Skipping build + deploy (--no-push).");
5890
- }
5891
- log.step("Listing it in the gallery\u2026");
5892
- let res;
5893
- try {
5894
- const body = {};
5895
- if (opts.title) body.title = opts.title;
5896
- if (opts.description) body.description = opts.description;
5897
- if (opts.regenerateCover) body.regenerateCover = true;
5898
- if (opts.categories?.length) body.categories = opts.categories;
5899
- if (opts.sourceRepoUrl) body.sourceRepoUrl = opts.sourceRepoUrl;
5900
- if (opts.sourceAuthor) body.sourceAuthor = opts.sourceAuthor;
5901
- if (opts.license) body.license = opts.license;
5902
- if (opts.purchaseUrl) body.purchaseUrl = opts.purchaseUrl;
5903
- if (opts.productName) body.productName = opts.productName;
5904
- if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
5905
- body.multiplayer = detections.multiplayer;
5906
- body.matchmaking = detections.matchmaking ?? null;
5907
- body.gameStateUsed = detections.gameStateUsed;
5908
- body.mobileControls = detections.mobileControls;
5909
- res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
5910
- method: "POST",
5911
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
5912
- body: JSON.stringify(body)
5847
+ const metadata = metadataOf(view);
5848
+ const contactSheet = conceptContactSheet(view);
5849
+ const nextCommand = `genex character preview ${view.id} --candidate <n> --user-approved`;
5850
+ if (opts.open) openUrl(contactSheet?.url ?? candidates[0].url);
5851
+ if (opts.json) {
5852
+ writeJson({
5853
+ kind: "character_concept",
5854
+ stage: "concept",
5855
+ id: view.id,
5856
+ status: view.status,
5857
+ provider: stringValue(metadata.provider),
5858
+ model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
5859
+ candidates,
5860
+ ...contactSheet ? { contactSheetUrl: contactSheet.url } : {},
5861
+ ...opts.quote ? { quote: opts.quote } : {},
5862
+ requiresUserApproval: true,
5863
+ nextCommand
5913
5864
  });
5914
- } catch {
5915
- log.error("Couldn't reach Genex \u2014 please try again.");
5916
- process.exitCode = 1;
5917
- return;
5918
- }
5919
- if (!res.ok) {
5920
- log.error("Publish failed \u2014 please try again.");
5921
- process.exitCode = 1;
5922
5865
  return;
5923
5866
  }
5867
+ log.success("Three character concept candidates are ready:");
5868
+ for (const candidate of candidates) log.plain(` ${candidate.candidate}. ${candidate.url}`);
5869
+ if (contactSheet) log.plain(` Contact sheet: ${contactSheet.url}`);
5924
5870
  log.plain("");
5925
- log.success("Published. \u{1F389}");
5926
- const dashboard = meta.dashboardOrigins?.[0];
5927
- if (dashboard) log.plain(` world page (share this link): ${c.cyan(`${dashboard}/world/${meta.slug}`)}`);
5928
- if (meta.playUrl) log.dim(` play: ${meta.playUrl}`);
5929
- }
5930
-
5931
- // src/commands/preview.ts
5932
- async function runPreview(opts) {
5933
- const log = createLogger({ quiet: opts.quiet });
5934
- log.plain(c.bold("genex preview"));
5871
+ log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all three numbered links, not only your favorite.`);
5872
+ log.plain(` ${c.bold(CONCEPT_STOP)}`);
5873
+ log.plain(` After approval: ${c.cyan(nextCommand)}`);
5935
5874
  log.plain("");
5936
- const meta = await readProject();
5937
- if (!meta) {
5938
- log.error("No genex project here. Run `genex init` in this directory first.");
5939
- process.exitCode = 1;
5940
- return;
5875
+ }
5876
+ var PREVIEW_DIRECTIONS = ["front", "back", "left", "right"];
5877
+ function previewViews(view) {
5878
+ const metadata = metadataOf(view);
5879
+ const rawViews = metadata.views && typeof metadata.views === "object" ? metadata.views : {};
5880
+ const result = {};
5881
+ for (const direction of PREVIEW_DIRECTIONS) {
5882
+ const fromMetadata = stringValue(rawViews[direction]);
5883
+ const fromFile = (view.files ?? []).find((file) => {
5884
+ const role = file.role.toLowerCase();
5885
+ return file.contentType.startsWith("image/") && role.includes(direction);
5886
+ })?.url;
5887
+ result[direction] = fromMetadata ?? fromFile;
5941
5888
  }
5942
- const token = await readUserToken(opts.envPath);
5943
- if (!token) {
5944
- log.error("Not authorized. Run `genex init` first to sign in.");
5889
+ return result;
5890
+ }
5891
+ function previewSource(view) {
5892
+ return (view.files ?? []).find(
5893
+ (file) => file.contentType === "model/gltf-binary" || /(?:source-highpoly|character-preview)(?:\.glb)?$/i.test(file.role)
5894
+ )?.url;
5895
+ }
5896
+ function reportCharacterPreviewTerminal(view, log, opts = {}) {
5897
+ const metadata = metadataOf(view);
5898
+ const views = previewViews(view);
5899
+ const sourceFaceCount = finiteNumber(metadata.sourceFaceCount);
5900
+ const missingViews = PREVIEW_DIRECTIONS.filter((direction) => !views[direction]);
5901
+ if (sourceFaceCount === void 0 || missingViews.length > 0) {
5902
+ const detail = [
5903
+ sourceFaceCount === void 0 ? "source face count" : "",
5904
+ missingViews.length > 0 ? `${missingViews.join(", ")} view${missingViews.length === 1 ? "" : "s"}` : ""
5905
+ ].filter(Boolean).join(" and ");
5906
+ const error = `Character preview completed without ${detail}.`;
5907
+ if (opts.json) writeJson({ kind: "character_preview", id: view.id, status: "failed", error });
5908
+ else log.error(error);
5945
5909
  process.exitCode = 1;
5946
5910
  return;
5947
5911
  }
5948
- const detections = await detectFeatures(log);
5949
- advisoryNudges(log, detections);
5950
- await exploreReuseNudge(log, meta);
5951
- const apiUrl = getApiUrl(meta.apiUrl);
5952
- const ok = await deployGame(
5953
- { projectId: meta.id, apiUrl, token },
5954
- // Detections reach the server on every preview too: matchmaking so a draft's
5955
- // declared preset doesn't silently run the default (null clears a removed
5956
- // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
5957
- // list the draft without wrongly treating it as a pre-embed-auth bundle.
5958
- {
5959
- noBuild: opts.noBuild,
5960
- matchmaking: detections.matchmaking,
5961
- embedSdkVersion: detections.embedSdkVersion,
5962
- multiplayer: detections.multiplayer,
5963
- mobileControls: detections.mobileControls
5964
- },
5965
- log
5966
- );
5967
- if (!ok) {
5968
- process.exitCode = 1;
5912
+ const intent = requestedBuildOf(view);
5913
+ const nextCommand = characterFinalizeCommand(view.id, intent);
5914
+ const sourceUrl = previewSource(view);
5915
+ const stop = `STOP \u2014 show the front, back, left, and right views to the user. The high-detail source has ${sourceFaceCount} faces and remains preserved in R2. Ask them to approve a separate 10,000-face triangle rigging copy.`;
5916
+ if (opts.open) openUrl(views.front);
5917
+ if (opts.json) {
5918
+ writeJson({
5919
+ kind: "character_preview",
5920
+ stage: "preview",
5921
+ id: view.id,
5922
+ status: view.status,
5923
+ provider: stringValue(metadata.provider),
5924
+ model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
5925
+ poseMode: stringValue(metadata.poseMode),
5926
+ shouldRemesh: metadata.shouldRemesh === true,
5927
+ sourceFaceCount,
5928
+ views,
5929
+ ...sourceUrl ? { sourceUrl } : {},
5930
+ requestedBuild: intent,
5931
+ ...opts.quote ? { quote: opts.quote } : {},
5932
+ requiresUserApproval: true,
5933
+ nextCommand
5934
+ });
5969
5935
  return;
5970
5936
  }
5971
- const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
5972
- if (freshStatus && freshStatus !== meta.status) {
5973
- meta.status = freshStatus;
5974
- }
5975
- const onDisk = await readProject();
5976
- const merged = { ...onDisk ?? meta, status: meta.status, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
5977
- await writeProject(merged);
5978
- meta.lastDeployAt = merged.lastDeployAt;
5979
- const published = meta.status === "published";
5937
+ log.success("The high-detail Meshy character preview is ready:");
5938
+ for (const direction of PREVIEW_DIRECTIONS) log.plain(` ${direction}: ${views[direction]}`);
5939
+ if (sourceUrl) log.plain(` High-detail source: ${sourceUrl}`);
5940
+ log.plain("");
5941
+ log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all four views.`);
5942
+ log.plain(` ${c.bold(stop)}`);
5943
+ log.plain(` After approval: ${c.cyan(nextCommand)}`);
5980
5944
  log.plain("");
5981
- if (published) {
5982
- log.success("Update deployed \u2014 this game is PUBLISHED, so the new build is already live for everyone.");
5983
- } else {
5984
- log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
5985
- }
5986
- const dashboard = meta.dashboardOrigins?.[0];
5987
- if (dashboard) {
5988
- const page = published ? "world" : "draft";
5989
- log.plain(` your game's page (share this link): ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
5990
- }
5945
+ }
5946
+ function writeJson(value) {
5947
+ process.stdout.write(`${JSON.stringify(value)}
5948
+ `);
5991
5949
  }
5992
5950
 
5993
5951
  // src/commands/wait.ts
@@ -6129,15 +6087,6 @@ async function runWaitAll(opts) {
6129
6087
  if (count("running") + count("queued") > 0) {
6130
6088
  log.dim(` Attach to any single one with: ${c.cyan("npx genex wait <id>")}`);
6131
6089
  }
6132
- const stage1Stalled = rows.some(
6133
- (r, i) => r.kind === "image" && r.status === "done" && r.prompt.startsWith(STAGE1_PROMPT_MARKER) && !rows.some(
6134
- (s, j) => j > i && s.kind === "image" && s.prompt.startsWith(STAGE2_PROMPT_MARKER)
6135
- )
6136
- );
6137
- if (stage1Stalled) {
6138
- log.plain("");
6139
- log.plain(` ${c.bold(STAGE1_LANDING_NUDGE)}`);
6140
- }
6141
6090
  log.plain("");
6142
6091
  log.dim(
6143
6092
  " \u{1F4DC} Resumed or compacted? Re-read AGENTS.md + DESIGN.md (continue from its Now: line) before building on."
@@ -19190,7 +19139,8 @@ async function main() {
19190
19139
  log.plain(HELP);
19191
19140
  return;
19192
19141
  }
19193
- const updateLog = createLogger({ quiet: parsed.options.quiet || parsed.options.json });
19142
+ const quietUpdates = Boolean(parsed.options.quiet || parsed.options.json);
19143
+ const updateLog = createLogger({ quiet: quietUpdates });
19194
19144
  if (parsed.command !== "init") await syncSkills(updateLog);
19195
19145
  const updateCheck = startUpdateCheck();
19196
19146
  Sentry2.setTag("command", parsed.command);
@@ -19311,7 +19261,7 @@ async function main() {
19311
19261
  process.exitCode = 1;
19312
19262
  }
19313
19263
  } finally {
19314
- await reportUpdateNudges(updateCheck, updateLog);
19264
+ if (!quietUpdates) await reportUpdateNudges(updateCheck, updateLog);
19315
19265
  await flushSentry();
19316
19266
  }
19317
19267
  }