@genex-ai/cli-demo 1.15.1-dev.585 → 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.
|
@@ -440,129 +440,22 @@ async function fetchSignedInEmail(apiUrl, token) {
|
|
|
440
440
|
}
|
|
441
441
|
|
|
442
442
|
// src/lib/blender-client.ts
|
|
443
|
-
import
|
|
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/
|
|
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
|
|
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
|
|
586
|
-
await
|
|
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
|
|
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
|
|
517
|
+
return path3.join(cwd, ".genex", "project.json");
|
|
625
518
|
}
|
|
626
519
|
function getWorkspacePath(cwd = process.cwd()) {
|
|
627
|
-
return
|
|
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
|
|
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
|
|
640
|
-
await
|
|
641
|
-
await
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
696
|
-
await
|
|
697
|
-
await
|
|
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-
|
|
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-
|
|
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
|
|
21600
|
-
|
|
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.
|
|
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
|
-
|
|
212
|
-
ends with the session — there is no close command. So build
|
|
213
|
-
sitting: shell → zones → hero pieces → export, then move on to
|
|
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
|
|