@genex-ai/cli-demo 1.15.1-dev.584 → 1.16.0-dev.586

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.
@@ -7,7 +7,7 @@ import {
7
7
  isRenderMode,
8
8
  sceneSummary,
9
9
  sheetOf
10
- } from "./chunk-JGX6YRC7.js";
10
+ } from "./chunk-GRBSHEXG.js";
11
11
  import {
12
12
  getCliVersion
13
13
  } from "./chunk-HYCSNWYX.js";
@@ -440,129 +440,22 @@ async function fetchSignedInEmail(apiUrl, token) {
440
440
  }
441
441
 
442
442
  // src/lib/blender-client.ts
443
- import fs2 from "fs";
444
- import path2 from "path";
445
- var BLENDER_TIMEOUT_MS = 15 * 60 * 1e3;
446
- var RENDER_MODES = ["solid", "wireframe", "normals", "lit"];
447
- function isRenderMode(v) {
448
- return typeof v === "string" && RENDER_MODES.includes(v);
449
- }
450
- function blenderEndpoint() {
451
- const raw = process.env.GENEX_BLENDER_URL?.trim();
452
- return raw ? raw.replace(/\/+$/, "") : void 0;
453
- }
454
- function blenderSetupHint() {
455
- if (process.env.GENEX_HOSTED_SESSION === "1") {
456
- return [
457
- "The Blender lane is off on this stand \u2014 there is no seat to acquire.",
458
- " Build the space in code instead ($genex-threejs-procedural-assets); do not wait for it."
459
- ].join("\n");
460
- }
461
- return [
462
- "No Blender endpoint. Run the service against your own Blender in another terminal:",
463
- " npx genex blender serve",
464
- " export GENEX_BLENDER_URL=http://localhost:8088",
465
- " GENEX_BLENDER_URL may also point at any running genex-blender service."
466
- ].join("\n");
467
- }
468
- var SHEET_FORMATS = ["webp", "png"];
469
- function sheetOf(r) {
470
- if (r.contactSheet?.b64) return { b64: r.contactSheet.b64, mime: r.contactSheet.mime };
471
- if (r.contactSheetPng) return { b64: r.contactSheetPng, mime: "image/png" };
472
- return null;
473
- }
474
- function sheetExt(mime) {
475
- return mime === "image/webp" ? "webp" : "png";
476
- }
477
- var SEAT_FILE = path2.join(".genex", "blender-seat.json");
478
- function readSeatGrant(cwd = process.cwd()) {
479
- try {
480
- const raw = JSON.parse(fs2.readFileSync(path2.join(cwd, SEAT_FILE), "utf8"));
481
- return typeof raw.url === "string" && typeof raw.token === "string" ? { url: raw.url, token: raw.token } : null;
482
- } catch {
483
- return null;
484
- }
485
- }
486
- function writeSeatGrant(grant, cwd = process.cwd()) {
487
- const file = path2.join(cwd, SEAT_FILE);
488
- fs2.mkdirSync(path2.dirname(file), { recursive: true });
489
- fs2.writeFileSync(file, JSON.stringify(grant, null, 2) + "\n", { mode: 384 });
490
- }
491
- async function blenderCall(base, route, body) {
492
- const seat = readSeatGrant();
493
- const ctl = new AbortController();
494
- const timer = setTimeout(() => ctl.abort(), BLENDER_TIMEOUT_MS);
495
- try {
496
- const res = await fetch(`${base}${route}`, {
497
- method: body === void 0 ? "GET" : "POST",
498
- headers: {
499
- // A REAL User-Agent, because the vendor proxy in front of a hosted pod
500
- // sits behind Cloudflare, and Cloudflare's error 1010 refuses the
501
- // default Python UA outright (measured: Python-urllib/3.x -> 403; any
502
- // override -> 200). Whether it also refuses undici's default is
503
- // untested, and this is the client every hosted agent will reach the
504
- // pod with -- so the question is closed here rather than found in a
505
- // sandbox as a 403 that looks like a bad token.
506
- "User-Agent": `genex-cli/${getCliVersion()}`,
507
- ...body === void 0 ? {} : { "Content-Type": "application/json" },
508
- // TWO credentials, two headers, and the router is strict about which is
509
- // which: a hosted SEAT presents its token as `x-genex-seat` on
510
- // `/s/<sid>/…`, while `x-genex-internal` is the POD secret and is honoured
511
- // only on `/pool/*`. The first cut sent the seat token under the pod
512
- // header and every hosted call would have been a 401 whose message
513
- // pointed at the wrong knob (review finding).
514
- ...seat?.token ? { "x-genex-seat": seat.token } : {},
515
- // Sent only when set. The service treats an unset secret as open, which
516
- // is right on localhost; the deployed compose makes it mandatory.
517
- ...!seat?.token && process.env.GENEX_BLENDER_SECRET ? { "x-genex-internal": process.env.GENEX_BLENDER_SECRET } : {}
518
- },
519
- body: body === void 0 ? void 0 : JSON.stringify(body),
520
- signal: ctl.signal
521
- });
522
- const text = await res.text();
523
- let json;
524
- try {
525
- json = JSON.parse(text);
526
- } catch {
527
- throw new Error(`${route} answered ${res.status} with non-JSON: ${text.slice(0, 200)}`);
528
- }
529
- if (!res.ok) {
530
- if (res.status === 401) {
531
- throw new Error(
532
- seat ? `${route} refused the seat token (401) \u2014 the seat may have been closed; run the command again to acquire a new one` : `${route} refused the request (401) \u2014 set GENEX_BLENDER_SECRET to the service's secret`
533
- );
534
- }
535
- const detail = typeof json.detail === "string" ? ` \u2014 ${json.detail}` : "";
536
- throw new Error(`${route} failed (${res.status}): ${json.error ?? text.slice(0, 200)}${detail}`);
537
- }
538
- return json;
539
- } catch (err) {
540
- if (err instanceof Error && err.name === "AbortError") {
541
- throw new Error(`${route} timed out after ${BLENDER_TIMEOUT_MS / 1e3}s`);
542
- }
543
- throw err;
544
- } finally {
545
- clearTimeout(timer);
546
- }
547
- }
548
- function sceneSummary(s) {
549
- if (!s) return "";
550
- return `objects ${s.objectCount} meshes ${s.meshCount} tris ${s.totalTris} materials ${s.materialCount} radius ${s.bounds.radius}`;
551
- }
552
-
553
- // src/lib/store.ts
554
- import fs4 from "fs/promises";
443
+ import fs4 from "fs";
555
444
  import path4 from "path";
556
445
 
557
- // src/lib/env.ts
446
+ // src/lib/store.ts
558
447
  import fs3 from "fs/promises";
559
448
  import path3 from "path";
449
+
450
+ // src/lib/env.ts
451
+ import fs2 from "fs/promises";
452
+ import path2 from "path";
560
453
  import { spawn as spawn2 } from "child_process";
561
454
  async function writeEnvVar(envPath, key, value) {
562
455
  let content = "";
563
456
  let existed = false;
564
457
  try {
565
- content = await fs3.readFile(envPath, "utf8");
458
+ content = await fs2.readFile(envPath, "utf8");
566
459
  existed = true;
567
460
  } catch {
568
461
  }
@@ -582,14 +475,14 @@ async function writeEnvVar(envPath, key, value) {
582
475
  next = prefix + assignment + "\n";
583
476
  mode = existed ? "appended" : "created";
584
477
  }
585
- await fs3.mkdir(path3.dirname(envPath), { recursive: true });
586
- await fs3.writeFile(envPath, next, { mode: 384 });
478
+ await fs2.mkdir(path2.dirname(envPath), { recursive: true });
479
+ await fs2.writeFile(envPath, next, { mode: 384 });
587
480
  await restrictFilePermissions(envPath);
588
481
  return { mode, path: envPath };
589
482
  }
590
483
  async function restrictFilePermissions(filePath) {
591
484
  if (process.platform !== "win32") {
592
- await fs3.chmod(filePath, 384).catch(() => {
485
+ await fs2.chmod(filePath, 384).catch(() => {
593
486
  });
594
487
  return;
595
488
  }
@@ -621,14 +514,14 @@ function escapeRegExp(s) {
621
514
 
622
515
  // src/lib/store.ts
623
516
  function getProjectMetadataPath(cwd = process.cwd()) {
624
- return path4.join(cwd, ".genex", "project.json");
517
+ return path3.join(cwd, ".genex", "project.json");
625
518
  }
626
519
  function getWorkspacePath(cwd = process.cwd()) {
627
- return path4.join(cwd, ".genex", "workspace.json");
520
+ return path3.join(cwd, ".genex", "workspace.json");
628
521
  }
629
522
  async function readWorkspace(cwd = process.cwd()) {
630
523
  try {
631
- const raw = await fs4.readFile(getWorkspacePath(cwd), "utf8");
524
+ const raw = await fs3.readFile(getWorkspacePath(cwd), "utf8");
632
525
  return JSON.parse(raw);
633
526
  } catch {
634
527
  return null;
@@ -636,9 +529,9 @@ async function readWorkspace(cwd = process.cwd()) {
636
529
  }
637
530
  async function writeWorkspace(meta, cwd = process.cwd()) {
638
531
  const file = getWorkspacePath(cwd);
639
- await fs4.mkdir(path4.dirname(file), { recursive: true });
640
- await fs4.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
641
- await fs4.chmod(file, 384).catch(() => {
532
+ await fs3.mkdir(path3.dirname(file), { recursive: true });
533
+ await fs3.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
534
+ await fs3.chmod(file, 384).catch(() => {
642
535
  });
643
536
  return { path: file };
644
537
  }
@@ -651,7 +544,7 @@ async function rotateRejectedEnv(envPath) {
651
544
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
652
545
  const aside = `${file}.rejected-${stamp}`;
653
546
  try {
654
- await fs4.rename(file, aside);
547
+ await fs3.rename(file, aside);
655
548
  return aside;
656
549
  } catch {
657
550
  return null;
@@ -661,14 +554,14 @@ async function readUserToken(envPath) {
661
554
  const fromGenex = await readTokenFromFile(getGenexEnvPath(envPath));
662
555
  if (fromGenex) return fromGenex;
663
556
  if (!envPath && !process.env[ENV_FILE_ENV]) {
664
- return readTokenFromFile(path4.join(process.cwd(), ".env"));
557
+ return readTokenFromFile(path3.join(process.cwd(), ".env"));
665
558
  }
666
559
  return null;
667
560
  }
668
561
  async function readTokenFromFile(file) {
669
562
  let content;
670
563
  try {
671
- content = await fs4.readFile(file, "utf8");
564
+ content = await fs3.readFile(file, "utf8");
672
565
  } catch {
673
566
  return null;
674
567
  }
@@ -684,7 +577,7 @@ function stripQuotes(v) {
684
577
  }
685
578
  async function readProject(cwd = process.cwd()) {
686
579
  try {
687
- const raw = await fs4.readFile(getProjectMetadataPath(cwd), "utf8");
580
+ const raw = await fs3.readFile(getProjectMetadataPath(cwd), "utf8");
688
581
  return JSON.parse(raw);
689
582
  } catch {
690
583
  return null;
@@ -692,13 +585,150 @@ async function readProject(cwd = process.cwd()) {
692
585
  }
693
586
  async function writeProject(meta, cwd = process.cwd()) {
694
587
  const file = getProjectMetadataPath(cwd);
695
- await fs4.mkdir(path4.dirname(file), { recursive: true });
696
- await fs4.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
697
- await fs4.chmod(file, 384).catch(() => {
588
+ await fs3.mkdir(path3.dirname(file), { recursive: true });
589
+ await fs3.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
590
+ await fs3.chmod(file, 384).catch(() => {
698
591
  });
699
592
  return { path: file };
700
593
  }
701
594
 
595
+ // src/lib/blender-client.ts
596
+ var BLENDER_TIMEOUT_MS = 15 * 60 * 1e3;
597
+ var RENDER_MODES = ["solid", "wireframe", "normals", "lit"];
598
+ function isRenderMode(v) {
599
+ return typeof v === "string" && RENDER_MODES.includes(v);
600
+ }
601
+ function blenderEndpoint() {
602
+ const raw = process.env.GENEX_BLENDER_URL?.trim();
603
+ return raw ? raw.replace(/\/+$/, "") : void 0;
604
+ }
605
+ function blenderSetupHint() {
606
+ if (process.env.GENEX_HOSTED_SESSION === "1") {
607
+ return [
608
+ "The Blender lane is off on this stand \u2014 there is no seat to acquire.",
609
+ " Build the space in code instead ($genex-threejs-procedural-assets); do not wait for it."
610
+ ].join("\n");
611
+ }
612
+ return [
613
+ "No Blender endpoint. Run the service against your own Blender in another terminal:",
614
+ " npx genex blender serve",
615
+ " export GENEX_BLENDER_URL=http://localhost:8088",
616
+ " GENEX_BLENDER_URL may also point at any running genex-blender service."
617
+ ].join("\n");
618
+ }
619
+ var SHEET_FORMATS = ["webp", "png"];
620
+ function sheetOf(r) {
621
+ if (r.contactSheet?.b64) return { b64: r.contactSheet.b64, mime: r.contactSheet.mime };
622
+ if (r.contactSheetPng) return { b64: r.contactSheetPng, mime: "image/png" };
623
+ return null;
624
+ }
625
+ function sheetExt(mime) {
626
+ return mime === "image/webp" ? "webp" : "png";
627
+ }
628
+ var SEAT_FILE = path4.join(".genex", "blender-seat.json");
629
+ function readSeatGrant(cwd = process.cwd()) {
630
+ try {
631
+ const raw = JSON.parse(fs4.readFileSync(path4.join(cwd, SEAT_FILE), "utf8"));
632
+ if (typeof raw.url !== "string" || typeof raw.token !== "string") return null;
633
+ return {
634
+ url: raw.url,
635
+ token: raw.token,
636
+ ...typeof raw.seatId === "string" ? { seatId: raw.seatId } : {},
637
+ ...raw.kind === "cli" || raw.kind === "hosted" ? { kind: raw.kind } : {}
638
+ };
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
643
+ function writeSeatGrant(grant, cwd = process.cwd()) {
644
+ const file = path4.join(cwd, SEAT_FILE);
645
+ fs4.mkdirSync(path4.dirname(file), { recursive: true });
646
+ fs4.writeFileSync(file, JSON.stringify(grant, null, 2) + "\n", { mode: 384 });
647
+ }
648
+ function deleteSeatGrant(cwd = process.cwd()) {
649
+ try {
650
+ fs4.unlinkSync(path4.join(cwd, SEAT_FILE));
651
+ } catch {
652
+ }
653
+ }
654
+ var lastTouchAt = 0;
655
+ var TOUCH_THROTTLE_MS = 6e4;
656
+ async function touchCliSeat() {
657
+ if (Date.now() - lastTouchAt < TOUCH_THROTTLE_MS) return;
658
+ lastTouchAt = Date.now();
659
+ try {
660
+ const token = await readUserToken();
661
+ if (!token) return;
662
+ await apiFetch(`${getApiUrl()}/api/blender/seat/touch`, {
663
+ method: "POST",
664
+ headers: { Authorization: `Bearer ${token}` }
665
+ });
666
+ } catch {
667
+ }
668
+ }
669
+ async function blenderCall(base, route, body) {
670
+ const seat = readSeatGrant();
671
+ const ctl = new AbortController();
672
+ const timer = setTimeout(() => ctl.abort(), BLENDER_TIMEOUT_MS);
673
+ try {
674
+ const res = await fetch(`${base}${route}`, {
675
+ method: body === void 0 ? "GET" : "POST",
676
+ headers: {
677
+ // A REAL User-Agent, because the vendor proxy in front of a hosted pod
678
+ // sits behind Cloudflare, and Cloudflare's error 1010 refuses the
679
+ // default Python UA outright (measured: Python-urllib/3.x -> 403; any
680
+ // override -> 200). Whether it also refuses undici's default is
681
+ // untested, and this is the client every hosted agent will reach the
682
+ // pod with -- so the question is closed here rather than found in a
683
+ // sandbox as a 403 that looks like a bad token.
684
+ "User-Agent": `genex-cli/${getCliVersion()}`,
685
+ ...body === void 0 ? {} : { "Content-Type": "application/json" },
686
+ // TWO credentials, two headers, and the router is strict about which is
687
+ // which: a hosted SEAT presents its token as `x-genex-seat` on
688
+ // `/s/<sid>/…`, while `x-genex-internal` is the POD secret and is honoured
689
+ // only on `/pool/*`. The first cut sent the seat token under the pod
690
+ // header and every hosted call would have been a 401 whose message
691
+ // pointed at the wrong knob (review finding).
692
+ ...seat?.token ? { "x-genex-seat": seat.token } : {},
693
+ // Sent only when set. The service treats an unset secret as open, which
694
+ // is right on localhost; the deployed compose makes it mandatory.
695
+ ...!seat?.token && process.env.GENEX_BLENDER_SECRET ? { "x-genex-internal": process.env.GENEX_BLENDER_SECRET } : {}
696
+ },
697
+ body: body === void 0 ? void 0 : JSON.stringify(body),
698
+ signal: ctl.signal
699
+ });
700
+ const text = await res.text();
701
+ let json;
702
+ try {
703
+ json = JSON.parse(text);
704
+ } catch {
705
+ throw new Error(`${route} answered ${res.status} with non-JSON: ${text.slice(0, 200)}`);
706
+ }
707
+ if (!res.ok) {
708
+ if (res.status === 401) {
709
+ throw new Error(
710
+ seat ? `${route} refused the seat token (401) \u2014 the seat may have been closed; run the command again to acquire a new one` : `${route} refused the request (401) \u2014 set GENEX_BLENDER_SECRET to the service's secret`
711
+ );
712
+ }
713
+ const detail = typeof json.detail === "string" ? ` \u2014 ${json.detail}` : "";
714
+ throw new Error(`${route} failed (${res.status}): ${json.error ?? text.slice(0, 200)}${detail}`);
715
+ }
716
+ if (seat?.kind === "cli") void touchCliSeat();
717
+ return json;
718
+ } catch (err) {
719
+ if (err instanceof Error && err.name === "AbortError") {
720
+ throw new Error(`${route} timed out after ${BLENDER_TIMEOUT_MS / 1e3}s`);
721
+ }
722
+ throw err;
723
+ } finally {
724
+ clearTimeout(timer);
725
+ }
726
+ }
727
+ function sceneSummary(s) {
728
+ if (!s) return "";
729
+ return `objects ${s.objectCount} meshes ${s.meshCount} tris ${s.totalTris} materials ${s.materialCount} radius ${s.bounds.radius}`;
730
+ }
731
+
702
732
  // src/lib/blender-seat.ts
703
733
  var SEAT_WAIT_BUDGET_MS = 9e4;
704
734
  var SEAT_POLL_MS = 1e4;
@@ -725,7 +755,7 @@ async function acquireSeat(opts) {
725
755
  if (typeof body.url !== "string" || typeof body.token !== "string") {
726
756
  return { kind: "refused", reason: "the API answered a seat without a url and token" };
727
757
  }
728
- const grant = { url: body.url, token: body.token };
758
+ const grant = { url: body.url, token: body.token, kind: "hosted" };
729
759
  writeSeatGrant(grant, opts.cwd);
730
760
  return { kind: "granted", grant };
731
761
  }
@@ -748,6 +778,70 @@ async function acquireSeat(opts) {
748
778
  return { kind: "refused", reason };
749
779
  }
750
780
  }
781
+ async function acquireCliSeat(opts) {
782
+ const token = opts.token !== void 0 ? opts.token : await readUserToken();
783
+ if (!token) return { kind: "refused", reason: "not signed in" };
784
+ const doFetch = opts.fetchImpl ?? apiFetch;
785
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
786
+ const deadline = Date.now() + (opts.budgetMs ?? SEAT_WAIT_BUDGET_MS);
787
+ let announced = false;
788
+ for (; ; ) {
789
+ const res = await doFetch(`${getApiUrl()}/api/blender/seat`, {
790
+ method: "POST",
791
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
792
+ body: JSON.stringify({ cli: true, scope: opts.scope })
793
+ });
794
+ if (res.status === 201) {
795
+ const body = await res.json();
796
+ if (typeof body.url !== "string" || typeof body.token !== "string") {
797
+ return { kind: "refused", reason: "the API answered a seat without a url and token" };
798
+ }
799
+ const grant = {
800
+ url: body.url,
801
+ token: body.token,
802
+ kind: body.kind === "hosted" ? "hosted" : "cli",
803
+ ...typeof body.seatId === "string" ? { seatId: body.seatId } : {}
804
+ };
805
+ writeSeatGrant(grant, opts.cwd);
806
+ if (grant.kind === "cli" && body.pricing) {
807
+ opts.log.step(
808
+ `Blender seat: ${body.pricing.creditsPerBlock} credits per ${body.pricing.blockSeconds}s while it's open. It closes itself after ${body.pricing.idleMinutes} idle minutes, or run "genex blender release".`
809
+ );
810
+ }
811
+ return { kind: "granted", grant };
812
+ }
813
+ if (res.status === 202 || res.status === 429) {
814
+ if (!announced) {
815
+ opts.log.step("Warming up a Blender seat (about 20 s on a warm pod, ~2 min on a cold one, longer on a host pulling the image for the first time)\u2026");
816
+ announced = true;
817
+ }
818
+ if (Date.now() >= deadline) return { kind: "warming" };
819
+ await sleep(SEAT_POLL_MS);
820
+ continue;
821
+ }
822
+ if (res.status === 404) return { kind: "off" };
823
+ if (res.status === 402) return { kind: "refused", reason: "insufficient credits" };
824
+ let reason = `HTTP ${res.status}`;
825
+ try {
826
+ const body = await res.json();
827
+ reason = body.reason ?? body.error ?? reason;
828
+ } catch {
829
+ }
830
+ return { kind: "refused", reason };
831
+ }
832
+ }
833
+ async function releaseCliSeat(opts) {
834
+ const token = opts.token !== void 0 ? opts.token : await readUserToken();
835
+ if (!token) return { ok: false, reason: "not signed in" };
836
+ const doFetch = opts.fetchImpl ?? apiFetch;
837
+ const res = await doFetch(`${getApiUrl()}/api/blender/seat`, {
838
+ method: "DELETE",
839
+ headers: { Authorization: `Bearer ${token}` }
840
+ });
841
+ if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
842
+ deleteSeatGrant(opts.cwd);
843
+ return { ok: true };
844
+ }
751
845
 
752
846
  export {
753
847
  run,
@@ -785,7 +879,10 @@ export {
785
879
  SHEET_FORMATS,
786
880
  sheetOf,
787
881
  sheetExt,
882
+ readSeatGrant,
788
883
  blenderCall,
789
884
  sceneSummary,
790
- acquireSeat
885
+ acquireSeat,
886
+ acquireCliSeat,
887
+ releaseCliSeat
791
888
  };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  RENDER_MODES,
4
4
  SHEET_FORMATS,
5
5
  acceptUrl,
6
+ acquireCliSeat,
6
7
  acquireSeat,
7
8
  apiFetch,
8
9
  blenderCall,
@@ -20,8 +21,10 @@ import {
20
21
  printedStructuredError,
21
22
  readProject,
22
23
  readRemoteSource,
24
+ readSeatGrant,
23
25
  readUserToken,
24
26
  readWorkspace,
27
+ releaseCliSeat,
25
28
  reportForceRefused,
26
29
  reportStale,
27
30
  reportTermsRefusal,
@@ -37,7 +40,7 @@ import {
37
40
  writeProject,
38
41
  writeUserToken,
39
42
  writeWorkspace
40
- } from "./chunk-JGX6YRC7.js";
43
+ } from "./chunk-GRBSHEXG.js";
41
44
  import {
42
45
  CLI_CHANNEL,
43
46
  DEFAULT_API_URL,
@@ -20422,7 +20425,7 @@ async function runMotion(opts) {
20422
20425
  // src/commands/blender.ts
20423
20426
  import fs22 from "fs/promises";
20424
20427
  import path21 from "path";
20425
- var SUBS2 = ["demo", "exec", "snap", "scene", "import", "export", "reset", "mcp", "serve"];
20428
+ var SUBS2 = ["demo", "exec", "snap", "scene", "import", "export", "reset", "mcp", "serve", "seat", "release"];
20426
20429
  var DEFAULT_OUT_DIR = "assets/blender";
20427
20430
  async function writeB64(dir, name, b64) {
20428
20431
  await fs22.mkdir(dir, { recursive: true });
@@ -20453,9 +20456,47 @@ async function runBlender(opts) {
20453
20456
  return serveLocalBlender({ port, log });
20454
20457
  }
20455
20458
  if (sub === "mcp") {
20456
- const { runBlenderMcp } = await import("./blender-mcp-SBNHGDIX.js");
20459
+ const { runBlenderMcp } = await import("./blender-mcp-GMZATIZS.js");
20457
20460
  return runBlenderMcp();
20458
20461
  }
20462
+ if (sub === "seat") {
20463
+ const project = await readProject();
20464
+ const out = await acquireCliSeat({ log, scope: project?.id ?? "default" });
20465
+ if (out.kind === "granted") {
20466
+ log.success(`Blender seat ready${out.grant.kind === "cli" ? "" : " (hosted)"}: ${out.grant.url}`);
20467
+ return 0;
20468
+ }
20469
+ if (out.kind === "warming") {
20470
+ log.warn("The Blender seat is still warming up. Run the same command again in a minute.");
20471
+ return 0;
20472
+ }
20473
+ if (out.kind === "off") {
20474
+ log.error("The Blender lane is off on this stand.");
20475
+ return 1;
20476
+ }
20477
+ log.error(`No Blender seat: ${out.reason}`);
20478
+ return 1;
20479
+ }
20480
+ if (sub === "release") {
20481
+ const held = readSeatGrant();
20482
+ if (!held) {
20483
+ log.success("No Blender seat held.");
20484
+ return 0;
20485
+ }
20486
+ if (held.kind !== "cli") {
20487
+ log.error(
20488
+ "This workspace's Blender seat belongs to a hosted session \u2014 it closes with the session, not `genex blender release`."
20489
+ );
20490
+ return 1;
20491
+ }
20492
+ const out = await releaseCliSeat({});
20493
+ if (!out.ok) {
20494
+ log.error(`Could not release the seat: ${out.reason}`);
20495
+ return 1;
20496
+ }
20497
+ log.success("Blender seat released.");
20498
+ return 0;
20499
+ }
20459
20500
  let base = blenderEndpoint();
20460
20501
  const seat = await acquireSeat({ log });
20461
20502
  if (seat.kind === "granted") {
@@ -21596,8 +21637,10 @@ ${c.bold("Usage")}
21596
21637
  import <glb-url> | export --out <file.glb> | reset.
21597
21638
  Hosted sessions get a GPU seat on first use; at a
21598
21639
  terminal run 'genex blender serve' against your
21599
- own Blender. Every scene change writes
21600
- ./assets/blender/contact-sheet.webp \u2014 look at it.
21640
+ own Blender, or 'genex blender seat' to rent one on
21641
+ the platform's GPUs (your own credits, per minute \u2014
21642
+ 'genex blender release' ends it). Every scene change
21643
+ writes ./assets/blender/contact-sheet.webp \u2014 look at it.
21601
21644
  genex wait <id> Attach to a generation enqueued with --no-wait and
21602
21645
  print its asset URL(s) when it finishes. Safe to
21603
21646
  re-run \u2014 it never creates a new generation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.15.1-dev.584",
3
+ "version": "1.16.0-dev.586",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -208,10 +208,16 @@ Then colliders (`$genex-threejs-physics-rapier`), the player
208
208
 
209
209
  ## Money
210
210
 
211
- The seat bills a few credits **per minute while it is held, idle or not**, and it
212
- ends with the session — there is no close command. So build the level in one
213
- sitting: shell → zones → hero pieces → export, then move on to the game. Batch
214
- changes into fewer, larger scripts; every step renders one sheet.
211
+ A **hosted session's** seat bills a few credits per minute while it is held, idle
212
+ or not, and ends with the session — there is no close command for it. So build
213
+ the level in one sitting: shell → zones → hero pieces → export, then move on to
214
+ the game. Batch changes into fewer, larger scripts; every step renders one sheet.
215
+
216
+ At a **terminal with no Blender installed**, `npx genex blender seat` rents the
217
+ same pooled GPU on your own credits (the price and the idle timeout print on
218
+ open); it ends itself after 15 idle minutes, or run `npx genex blender release`
219
+ when you are done. Prefer `npx genex blender serve` against your own Blender
220
+ when one is available — the measurements favour it, and it costs nothing.
215
221
 
216
222
  ## Troubleshooting
217
223