@genex-ai/cli-demo 1.36.13 → 1.36.14-dev.1027
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/{blender-mcp-QVEGHE5L.js → blender-mcp-WSURPZET.js} +2 -2
- package/dist/{blender-serve-66OHC4ML.js → blender-serve-EH2MOW3U.js} +1 -1
- package/dist/{chunk-4BBTPRJW.js → chunk-EABIVAGT.js} +2 -2
- package/dist/{chunk-QI7FIYBY.js → chunk-K4EHXOZK.js} +1 -1
- package/dist/index.js +2153 -626
- package/package.json +1 -1
- package/templates/skills/genex-game-director/SKILL.md +2 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-items/SKILL.md +191 -0
- package/templates/skills/genex-llm-in-games/SKILL.md +209 -72
- package/templates/skills/genex-llm-in-games/references/pricing.md +126 -85
- package/templates/skills/genex-monetization/SKILL.md +9 -10
- package/templates/skills/genex-tool-llm/SKILL.md +11 -9
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
writeSecretFile,
|
|
58
58
|
writeUserToken,
|
|
59
59
|
writeWorkspace
|
|
60
|
-
} from "./chunk-
|
|
60
|
+
} from "./chunk-EABIVAGT.js";
|
|
61
61
|
import {
|
|
62
62
|
CLI_CHANNEL,
|
|
63
63
|
DEFAULT_API_URL,
|
|
@@ -78,7 +78,7 @@ import {
|
|
|
78
78
|
normalizeApiOrigin,
|
|
79
79
|
originKey,
|
|
80
80
|
resolveAgentTargets
|
|
81
|
-
} from "./chunk-
|
|
81
|
+
} from "./chunk-K4EHXOZK.js";
|
|
82
82
|
|
|
83
83
|
// src/instrument.ts
|
|
84
84
|
import { setTimeout as sleep } from "timers/promises";
|
|
@@ -754,7 +754,7 @@ function tokenizeCommand(input) {
|
|
|
754
754
|
// src/utils/logger.ts
|
|
755
755
|
function createLogger(opts = {}) {
|
|
756
756
|
const out = (s) => {
|
|
757
|
-
if (!opts.quiet) writeSafely(process.stdout, s + "\n");
|
|
757
|
+
if (!opts.quiet) writeSafely(opts.toStderr ? process.stderr : process.stdout, s + "\n");
|
|
758
758
|
};
|
|
759
759
|
const err = (s) => {
|
|
760
760
|
writeSafely(process.stderr, s + "\n");
|
|
@@ -1134,7 +1134,7 @@ Important note: put soul into your creations, with many details and love. Aim to
|
|
|
1134
1134
|
21. A turn ends in exactly one of two ways: on a question the player must answer before the next step can be chosen, or on a handoff \u2014 the draft link, what changed, what to try, and what is still open. Never close a turn on a promise. "I'll keep building", "while I keep working", "adding that now" are things you say and then DO before you stop; if you are stopping, say that you have stopped and what remains. While the Build plan's \`Now:\` line has open work and no answer is needed from the player, the turn is not over: preview, hand off, and start the next milestone in the same turn, until the requested outcome is reached or the platform's own budget ends the session. If a stop was forced on you anyway, the first line of your next turn is the promise you left and where it stands.
|
|
1135
1135
|
22. **What the player sees must read as the thing it is**, in this game's own style: a building as that building, a person as a person, a prop as that prop, a surface as its material \u2014 and a coloured box, a capsule, or a flat grey block standing in for one of them is never its finished version. Generation is the default route to that bar wherever code will not honestly reach it: the player's character, whatever the request names, whatever the player walks up to, enters, or interacts with, and the music and sound underneath \u2014 reach for the belt on your own judgment, without stopping to ask; a status line saying what you queued is enough, and \`--no-wait\` keeps you building while it lands. Procedural code is a first-class engine for what is structural, repeated, distant, or parametric \u2014 terrain, sky, fences, paving, modular kits, filler \u2014 and for anything else only when the result meets the same bar and you have looked at it in a capture before its row says \`landed\`. Start with one reusable implementation per distinct required gameplay or visual role. Background populations default to reusable procedural bodies and motion or compatible existing rigged assets. Catalog clips need a compatible skeleton and may be paid. Reserve paid custom bodies for the player and characters examined or interacted with closely, while honoring explicit user requests. Add variants only for an unmet requirement; unspent allowance is not unfinished work. Allowance questions use timeoutPolicy no-consent: silence does not raise the allowance. Record role, reuse and completion criteria in the existing Assets table. Decide per object, not per category, and write the route on each Assets row in \`DESIGN.md\` \u2014 the paid flow for generated pieces, the procedural flow with its local path for code-built ones. An Assets table with no generation in it is a decision, not a default: record it as \`Generation: none \u2014 <why code alone reaches the bar here>\` or generate.
|
|
1136
1136
|
23. **The screenshots you take to look at your own work go in \`.genex/scratch/\`.** Captures, render comparisons, before/after strips, traces and metrics dumps are how you SEE what you built \u2014 they are not part of what you built, and \`genex preview\` pushes the whole folder to the game's source, every time, forever. \`.genex/scratch/\` already exists for this and is already ignored, so there is nothing to set up: write the capture as \`.genex/scratch/arena-before.png\` and read it back from there. Never put them in a folder of your own at the top level: \`progress/\`, \`reports/\`, \`shots/\` and their kind are pushed like source and become permanent weight in the game and in every remix of it \u2014 one real game reached 11 GB and 10,332 committed screenshots exactly this way. If you find such a folder already there, add it to \`.gitignore\`; never delete the player's files (law 18). One file in \`.genex/scratch/\` IS read, by the platform: \`.genex/scratch/cover.png\` is the game's cover \u2014 the frame you would put on its poster: real gameplay at its signature moment, well lit, no menus, popups or busy HUD, landscape 16:9, and never any text (the page sets the title beside it). The next \`genex preview\` makes it the cover; save a better moment over it whenever one comes along, and if your browser tool can set the cover itself, use that instead. Before the first publish, and whenever the game's look changes, load \`$genex-cover\`: it chooses, stages, lights and judges that frame.
|
|
1137
|
-
24. **Benchmark before declaring an in-game LLM
|
|
1137
|
+
24. **Benchmark before declaring an in-game LLM ceiling.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it from their Genex credits, billed as used, and the game declares no price, only a per-call CEILING: \`maxCredits\` on \`generate()\`, \`perCallMaxCredits\` on a standing budget. That ceiling bounds what one call may cost AND funds how long its answer may be, so never pick it by judgement or from a vendor's rate card. Run the real prompt on your own credits \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-credits <n> --user-approved\` \u2014 and declare the figures it prints, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit changes the ceiling. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate and credits per period are computed from the game's own loop and the bench's measured average. The coin spellings (\`estimateCoins\`, \`perCallMaxCoins\`) are deprecated \u2014 write the credit names. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, credits, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
|
|
1138
1138
|
${CONTRACT_END}
|
|
1139
1139
|
`;
|
|
1140
1140
|
var TOOLS_CONTRACT_HEAD = `# Genex Tools (always in effect in this folder)
|
|
@@ -1148,7 +1148,7 @@ var TOOLS_CONTRACT_OFFER = `5. **When the game is built and playable \u2014 neve
|
|
|
1148
1148
|
var TOOLS_CONTRACT_HOSTED_LAWS = `5. **This game is hosted on Genex now, and publishing is glue \u2014 never a rebuild.** The game ships exactly as the user built it: \`initEmbed()\` first in the boot code (the \`genex-threejs-embed-auth\` card has the call; the renderer draws before any \`await waitForPlayer()\`), a static build into \`dist/\` with relative asset paths, then \`npx genex preview\`. Never restructure, reformat or "improve" the game to ship it, never scaffold a new app around it, and never start a design document or a build plan for it \u2014 the user's own process is theirs. Load the \`genex-tool-publish\` card before the first preview; it owns the vocabulary, the links and the limits.
|
|
1149
1149
|
6. **Ship first, then report.** \`preview\` prints a preflight \u2014 phone memory, a missing volume slider, an asset nobody wired, a viewport line. It never blocks a deploy, so push the build as it is, hand over the link, THEN relay each preflight line to the user in one plain sentence with an offer to fix it, and fix one only on their yes. The preflight is a report for the user, not a to-do list for you.
|
|
1150
1150
|
7. **The link is the game's page, and there are two versions.** After every preview give the user \`<dashboard>/draft/<slug>\` \u2014 \`dashboardOrigins[0]\` and \`slug\` from \`.genex/project.json\` \u2014 never localhost, a file path or the bare play origin. \`preview\` updates the draft and never touches what players are on; the first release is \`npx genex publish\` (it lists the game); after that "publish it", "update it" and "yes" all mean \`npx genex promote\` \u2014 the exact draft build, no rebuild. Ask once per round of work, in one line, and keep working while you wait.`;
|
|
1151
|
-
var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with Genex
|
|
1151
|
+
var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with their Genex credits or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
|
|
1152
1152
|
function toolsContractLlmLaw(n, hosted) {
|
|
1153
1153
|
const onYes = hosted ? "On a yes, load `$genex-tool-llm` and then `$genex-llm-in-games`, which owns the build." : "On a yes, load `$genex-tool-llm`, which owns the rest: it checks the lane with `npx genex llm models` first, then runs `npx genex init --convert` (a hosted game is a static browser build \u2014 a local server holding a key can never ship).";
|
|
1154
1154
|
return `${n}. **A model running while people PLAY is a platform feature \u2014 never something you wire onto the user's own meter.** When a request implies one (NPCs that talk or decide in their own words, content written from what the player types, a prompt box in the game, "let the player pick a model"), recognise it, say in ONE line: "${TOOLS_CONTRACT_LLM_OFFER}" \u2014 then ASK and wait. ${onYes} On a no, build the authored version instead. Either way, never ship a game that calls a model on a key or an account of the user's own: every visitor would spend their money with nobody approving it, and the credential is readable in the bundle. Player-funded generation is text and JSON only \u2014 3D, images, video and audio stay the asset lanes above, on the user's meter.`;
|
|
@@ -1396,6 +1396,7 @@ var REFUSED_IN_TOOLS = /* @__PURE__ */ new Set([
|
|
|
1396
1396
|
"cover",
|
|
1397
1397
|
"make-remixable",
|
|
1398
1398
|
"shop",
|
|
1399
|
+
"items",
|
|
1399
1400
|
"domain",
|
|
1400
1401
|
"asset",
|
|
1401
1402
|
"controller"
|
|
@@ -2104,13 +2105,13 @@ async function extractSourceArchive(file, dest, rootFolder) {
|
|
|
2104
2105
|
cursor += 46 + nameLen + extraLen + commentLen;
|
|
2105
2106
|
}
|
|
2106
2107
|
if (cursor !== central.length) badArchive("Source archive directory length mismatch.");
|
|
2107
|
-
const
|
|
2108
|
+
const byName2 = new Map(entries.map((e) => [e.name.normalize("NFC").toLowerCase(), e]));
|
|
2108
2109
|
const sorted = [...entries].sort((a, b) => a.offset - b.offset);
|
|
2109
2110
|
for (let i = 0; i < sorted.length; i++) {
|
|
2110
2111
|
if (i && sorted[i].offset < sorted[i - 1].endOffset) badArchive("Source archive entries overlap.");
|
|
2111
2112
|
const parts = sorted[i].name.split("/");
|
|
2112
2113
|
for (let j = 1; j < parts.length; j++) {
|
|
2113
|
-
const parent =
|
|
2114
|
+
const parent = byName2.get(parts.slice(0, j).join("/").normalize("NFC").toLowerCase());
|
|
2114
2115
|
if (parent && !parent.dir) badArchive("Source archive file/link is used as a directory.");
|
|
2115
2116
|
}
|
|
2116
2117
|
}
|
|
@@ -3871,7 +3872,7 @@ import path17 from "path";
|
|
|
3871
3872
|
import fs14 from "fs/promises";
|
|
3872
3873
|
import path15 from "path";
|
|
3873
3874
|
var FALLBACK_SOURCE_LIMITS = {
|
|
3874
|
-
maxTotalBytes:
|
|
3875
|
+
maxTotalBytes: 5 * 1024 * 1024 * 1024,
|
|
3875
3876
|
maxFiles: 5e3
|
|
3876
3877
|
};
|
|
3877
3878
|
function isOversized(p, limits) {
|
|
@@ -7232,11 +7233,11 @@ async function childRun(cwd, command, args, timeoutMs = 3e5) {
|
|
|
7232
7233
|
env.YARN_ENABLE_IMMUTABLE_INSTALLS = "false";
|
|
7233
7234
|
const child = spawn4(command, args, { cwd, env, shell: process.platform === "win32" && ["npm", "pnpm", "yarn", "bun"].includes(command), stdio: ["ignore", "pipe", "pipe"] });
|
|
7234
7235
|
let tail = "";
|
|
7235
|
-
const
|
|
7236
|
+
const collect2 = (chunk) => {
|
|
7236
7237
|
tail = (tail + chunk.toString()).slice(-8e3);
|
|
7237
7238
|
};
|
|
7238
|
-
child.stdout.on("data",
|
|
7239
|
-
child.stderr.on("data",
|
|
7239
|
+
child.stdout.on("data", collect2);
|
|
7240
|
+
child.stderr.on("data", collect2);
|
|
7240
7241
|
const timer = setTimeout(() => {
|
|
7241
7242
|
child.kill();
|
|
7242
7243
|
reject(new RemixError("setup_timeout", `${command} timed out. The workspace is retained; retry the same remix command.`));
|
|
@@ -8553,30 +8554,14 @@ async function runDomain(opts) {
|
|
|
8553
8554
|
log.success(`${hostname} disconnected.`);
|
|
8554
8555
|
}
|
|
8555
8556
|
|
|
8556
|
-
// src/
|
|
8557
|
-
|
|
8558
|
-
function money(cents) {
|
|
8559
|
-
return cents === void 0 ? "" : ` ($${(cents / 100).toFixed(2)})`;
|
|
8560
|
-
}
|
|
8561
|
-
async function runShop(opts) {
|
|
8562
|
-
const log = createLogger({ quiet: opts.quiet });
|
|
8563
|
-
const sub = opts.name?.trim() || "list";
|
|
8564
|
-
if (!SUBS.includes(sub)) {
|
|
8565
|
-
log.error(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS.join(", ")}.`);
|
|
8566
|
-
process.exitCode = 1;
|
|
8567
|
-
return;
|
|
8568
|
-
}
|
|
8569
|
-
if (opts.type !== void 0 && opts.type !== "consumable" && opts.type !== "durable") {
|
|
8570
|
-
log.error(`Invalid --type value for shop: ${opts.type} (consumable or durable)`);
|
|
8571
|
-
process.exitCode = 1;
|
|
8572
|
-
return;
|
|
8573
|
-
}
|
|
8557
|
+
// src/lib/linked-game.ts
|
|
8558
|
+
async function openLinkedGame(opts, log, rerun) {
|
|
8574
8559
|
const meta = await readProject();
|
|
8575
8560
|
if (!meta?.id) {
|
|
8576
8561
|
log.error("This folder isn't linked to a game.");
|
|
8577
8562
|
log.dim(` Run ${c.cyan("genex link")} (or ${c.cyan("genex list")} to find the slug) first.`);
|
|
8578
8563
|
process.exitCode = 1;
|
|
8579
|
-
return;
|
|
8564
|
+
return null;
|
|
8580
8565
|
}
|
|
8581
8566
|
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
8582
8567
|
let token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
|
|
@@ -8584,7 +8569,7 @@ async function runShop(opts) {
|
|
|
8584
8569
|
if (opts.noAuth) {
|
|
8585
8570
|
log.error("Not signed in. Re-run without --no-auth to connect.");
|
|
8586
8571
|
process.exitCode = 1;
|
|
8587
|
-
return;
|
|
8572
|
+
return null;
|
|
8588
8573
|
}
|
|
8589
8574
|
log.plain("Not signed in \u2014 connecting\u2026");
|
|
8590
8575
|
try {
|
|
@@ -8597,138 +8582,1389 @@ async function runShop(opts) {
|
|
|
8597
8582
|
} catch (err) {
|
|
8598
8583
|
if (err instanceof AuthPendingError) {
|
|
8599
8584
|
printAuthHandoff(log, err);
|
|
8600
|
-
log.dim(`Then re-run ${c.cyan(
|
|
8601
|
-
return;
|
|
8585
|
+
log.dim(`Then re-run ${c.cyan(rerun)}.`);
|
|
8586
|
+
return null;
|
|
8602
8587
|
}
|
|
8603
8588
|
log.error(`Sign-in didn't complete: ${err instanceof Error ? err.message : String(err)}`);
|
|
8604
8589
|
process.exitCode = 1;
|
|
8605
|
-
return;
|
|
8590
|
+
return null;
|
|
8606
8591
|
}
|
|
8607
8592
|
await writeUserToken(token, opts.envPath, apiUrl);
|
|
8608
8593
|
}
|
|
8609
8594
|
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
8610
8595
|
const call2 = (url, init) => apiFetch(url, { ...init, headers: { ...auth, ...init?.headers ?? {} } });
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8596
|
+
return { meta, apiUrl, call: call2 };
|
|
8597
|
+
}
|
|
8598
|
+
function formatCoins(coins) {
|
|
8599
|
+
return coins.toLocaleString("en-US");
|
|
8600
|
+
}
|
|
8601
|
+
function coinPrice(coins, displayUsdCents) {
|
|
8602
|
+
if (coins === 0) return "Free";
|
|
8603
|
+
if (displayUsdCents === void 0) return `${formatCoins(coins)} coin`;
|
|
8604
|
+
const usd = (displayUsdCents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
8605
|
+
return `${formatCoins(coins)} coin ($${usd})`;
|
|
8606
|
+
}
|
|
8607
|
+
function priceGridLine(grid, freeAllowed) {
|
|
8608
|
+
return `${[...freeAllowed ? ["Free"] : [], ...grid.map(formatCoins)].join(" \xB7 ")} coin`;
|
|
8609
|
+
}
|
|
8610
|
+
|
|
8611
|
+
// src/lib/item-refusal.ts
|
|
8612
|
+
var ITEM_ERROR_KIND = "genex.item_error";
|
|
8613
|
+
var ITEM_ERROR_VERSION = 1;
|
|
8614
|
+
var ItemErrorCode = {
|
|
8615
|
+
/** A command line the command cannot run (a missing name, a flag in the wrong place). */
|
|
8616
|
+
Usage: "usage",
|
|
8617
|
+
/** A name, description or tag list over its limit, judged here or by the API's validator. */
|
|
8618
|
+
InvalidField: "invalid_field",
|
|
8619
|
+
/** The API refused with no code the CLI can read. */
|
|
8620
|
+
HttpError: "http_error",
|
|
8621
|
+
/** The game folder refused to be written through (a link or a file in the way). */
|
|
8622
|
+
FolderRefused: "folder_refused"
|
|
8623
|
+
};
|
|
8624
|
+
var ITEM_NAME_MAX = 80;
|
|
8625
|
+
var ITEM_DESCRIPTION_MAX = 1e3;
|
|
8626
|
+
var ITEM_TAGS_MAX = 10;
|
|
8627
|
+
var ITEM_TAG_LENGTH_MAX = 32;
|
|
8628
|
+
var FIELD_OF_KEY = {
|
|
8629
|
+
name: "name",
|
|
8630
|
+
description: "description",
|
|
8631
|
+
tags: "tags",
|
|
8632
|
+
priceCoins: "price",
|
|
8633
|
+
iconUrl: "picture",
|
|
8634
|
+
type: "type",
|
|
8635
|
+
shareable: "shared",
|
|
8636
|
+
resellable: "resellable"
|
|
8637
|
+
};
|
|
8638
|
+
var LABEL_OF_KEY = {
|
|
8639
|
+
priceCoins: "price",
|
|
8640
|
+
iconUrl: "picture URL (--icon)"
|
|
8641
|
+
};
|
|
8642
|
+
var formatCount = (n) => n.toLocaleString("en-US");
|
|
8643
|
+
function zodIssuesOf(body) {
|
|
8644
|
+
if (typeof body !== "object" || body === null) return null;
|
|
8645
|
+
const error = body.error;
|
|
8646
|
+
if (typeof error !== "object" || error === null) return null;
|
|
8647
|
+
const issues = error.issues;
|
|
8648
|
+
if (!Array.isArray(issues) || issues.length === 0) return null;
|
|
8649
|
+
return issues.filter((i) => typeof i === "object" && i !== null);
|
|
8650
|
+
}
|
|
8651
|
+
function issueSentence(key, index, issue) {
|
|
8652
|
+
const measured = issue.type ?? issue.origin;
|
|
8653
|
+
const limit = typeof issue.maximum === "number" ? formatCount(issue.maximum) : null;
|
|
8654
|
+
const label = LABEL_OF_KEY[key] ?? key;
|
|
8655
|
+
if (issue.code === "too_big" && limit !== null) {
|
|
8656
|
+
if (key === "tags" && typeof index === "number") return `Each tag is at most ${limit} characters (tag ${index + 1} is longer).`;
|
|
8657
|
+
if (measured === "array") return key === "tags" ? `An item has at most ${limit} tags.` : `The ${label} has at most ${limit} entries.`;
|
|
8658
|
+
if (measured === "string") return `The ${label} is too long: at most ${limit} characters.`;
|
|
8659
|
+
return `The ${label} is at most ${limit}.`;
|
|
8660
|
+
}
|
|
8661
|
+
if (issue.code === "too_small" && issue.minimum === 1 && measured === "string") {
|
|
8662
|
+
return key === "tags" ? "A tag can't be empty." : `The ${label} can't be empty.`;
|
|
8663
|
+
}
|
|
8664
|
+
const detail = typeof issue.message === "string" ? `: ${issue.message}` : ".";
|
|
8665
|
+
return `The ${label} was refused${detail}`;
|
|
8666
|
+
}
|
|
8667
|
+
function refusalFromBody(status2, body, fallback) {
|
|
8668
|
+
const typed = body ?? {};
|
|
8669
|
+
if (typeof typed.error === "string") {
|
|
8670
|
+
return {
|
|
8671
|
+
error: typed.error,
|
|
8672
|
+
message: typeof typed.message === "string" && typed.message.trim() ? typed.message : `${fallback} (${typed.error}).`,
|
|
8673
|
+
...typeof typed.field === "string" ? { field: typed.field } : {},
|
|
8674
|
+
...typeof typed.path === "string" ? { path: typed.path } : {},
|
|
8675
|
+
...typeof typed.retryAfterMs === "number" ? { retryAfterMs: typed.retryAfterMs } : {}
|
|
8676
|
+
};
|
|
8677
|
+
}
|
|
8678
|
+
const issues = zodIssuesOf(body);
|
|
8679
|
+
if (issues) {
|
|
8680
|
+
const sentences = [];
|
|
8681
|
+
let field;
|
|
8682
|
+
for (const issue of issues) {
|
|
8683
|
+
const [key, index] = Array.isArray(issue.path) ? issue.path : [];
|
|
8684
|
+
if (typeof key !== "string") continue;
|
|
8685
|
+
field ??= FIELD_OF_KEY[key] ?? key;
|
|
8686
|
+
const sentence = issueSentence(key, index, issue);
|
|
8687
|
+
if (!sentences.includes(sentence)) sentences.push(sentence);
|
|
8688
|
+
}
|
|
8689
|
+
if (sentences.length > 0) {
|
|
8690
|
+
return { error: ItemErrorCode.InvalidField, message: sentences.join(" "), ...field ? { field } : {} };
|
|
8691
|
+
}
|
|
8692
|
+
}
|
|
8693
|
+
return { error: ItemErrorCode.HttpError, message: `${fallback} (HTTP ${status2}).` };
|
|
8694
|
+
}
|
|
8695
|
+
async function bodyOf(res) {
|
|
8696
|
+
return res.json().catch(() => null);
|
|
8697
|
+
}
|
|
8698
|
+
function storedTags(tags2) {
|
|
8699
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8700
|
+
for (const tag of tags2) {
|
|
8701
|
+
const stored = tag.trim().replace(/\s+/g, " ").toLowerCase();
|
|
8702
|
+
if (stored) seen.add(stored);
|
|
8703
|
+
}
|
|
8704
|
+
return [...seen];
|
|
8705
|
+
}
|
|
8706
|
+
function checkItemWords(words2) {
|
|
8707
|
+
const invalid2 = (field, message) => ({ error: ItemErrorCode.InvalidField, field, message });
|
|
8708
|
+
const name = words2.name?.trim();
|
|
8709
|
+
if (name !== void 0 && name.length > ITEM_NAME_MAX) {
|
|
8710
|
+
return invalid2("name", `The name is too long: at most ${formatCount(ITEM_NAME_MAX)} characters (it has ${formatCount(name.length)}).`);
|
|
8711
|
+
}
|
|
8712
|
+
const description = words2.description?.trim();
|
|
8713
|
+
if (description !== void 0 && description.length > ITEM_DESCRIPTION_MAX) {
|
|
8714
|
+
return invalid2(
|
|
8715
|
+
"description",
|
|
8716
|
+
`The description is too long: at most ${formatCount(ITEM_DESCRIPTION_MAX)} characters (it has ${formatCount(description.length)}).`
|
|
8717
|
+
);
|
|
8718
|
+
}
|
|
8719
|
+
if (!words2.tags) return null;
|
|
8720
|
+
const tags2 = storedTags(words2.tags);
|
|
8721
|
+
if (tags2.length > ITEM_TAGS_MAX) {
|
|
8722
|
+
return invalid2("tags", `An item has at most ${formatCount(ITEM_TAGS_MAX)} tags (this one has ${formatCount(tags2.length)}).`);
|
|
8723
|
+
}
|
|
8724
|
+
const long = tags2.find((tag) => tag.length > ITEM_TAG_LENGTH_MAX);
|
|
8725
|
+
if (long !== void 0) {
|
|
8726
|
+
return invalid2("tags", `Each tag is at most ${formatCount(ITEM_TAG_LENGTH_MAX)} characters ("${long.slice(0, 40)}${long.length > 40 ? "\u2026" : ""}" has ${formatCount(long.length)}).`);
|
|
8727
|
+
}
|
|
8728
|
+
return null;
|
|
8729
|
+
}
|
|
8730
|
+
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
8731
|
+
function itemErrorDocument(refusal2) {
|
|
8732
|
+
const plain = (text) => text.replace(ANSI, "").trim();
|
|
8733
|
+
const hint = refusal2.hints?.map(plain).filter(Boolean).join("\n");
|
|
8734
|
+
return {
|
|
8735
|
+
kind: ITEM_ERROR_KIND,
|
|
8736
|
+
version: ITEM_ERROR_VERSION,
|
|
8737
|
+
error: refusal2.error,
|
|
8738
|
+
...refusal2.field !== void 0 ? { field: refusal2.field } : {},
|
|
8739
|
+
...refusal2.path !== void 0 ? { path: refusal2.path } : {},
|
|
8740
|
+
message: plain(refusal2.message),
|
|
8741
|
+
...refusal2.id !== void 0 ? { id: refusal2.id } : {},
|
|
8742
|
+
...refusal2.retryAfterMs !== void 0 ? { retryAfterMs: refusal2.retryAfterMs } : {},
|
|
8743
|
+
...hint ? { hint } : {}
|
|
8744
|
+
};
|
|
8745
|
+
}
|
|
8746
|
+
function reportItemRefusal(log, json, refusal2, printed = false) {
|
|
8747
|
+
process.exitCode = 1;
|
|
8748
|
+
if (!printed) {
|
|
8749
|
+
const code = refusal2.error === ItemErrorCode.Usage ? "" : ` ${c.dim(`[${refusal2.error}]`)}`;
|
|
8750
|
+
log.error(`${refusal2.message}${code}`);
|
|
8751
|
+
for (const line of refusal2.hints ?? []) writeSafely(process.stderr, `${c.dim(` ${line}`)}
|
|
8752
|
+
`);
|
|
8753
|
+
}
|
|
8754
|
+
if (json) writeSafely(process.stdout, `${JSON.stringify(itemErrorDocument(refusal2))}
|
|
8755
|
+
`);
|
|
8756
|
+
}
|
|
8757
|
+
|
|
8758
|
+
// src/lib/item-media.ts
|
|
8759
|
+
import fs24 from "fs/promises";
|
|
8760
|
+
var MIB = 1024 * 1024;
|
|
8761
|
+
var MediaKind = { Picture: "picture", Model: "model" };
|
|
8762
|
+
var MediaContentType = {
|
|
8763
|
+
Png: "image/png",
|
|
8764
|
+
Webp: "image/webp",
|
|
8765
|
+
Glb: "model/gltf-binary",
|
|
8766
|
+
Zip: "application/zip"
|
|
8767
|
+
};
|
|
8768
|
+
var MEDIA_BYTES_MAX = { picture: 2 * MIB, model: 20 * MIB };
|
|
8769
|
+
var MEDIA_FLAG = { picture: "--image", model: "--model" };
|
|
8770
|
+
var ItemMediaError = class extends Error {
|
|
8771
|
+
};
|
|
8772
|
+
var startsWith = (bytes, ascii, at = 0) => bytes.length >= at + ascii.length && bytes.subarray(at, at + ascii.length).toString("latin1") === ascii;
|
|
8773
|
+
function pictureType(bytes) {
|
|
8774
|
+
if (startsWith(bytes, "\x89PNG\r\n\n")) return MediaContentType.Png;
|
|
8775
|
+
if (startsWith(bytes, "RIFF") && startsWith(bytes, "WEBP", 8)) return MediaContentType.Webp;
|
|
8776
|
+
return null;
|
|
8777
|
+
}
|
|
8778
|
+
function modelType(bytes) {
|
|
8779
|
+
return startsWith(bytes, "glTF") ? MediaContentType.Glb : null;
|
|
8780
|
+
}
|
|
8781
|
+
async function readItemMedia(kind, file) {
|
|
8782
|
+
const flag = MEDIA_FLAG[kind];
|
|
8783
|
+
if (/^https?:\/\//i.test(file)) {
|
|
8784
|
+
throw new ItemMediaError(
|
|
8785
|
+
kind === MediaKind.Picture ? `${flag} takes a file on this machine. For a picture that is already online, pass --icon <url>.` : `${flag} takes a .glb file on this machine.`
|
|
8786
|
+
);
|
|
8787
|
+
}
|
|
8788
|
+
let size;
|
|
8789
|
+
try {
|
|
8790
|
+
const stat = await fs24.stat(file);
|
|
8791
|
+
if (!stat.isFile()) throw new Error("not a file");
|
|
8792
|
+
size = stat.size;
|
|
8793
|
+
} catch {
|
|
8794
|
+
throw new ItemMediaError(`${file} isn't a file (${flag}).`);
|
|
8795
|
+
}
|
|
8796
|
+
const max = MEDIA_BYTES_MAX[kind];
|
|
8797
|
+
if (size > max) {
|
|
8798
|
+
throw new ItemMediaError(`${file} is ${Math.ceil(size / MIB * 10) / 10} MB; ${kind === MediaKind.Picture ? "a picture" : "a model"} may be at most ${max / MIB} MB.`);
|
|
8799
|
+
}
|
|
8800
|
+
const bytes = await fs24.readFile(file);
|
|
8801
|
+
const contentType = kind === MediaKind.Picture ? pictureType(bytes) : modelType(bytes);
|
|
8802
|
+
if (!contentType) {
|
|
8803
|
+
throw new ItemMediaError(
|
|
8804
|
+
kind === MediaKind.Picture ? `${file} isn't a PNG or WebP picture. Render the item on a transparent background and save it as PNG.` : `${file} isn't a binary glTF model (.glb).`
|
|
8805
|
+
);
|
|
8806
|
+
}
|
|
8807
|
+
return { kind, file, bytes, contentType };
|
|
8808
|
+
}
|
|
8809
|
+
|
|
8810
|
+
// src/lib/item-package.ts
|
|
8811
|
+
import { createHash } from "crypto";
|
|
8812
|
+
import { constants as fsConstants } from "fs";
|
|
8813
|
+
import fs25 from "fs/promises";
|
|
8814
|
+
import path24 from "path";
|
|
8815
|
+
import zlib from "zlib";
|
|
8816
|
+
var MIB2 = 1024 * 1024;
|
|
8817
|
+
var ITEM_PACKAGE_BYTES_MAX = 50 * MIB2;
|
|
8818
|
+
var ITEM_PACKAGE_FILES_MAX = 2e3;
|
|
8819
|
+
var ITEM_PACKAGE_UNPACKED_BYTES_MAX = 200 * MIB2;
|
|
8820
|
+
var PackSkip = {
|
|
8821
|
+
/** Git metadata, `node_modules`, `.genex/`, and secrets (`.env`, keys, `.npmrc`). */
|
|
8822
|
+
Excluded: "excluded",
|
|
8823
|
+
/** Never followed, wherever it points. */
|
|
8824
|
+
Symlink: "symlink",
|
|
8825
|
+
/** A socket, FIFO or device. */
|
|
8826
|
+
NotAFile: "not_a_file",
|
|
8827
|
+
/** Bigger than a whole package may be. */
|
|
8828
|
+
TooLarge: "too_large",
|
|
8829
|
+
/** A name another system could not write back safely (a backslash, `:`, a reserved name). */
|
|
8830
|
+
UnsafeName: "unsafe_name",
|
|
8831
|
+
/** A top-level `item.json`: `genex items add` writes its own there ({@link ITEM_FILE_NAME}). */
|
|
8832
|
+
Reserved: "reserved",
|
|
8833
|
+
/** A `genex-items/` folder: other creators' items, for integration in this game only (plan 8.1). */
|
|
8834
|
+
SharedItem: "shared_item"
|
|
8835
|
+
};
|
|
8836
|
+
var PackageErrorCode = {
|
|
8837
|
+
DirMissing: "package_dir_missing",
|
|
8838
|
+
Empty: "package_empty",
|
|
8839
|
+
TooLarge: "package_too_large",
|
|
8840
|
+
TooManyFiles: "package_too_many_files",
|
|
8841
|
+
/** The archive is unsafe or malformed; `path` names the entry when there is one. */
|
|
8842
|
+
Invalid: "package_invalid",
|
|
8843
|
+
/** The archive is well formed but is not what the API recorded for it. */
|
|
8844
|
+
Mismatch: "package_mismatch",
|
|
8845
|
+
DestinationExists: "package_destination_exists",
|
|
8846
|
+
/** The signed link could not be read (network, expiry, an HTTP error). */
|
|
8847
|
+
DownloadFailed: "package_download_failed"
|
|
8848
|
+
};
|
|
8849
|
+
var ItemPackageError = class extends Error {
|
|
8850
|
+
code;
|
|
8851
|
+
/** The archive path at fault, when there is one. */
|
|
8852
|
+
entryPath;
|
|
8853
|
+
constructor(code, message, entryPath) {
|
|
8854
|
+
super(message);
|
|
8855
|
+
this.code = code;
|
|
8856
|
+
this.entryPath = entryPath;
|
|
8857
|
+
}
|
|
8858
|
+
};
|
|
8859
|
+
var ITEM_FILE_NAME = "item.json";
|
|
8860
|
+
var SHARED_ITEMS_DIR = "genex-items";
|
|
8861
|
+
function isReservedPath(relPath) {
|
|
8862
|
+
const top = relPath.split("/")[0] ?? "";
|
|
8863
|
+
return top.normalize("NFC").toLowerCase() === ITEM_FILE_NAME;
|
|
8864
|
+
}
|
|
8865
|
+
function packExclusion(relPath, name) {
|
|
8866
|
+
if (excludedSourcePath(relPath)) return PackSkip.Excluded;
|
|
8867
|
+
if (name.toLowerCase() === SHARED_ITEMS_DIR) return PackSkip.SharedItem;
|
|
8868
|
+
if (!relPath.includes("/") && isReservedPath(relPath)) return PackSkip.Reserved;
|
|
8869
|
+
return null;
|
|
8870
|
+
}
|
|
8871
|
+
async function packItemDirectory(dir) {
|
|
8872
|
+
const root = await realDirectory(dir);
|
|
8873
|
+
const collected = { files: [], skipped: [] };
|
|
8874
|
+
await collect(root, root, "", collected);
|
|
8875
|
+
if (collected.files.length === 0) {
|
|
8876
|
+
throw new ItemPackageError(PackageErrorCode.Empty, `${dir} has no files to package.`);
|
|
8877
|
+
}
|
|
8878
|
+
const zip = writeZip(collected.files);
|
|
8879
|
+
if (zip.length > ITEM_PACKAGE_BYTES_MAX) {
|
|
8880
|
+
throw new ItemPackageError(
|
|
8881
|
+
PackageErrorCode.TooLarge,
|
|
8882
|
+
`The package is ${megabytes(zip.length)} zipped; a package may be at most ${megabytes(ITEM_PACKAGE_BYTES_MAX)}.`
|
|
8883
|
+
);
|
|
8884
|
+
}
|
|
8885
|
+
return {
|
|
8886
|
+
zip,
|
|
8887
|
+
files: collected.files.map((f) => ({ path: f.path, bytes: f.data.length })),
|
|
8888
|
+
skipped: collected.skipped
|
|
8889
|
+
};
|
|
8890
|
+
}
|
|
8891
|
+
async function realDirectory(dir) {
|
|
8892
|
+
try {
|
|
8893
|
+
const root = await fs25.realpath(dir);
|
|
8894
|
+
if ((await fs25.stat(root)).isDirectory()) return root;
|
|
8895
|
+
} catch {
|
|
8896
|
+
}
|
|
8897
|
+
throw new ItemPackageError(PackageErrorCode.DirMissing, `${dir} is not a folder.`);
|
|
8898
|
+
}
|
|
8899
|
+
async function insideRoot(root, candidate) {
|
|
8900
|
+
try {
|
|
8901
|
+
const real = await fs25.realpath(candidate);
|
|
8902
|
+
return real === root || real.startsWith(root + path24.sep);
|
|
8903
|
+
} catch {
|
|
8904
|
+
return false;
|
|
8905
|
+
}
|
|
8906
|
+
}
|
|
8907
|
+
function portableName(relPath) {
|
|
8908
|
+
try {
|
|
8909
|
+
validateArchivePath(relPath);
|
|
8910
|
+
return true;
|
|
8911
|
+
} catch (err) {
|
|
8912
|
+
if (err instanceof RemixError) return false;
|
|
8913
|
+
throw err;
|
|
8914
|
+
}
|
|
8915
|
+
}
|
|
8916
|
+
var byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
8917
|
+
async function collect(root, abs, rel, out) {
|
|
8918
|
+
const entries = (await fs25.readdir(abs, { withFileTypes: true })).sort(byName);
|
|
8919
|
+
for (const entry of entries) {
|
|
8920
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
8921
|
+
const full = path24.join(abs, entry.name);
|
|
8922
|
+
const excluded = packExclusion(relPath, entry.name);
|
|
8923
|
+
if (excluded) {
|
|
8924
|
+
out.skipped.push({ path: relPath, reason: excluded });
|
|
8925
|
+
continue;
|
|
8616
8926
|
}
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8927
|
+
if (entry.isSymbolicLink()) {
|
|
8928
|
+
out.skipped.push({ path: relPath, reason: PackSkip.Symlink });
|
|
8929
|
+
continue;
|
|
8930
|
+
}
|
|
8931
|
+
if (entry.isDirectory()) {
|
|
8932
|
+
if (await insideRoot(root, full)) await collect(root, full, relPath, out);
|
|
8933
|
+
else out.skipped.push({ path: relPath, reason: PackSkip.Symlink });
|
|
8934
|
+
continue;
|
|
8622
8935
|
}
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8936
|
+
if (!entry.isFile()) {
|
|
8937
|
+
out.skipped.push({ path: relPath, reason: PackSkip.NotAFile });
|
|
8938
|
+
continue;
|
|
8626
8939
|
}
|
|
8627
|
-
if (
|
|
8628
|
-
|
|
8940
|
+
if (!portableName(relPath)) {
|
|
8941
|
+
out.skipped.push({ path: relPath, reason: PackSkip.UnsafeName });
|
|
8942
|
+
continue;
|
|
8943
|
+
}
|
|
8944
|
+
const read = await readRegularFile(root, full);
|
|
8945
|
+
if (!Buffer.isBuffer(read)) {
|
|
8946
|
+
out.skipped.push({ path: relPath, reason: read });
|
|
8947
|
+
continue;
|
|
8948
|
+
}
|
|
8949
|
+
out.files.push({ path: relPath, data: read });
|
|
8950
|
+
if (out.files.length > ITEM_PACKAGE_FILES_MAX) {
|
|
8951
|
+
throw new ItemPackageError(
|
|
8952
|
+
PackageErrorCode.TooManyFiles,
|
|
8953
|
+
`The folder holds more than ${ITEM_PACKAGE_FILES_MAX} files; a package may hold at most that many.`
|
|
8954
|
+
);
|
|
8629
8955
|
}
|
|
8630
8956
|
}
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8957
|
+
}
|
|
8958
|
+
async function readRegularFile(root, full) {
|
|
8959
|
+
if (!await insideRoot(root, path24.dirname(full))) return PackSkip.Symlink;
|
|
8960
|
+
let handle;
|
|
8961
|
+
try {
|
|
8962
|
+
handle = await fs25.open(full, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
8963
|
+
} catch {
|
|
8964
|
+
return PackSkip.Symlink;
|
|
8965
|
+
}
|
|
8966
|
+
try {
|
|
8967
|
+
const stat = await handle.stat();
|
|
8968
|
+
if (!stat.isFile()) return PackSkip.NotAFile;
|
|
8969
|
+
if (stat.size > ITEM_PACKAGE_BYTES_MAX) return PackSkip.TooLarge;
|
|
8970
|
+
const data = Buffer.alloc(stat.size);
|
|
8971
|
+
let filled = 0;
|
|
8972
|
+
while (filled < data.length) {
|
|
8973
|
+
const { bytesRead } = await handle.read(data, filled, data.length - filled, filled);
|
|
8974
|
+
if (bytesRead === 0) break;
|
|
8975
|
+
filled += bytesRead;
|
|
8976
|
+
}
|
|
8977
|
+
return data.subarray(0, filled);
|
|
8978
|
+
} finally {
|
|
8979
|
+
await handle.close();
|
|
8980
|
+
}
|
|
8981
|
+
}
|
|
8982
|
+
var LOCAL_SIGNATURE = 67324752;
|
|
8983
|
+
var CENTRAL_SIGNATURE = 33639248;
|
|
8984
|
+
var EOCD_SIGNATURE = 101010256;
|
|
8985
|
+
var DESCRIPTOR_SIGNATURE = 134695760;
|
|
8986
|
+
var LOCAL_HEADER_BYTES = 30;
|
|
8987
|
+
var CENTRAL_HEADER_BYTES = 46;
|
|
8988
|
+
var EOCD_BYTES = 22;
|
|
8989
|
+
var COMMENT_MAX = 65535;
|
|
8990
|
+
var METHOD_STORED = 0;
|
|
8991
|
+
var METHOD_DEFLATE = 8;
|
|
8992
|
+
var FLAG_ENCRYPTED = 1;
|
|
8993
|
+
var FLAG_DESCRIPTOR = 8;
|
|
8994
|
+
var FLAG_STRONG_ENCRYPTION = 64;
|
|
8995
|
+
var FLAG_UTF8 = 2048;
|
|
8996
|
+
var FLAG_MASKED_HEADER = 8192;
|
|
8997
|
+
var UNIX_HOST = 3;
|
|
8998
|
+
var MACOS_HOST = 19;
|
|
8999
|
+
var VERSION = 20;
|
|
9000
|
+
var DOS_DATE = 1 << 5 | 1;
|
|
9001
|
+
var S_IFMT = 61440;
|
|
9002
|
+
var S_IFREG = 32768;
|
|
9003
|
+
var S_IFDIR = 16384;
|
|
9004
|
+
var FILE_MODE = 420;
|
|
9005
|
+
function writeZip(files) {
|
|
9006
|
+
const parts = [];
|
|
9007
|
+
const central = [];
|
|
9008
|
+
let offset = 0;
|
|
9009
|
+
for (const file of files) {
|
|
9010
|
+
const name = Buffer.from(file.path, "utf8");
|
|
9011
|
+
const crc = sourceCrc32(file.data);
|
|
9012
|
+
const deflated = zlib.deflateRawSync(file.data, { level: 9 });
|
|
9013
|
+
const stored = deflated.length >= file.data.length;
|
|
9014
|
+
const body = stored ? file.data : deflated;
|
|
9015
|
+
const method = stored ? METHOD_STORED : METHOD_DEFLATE;
|
|
9016
|
+
const local = Buffer.alloc(LOCAL_HEADER_BYTES);
|
|
9017
|
+
local.writeUInt32LE(LOCAL_SIGNATURE, 0);
|
|
9018
|
+
local.writeUInt16LE(VERSION, 4);
|
|
9019
|
+
local.writeUInt16LE(FLAG_UTF8, 6);
|
|
9020
|
+
local.writeUInt16LE(method, 8);
|
|
9021
|
+
local.writeUInt16LE(0, 10);
|
|
9022
|
+
local.writeUInt16LE(DOS_DATE, 12);
|
|
9023
|
+
local.writeUInt32LE(crc, 14);
|
|
9024
|
+
local.writeUInt32LE(body.length, 18);
|
|
9025
|
+
local.writeUInt32LE(file.data.length, 22);
|
|
9026
|
+
local.writeUInt16LE(name.length, 26);
|
|
9027
|
+
local.writeUInt16LE(0, 28);
|
|
9028
|
+
const entry = Buffer.alloc(CENTRAL_HEADER_BYTES);
|
|
9029
|
+
entry.writeUInt32LE(CENTRAL_SIGNATURE, 0);
|
|
9030
|
+
entry.writeUInt16LE(UNIX_HOST << 8 | VERSION, 4);
|
|
9031
|
+
entry.writeUInt16LE(VERSION, 6);
|
|
9032
|
+
entry.writeUInt16LE(FLAG_UTF8, 8);
|
|
9033
|
+
entry.writeUInt16LE(method, 10);
|
|
9034
|
+
entry.writeUInt16LE(0, 12);
|
|
9035
|
+
entry.writeUInt16LE(DOS_DATE, 14);
|
|
9036
|
+
entry.writeUInt32LE(crc, 16);
|
|
9037
|
+
entry.writeUInt32LE(body.length, 20);
|
|
9038
|
+
entry.writeUInt32LE(file.data.length, 24);
|
|
9039
|
+
entry.writeUInt16LE(name.length, 28);
|
|
9040
|
+
entry.writeUInt32LE((S_IFREG | FILE_MODE) << 16 >>> 0, 38);
|
|
9041
|
+
entry.writeUInt32LE(offset, 42);
|
|
9042
|
+
parts.push(local, name, body);
|
|
9043
|
+
central.push(entry, name);
|
|
9044
|
+
offset += local.length + name.length + body.length;
|
|
9045
|
+
}
|
|
9046
|
+
const directory = Buffer.concat(central);
|
|
9047
|
+
const end = Buffer.alloc(EOCD_BYTES);
|
|
9048
|
+
end.writeUInt32LE(EOCD_SIGNATURE, 0);
|
|
9049
|
+
end.writeUInt16LE(files.length, 8);
|
|
9050
|
+
end.writeUInt16LE(files.length, 10);
|
|
9051
|
+
end.writeUInt32LE(directory.length, 12);
|
|
9052
|
+
end.writeUInt32LE(offset, 16);
|
|
9053
|
+
return Buffer.concat([...parts, directory, end]);
|
|
9054
|
+
}
|
|
9055
|
+
function invalid(message, entryPath) {
|
|
9056
|
+
throw new ItemPackageError(PackageErrorCode.Invalid, message, entryPath);
|
|
9057
|
+
}
|
|
9058
|
+
function findDirectory(zip) {
|
|
9059
|
+
for (let at = zip.length - EOCD_BYTES; at >= Math.max(0, zip.length - EOCD_BYTES - COMMENT_MAX); at--) {
|
|
9060
|
+
if (zip.readUInt32LE(at) !== EOCD_SIGNATURE) continue;
|
|
9061
|
+
if (at + EOCD_BYTES + zip.readUInt16LE(at + 20) !== zip.length) continue;
|
|
9062
|
+
const count = zip.readUInt16LE(at + 10);
|
|
9063
|
+
const bytes = zip.readUInt32LE(at + 12);
|
|
9064
|
+
const offset = zip.readUInt32LE(at + 16);
|
|
9065
|
+
const singleDisk = zip.readUInt16LE(at + 4) === 0 && zip.readUInt16LE(at + 6) === 0 && zip.readUInt16LE(at + 8) === count;
|
|
9066
|
+
if (!singleDisk) invalid("The package spans several disks.");
|
|
9067
|
+
if (count === 65535 || bytes === 4294967295 || offset === 4294967295) invalid("The package uses ZIP64, which a package never needs.");
|
|
9068
|
+
if (offset + bytes !== at) invalid("The package's directory is not where its end record says.");
|
|
9069
|
+
return { offset, bytes, count, end: at };
|
|
9070
|
+
}
|
|
9071
|
+
return invalid("The package is not a zip archive.");
|
|
9072
|
+
}
|
|
9073
|
+
function readEntries(zip) {
|
|
9074
|
+
if (zip.length < EOCD_BYTES) invalid("The package is not a zip archive.");
|
|
9075
|
+
if (zip.length > ITEM_PACKAGE_BYTES_MAX) {
|
|
9076
|
+
throw new ItemPackageError(PackageErrorCode.TooLarge, `The package is over ${megabytes(ITEM_PACKAGE_BYTES_MAX)}.`);
|
|
9077
|
+
}
|
|
9078
|
+
const directory = findDirectory(zip);
|
|
9079
|
+
if (directory.count > ITEM_PACKAGE_FILES_MAX * 2) invalid("The package holds too many entries.");
|
|
9080
|
+
const entries = [];
|
|
9081
|
+
const seen = /* @__PURE__ */ new Map();
|
|
9082
|
+
let cursor = directory.offset;
|
|
9083
|
+
let unpacked = 0;
|
|
9084
|
+
for (let i = 0; i < directory.count; i++) {
|
|
9085
|
+
const entry = readEntry(zip, cursor, directory.offset, directory.end);
|
|
9086
|
+
cursor = entry.next;
|
|
9087
|
+
const key = entry.value.path.normalize("NFC").toLowerCase();
|
|
9088
|
+
if (seen.has(key)) invalid(`The package holds ${entry.value.path} twice (names that differ only in case collide on disk).`, entry.value.path);
|
|
9089
|
+
seen.set(key, entry.value.dir);
|
|
9090
|
+
unpacked += entry.value.size;
|
|
9091
|
+
if (unpacked > ITEM_PACKAGE_UNPACKED_BYTES_MAX) invalid(`The package unpacks to more than ${megabytes(ITEM_PACKAGE_UNPACKED_BYTES_MAX)}.`);
|
|
9092
|
+
entries.push(entry.value);
|
|
9093
|
+
}
|
|
9094
|
+
if (cursor !== directory.end) invalid("The package's directory has trailing bytes.");
|
|
9095
|
+
if (entries.filter((e) => !e.dir).length > ITEM_PACKAGE_FILES_MAX) invalid(`The package holds more than ${ITEM_PACKAGE_FILES_MAX} files.`);
|
|
9096
|
+
for (const entry of entries) {
|
|
9097
|
+
const parts = entry.path.normalize("NFC").toLowerCase().split("/");
|
|
9098
|
+
for (let depth = 1; depth < parts.length; depth++) {
|
|
9099
|
+
if (seen.get(parts.slice(0, depth).join("/")) === false) invalid(`The package uses the file ${parts.slice(0, depth).join("/")} as a folder.`, entry.path);
|
|
9100
|
+
}
|
|
9101
|
+
}
|
|
9102
|
+
const byOffset = [...entries].sort((a, b) => a.dataOffset - b.dataOffset);
|
|
9103
|
+
for (let i = 1; i < byOffset.length; i++) {
|
|
9104
|
+
const previous = byOffset[i - 1];
|
|
9105
|
+
const current = byOffset[i];
|
|
9106
|
+
if (previous && current && current.dataOffset < previous.dataEnd) invalid("The package's entries overlap.", current.path);
|
|
9107
|
+
}
|
|
9108
|
+
return entries;
|
|
9109
|
+
}
|
|
9110
|
+
function readEntry(zip, at, directoryStart, directoryEnd) {
|
|
9111
|
+
if (at + CENTRAL_HEADER_BYTES > directoryEnd || zip.readUInt32LE(at) !== CENTRAL_SIGNATURE) invalid("The package's directory is damaged.");
|
|
9112
|
+
const madeBy = zip.readUInt16LE(at + 4) >>> 8;
|
|
9113
|
+
const flags = zip.readUInt16LE(at + 8);
|
|
9114
|
+
const method = zip.readUInt16LE(at + 10);
|
|
9115
|
+
const crc = zip.readUInt32LE(at + 16);
|
|
9116
|
+
const compressedSize = zip.readUInt32LE(at + 20);
|
|
9117
|
+
const size = zip.readUInt32LE(at + 24);
|
|
9118
|
+
const nameLength = zip.readUInt16LE(at + 28);
|
|
9119
|
+
const extraLength = zip.readUInt16LE(at + 30);
|
|
9120
|
+
const commentLength = zip.readUInt16LE(at + 32);
|
|
9121
|
+
const external = zip.readUInt32LE(at + 38);
|
|
9122
|
+
const localOffset = zip.readUInt32LE(at + 42);
|
|
9123
|
+
const next = at + CENTRAL_HEADER_BYTES + nameLength + extraLength + commentLength;
|
|
9124
|
+
if (next > directoryEnd) invalid("The package's directory is damaged.");
|
|
9125
|
+
const rawName = zip.subarray(at + CENTRAL_HEADER_BYTES, at + CENTRAL_HEADER_BYTES + nameLength).toString("utf8");
|
|
9126
|
+
let name;
|
|
9127
|
+
try {
|
|
9128
|
+
name = validateArchivePath(rawName);
|
|
9129
|
+
} catch (err) {
|
|
9130
|
+
if (err instanceof RemixError) invalid(`The package holds an unsafe path: ${JSON.stringify(rawName)}.`, rawName);
|
|
9131
|
+
throw err;
|
|
9132
|
+
}
|
|
9133
|
+
if (isReservedPath(name)) invalid(`The package holds its own ${ITEM_FILE_NAME}, which genex writes itself.`, name);
|
|
9134
|
+
if (flags & (FLAG_ENCRYPTED | FLAG_STRONG_ENCRYPTION | FLAG_MASKED_HEADER)) invalid(`${name} is encrypted.`, name);
|
|
9135
|
+
if (method !== METHOD_STORED && method !== METHOD_DEFLATE) invalid(`${name} uses a compression a package never needs.`, name);
|
|
9136
|
+
const mode = madeBy === UNIX_HOST || madeBy === MACOS_HOST ? external >>> 16 & S_IFMT : 0;
|
|
9137
|
+
const dir = rawName.endsWith("/") || mode === S_IFDIR;
|
|
9138
|
+
if (mode !== 0 && mode !== S_IFREG && mode !== S_IFDIR) invalid(`${name} is a link or a special file, not a plain file.`, name);
|
|
9139
|
+
if (dir && size !== 0) invalid(`The folder ${name} carries data.`, name);
|
|
9140
|
+
if (size > ITEM_PACKAGE_BYTES_MAX) invalid(`${name} is too large.`, name);
|
|
9141
|
+
if (method === METHOD_STORED && compressedSize !== size) invalid(`${name} has inconsistent sizes.`, name);
|
|
9142
|
+
if (localOffset + LOCAL_HEADER_BYTES > directoryStart || zip.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) invalid(`${name} has no local header.`, name);
|
|
9143
|
+
const localNameLength = zip.readUInt16LE(localOffset + 26);
|
|
9144
|
+
const localExtraLength = zip.readUInt16LE(localOffset + 28);
|
|
9145
|
+
const localName = zip.subarray(localOffset + LOCAL_HEADER_BYTES, localOffset + LOCAL_HEADER_BYTES + localNameLength).toString("utf8");
|
|
9146
|
+
if (localName !== rawName || zip.readUInt16LE(localOffset + 8) !== method) invalid(`${name}'s local header disagrees with the directory.`, name);
|
|
9147
|
+
const dataOffset = localOffset + LOCAL_HEADER_BYTES + localNameLength + localExtraLength;
|
|
9148
|
+
const dataEnd = dataOffset + compressedSize + (flags & FLAG_DESCRIPTOR ? 16 : 0);
|
|
9149
|
+
if (dataEnd > directoryStart) invalid(`${name} is truncated.`, name);
|
|
9150
|
+
if (flags & FLAG_DESCRIPTOR && zip.readUInt32LE(dataOffset + compressedSize) !== DESCRIPTOR_SIGNATURE) {
|
|
9151
|
+
invalid(`${name}'s data descriptor is damaged.`, name);
|
|
9152
|
+
}
|
|
9153
|
+
return { value: { path: name, dir, method, crc, compressedSize, size, dataOffset, dataEnd }, next };
|
|
9154
|
+
}
|
|
9155
|
+
function entryBytes(zip, entry) {
|
|
9156
|
+
const raw = zip.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize);
|
|
9157
|
+
let data;
|
|
9158
|
+
if (entry.method === METHOD_STORED) data = raw;
|
|
9159
|
+
else {
|
|
9160
|
+
try {
|
|
9161
|
+
data = zlib.inflateRawSync(raw, { maxOutputLength: entry.size + 1 });
|
|
9162
|
+
} catch {
|
|
9163
|
+
return invalid(`${entry.path} does not decompress to its declared size.`, entry.path);
|
|
9164
|
+
}
|
|
9165
|
+
}
|
|
9166
|
+
if (data.length !== entry.size) invalid(`${entry.path} does not decompress to its declared size.`, entry.path);
|
|
9167
|
+
if (sourceCrc32(data) !== entry.crc) invalid(`${entry.path} fails its checksum.`, entry.path);
|
|
9168
|
+
return data;
|
|
9169
|
+
}
|
|
9170
|
+
var sha2562 = (data) => createHash("sha256").update(data).digest("hex");
|
|
9171
|
+
function checkAgainstManifest(zip, files, manifest) {
|
|
9172
|
+
const expected = new Map(manifest.files.map((f) => [f.path, f]));
|
|
9173
|
+
if (expected.size !== manifest.files.length) throw new ItemPackageError(PackageErrorCode.Mismatch, "The package's manifest lists a file twice.");
|
|
9174
|
+
for (const file of files) {
|
|
9175
|
+
const want = expected.get(file.path);
|
|
9176
|
+
if (!want) throw new ItemPackageError(PackageErrorCode.Mismatch, `${file.path} is in the package but not in its manifest.`, file.path);
|
|
9177
|
+
if (want.bytes !== file.size || want.sha256.toLowerCase() !== sha2562(entryBytes(zip, file))) {
|
|
9178
|
+
throw new ItemPackageError(PackageErrorCode.Mismatch, `${file.path} is not the file the manifest recorded.`, file.path);
|
|
9179
|
+
}
|
|
9180
|
+
expected.delete(file.path);
|
|
9181
|
+
}
|
|
9182
|
+
const missing = [...expected.keys()];
|
|
9183
|
+
if (missing.length > 0) throw new ItemPackageError(PackageErrorCode.Mismatch, `The package is missing ${missing[0]}, which its manifest lists.`, missing[0]);
|
|
9184
|
+
}
|
|
9185
|
+
async function extractItemPackage(zip, dest, manifest) {
|
|
9186
|
+
const entries = readEntries(zip);
|
|
9187
|
+
const files = entries.filter((e) => !e.dir);
|
|
9188
|
+
for (const file of files) entryBytes(zip, file);
|
|
9189
|
+
if (manifest) checkAgainstManifest(zip, files, manifest);
|
|
9190
|
+
try {
|
|
9191
|
+
await fs25.mkdir(dest);
|
|
9192
|
+
} catch (err) {
|
|
9193
|
+
if (err.code === "EEXIST") {
|
|
9194
|
+
throw new ItemPackageError(PackageErrorCode.DestinationExists, `${dest} already exists.`);
|
|
9195
|
+
}
|
|
9196
|
+
throw err;
|
|
9197
|
+
}
|
|
9198
|
+
for (const entry of entries) {
|
|
9199
|
+
const target = path24.join(dest, ...entry.path.split("/"));
|
|
9200
|
+
if (entry.dir) {
|
|
9201
|
+
await fs25.mkdir(target, { recursive: true });
|
|
9202
|
+
continue;
|
|
8638
9203
|
}
|
|
9204
|
+
await fs25.mkdir(path24.dirname(target), { recursive: true });
|
|
9205
|
+
await fs25.writeFile(target, entryBytes(zip, entry), { flag: "wx", mode: FILE_MODE });
|
|
9206
|
+
}
|
|
9207
|
+
return files.map((f) => f.path);
|
|
9208
|
+
}
|
|
9209
|
+
function megabytes(bytes) {
|
|
9210
|
+
return `${Math.round(bytes / MIB2 * 10) / 10} MB`;
|
|
9211
|
+
}
|
|
9212
|
+
|
|
9213
|
+
// src/lib/item-transfer.ts
|
|
9214
|
+
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
9215
|
+
var UPLOAD_TIMEOUT_MS = 3e5;
|
|
9216
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
9217
|
+
function isTransferUrl(url) {
|
|
9218
|
+
let parsed;
|
|
9219
|
+
try {
|
|
9220
|
+
parsed = new URL(url);
|
|
9221
|
+
} catch {
|
|
9222
|
+
return false;
|
|
9223
|
+
}
|
|
9224
|
+
if (parsed.username || parsed.password) return false;
|
|
9225
|
+
if (parsed.protocol === "https:") return true;
|
|
9226
|
+
return parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
|
|
9227
|
+
}
|
|
9228
|
+
async function putSigned(uploadUrl, bytes, contentType) {
|
|
9229
|
+
if (!isTransferUrl(uploadUrl)) throw new Error("The upload link Genex returned isn't an https URL.");
|
|
9230
|
+
const res = await fetch(uploadUrl, {
|
|
9231
|
+
method: "PUT",
|
|
9232
|
+
headers: { "Content-Type": contentType },
|
|
9233
|
+
body: bytes,
|
|
9234
|
+
redirect: "error",
|
|
9235
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
|
|
9236
|
+
});
|
|
9237
|
+
if (!res.ok) throw new Error(`The upload was refused (HTTP ${res.status}).`);
|
|
9238
|
+
}
|
|
9239
|
+
async function downloadItemPackage(url, fetchImpl = fetch) {
|
|
9240
|
+
if (!isTransferUrl(url)) {
|
|
9241
|
+
throw new ItemPackageError(PackageErrorCode.Invalid, "The package link Genex returned isn't an https URL.");
|
|
9242
|
+
}
|
|
9243
|
+
let res;
|
|
9244
|
+
try {
|
|
9245
|
+
res = await fetchImpl(url, { redirect: "error", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
9246
|
+
} catch (err) {
|
|
9247
|
+
throw new ItemPackageError(PackageErrorCode.DownloadFailed, `The package download failed: ${err instanceof Error ? err.message : String(err)}.`);
|
|
9248
|
+
}
|
|
9249
|
+
if (!res.ok || !res.body) {
|
|
9250
|
+
throw new ItemPackageError(PackageErrorCode.DownloadFailed, `The package download failed (HTTP ${res.status}).`);
|
|
9251
|
+
}
|
|
9252
|
+
const declared = Number(res.headers.get("content-length") ?? "0");
|
|
9253
|
+
if (declared > ITEM_PACKAGE_BYTES_MAX) {
|
|
9254
|
+
await res.body.cancel().catch(() => {
|
|
9255
|
+
});
|
|
9256
|
+
throw new ItemPackageError(PackageErrorCode.TooLarge, "The package is over the 50 MB limit.");
|
|
9257
|
+
}
|
|
9258
|
+
const chunks = [];
|
|
9259
|
+
let total = 0;
|
|
9260
|
+
for await (const chunk of res.body) {
|
|
9261
|
+
total += chunk.length;
|
|
9262
|
+
if (total > ITEM_PACKAGE_BYTES_MAX) {
|
|
9263
|
+
throw new ItemPackageError(PackageErrorCode.TooLarge, "The package is over the 50 MB limit.");
|
|
9264
|
+
}
|
|
9265
|
+
chunks.push(chunk);
|
|
9266
|
+
}
|
|
9267
|
+
return Buffer.concat(chunks);
|
|
9268
|
+
}
|
|
9269
|
+
|
|
9270
|
+
// src/commands/shop.ts
|
|
9271
|
+
var ITEM_DRAFT_KIND = "genex.item_draft";
|
|
9272
|
+
var ITEM_DRAFT_VERSION = 1;
|
|
9273
|
+
var SUBS = ["list", "add", "set", "remove", "test", "publish", "unpublish"];
|
|
9274
|
+
var STATE_LABEL = {
|
|
9275
|
+
draft: "draft \u2014 not on sale yet",
|
|
9276
|
+
off_sale: "off sale",
|
|
9277
|
+
taken_down: "taken off sale after a complaint"
|
|
9278
|
+
};
|
|
9279
|
+
var REFUSED_FILE_CODES = /* @__PURE__ */ new Set([
|
|
9280
|
+
"picture_invalid",
|
|
9281
|
+
"model_invalid",
|
|
9282
|
+
"media_type",
|
|
9283
|
+
"media_too_large",
|
|
9284
|
+
"package_invalid",
|
|
9285
|
+
"package_too_large"
|
|
9286
|
+
]);
|
|
9287
|
+
var ANOTHER_FILE = { picture: "<picture.png>", model: "<file.glb>", package: "<dir>" };
|
|
9288
|
+
function usageRefusal(message) {
|
|
9289
|
+
return { error: ItemErrorCode.Usage, message };
|
|
9290
|
+
}
|
|
9291
|
+
function clearTagsRefusal(sub, opts) {
|
|
9292
|
+
if (!opts.clearTags) return null;
|
|
9293
|
+
if (sub !== "set") return usageRefusal(`--clear-tags empties a listed item's tags: ${c.cyan("genex shop set <id> --clear-tags")}.`);
|
|
9294
|
+
if (opts.itemTags?.length) return usageRefusal("Pass --tag (the new list) or --clear-tags (no tags), not both.");
|
|
9295
|
+
return null;
|
|
9296
|
+
}
|
|
9297
|
+
async function runShop(opts) {
|
|
9298
|
+
const log = createLogger({ quiet: opts.quiet, toStderr: opts.json });
|
|
9299
|
+
const refuse = (refusal2) => reportItemRefusal(log, opts.json, refusal2);
|
|
9300
|
+
const sub = opts.name?.trim() || "list";
|
|
9301
|
+
if (!SUBS.includes(sub)) return refuse(usageRefusal(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS.join(", ")}.`));
|
|
9302
|
+
if (opts.type !== void 0 && opts.type !== "consumable" && opts.type !== "durable") {
|
|
9303
|
+
return refuse({ ...usageRefusal(`Invalid --type value for shop: ${opts.type} (consumable or durable)`), field: "type" });
|
|
9304
|
+
}
|
|
9305
|
+
const clearTags = clearTagsRefusal(sub, opts);
|
|
9306
|
+
if (clearTags) return refuse(clearTags);
|
|
9307
|
+
let attachments = { media: [], pack: null };
|
|
9308
|
+
if (sub === "add" || sub === "set") {
|
|
9309
|
+
const words2 = checkItemWords({ name: sub === "add" ? opts.hostname : opts.rename, description: opts.description, tags: opts.itemTags });
|
|
9310
|
+
if (words2) return refuse(words2);
|
|
9311
|
+
const prepared = await prepareAttachments(opts, refuse);
|
|
9312
|
+
if (!prepared) return;
|
|
9313
|
+
attachments = prepared;
|
|
9314
|
+
}
|
|
9315
|
+
const game = await openLinkedGame(opts, log, `genex shop ${sub}`);
|
|
9316
|
+
if (!game) return;
|
|
9317
|
+
const shop = new ShopSession(game, log, opts);
|
|
9318
|
+
if (sub === "list") return shop.list();
|
|
9319
|
+
if (sub === "add") return shop.add(attachments);
|
|
9320
|
+
const skuId = opts.hostname?.trim();
|
|
9321
|
+
if (!skuId) return refuse(usageRefusal(`\`genex shop ${sub}\` needs an item id \u2014 see ${c.cyan("genex shop list")}.`));
|
|
9322
|
+
if (sub === "test") return shop.testGrant(skuId);
|
|
9323
|
+
if (sub === "set") return shop.set(skuId, attachments);
|
|
9324
|
+
if (sub === "publish") return shop.publish(skuId);
|
|
9325
|
+
if (sub === "unpublish") return shop.unpublish(skuId);
|
|
9326
|
+
return shop.remove(skuId);
|
|
9327
|
+
}
|
|
9328
|
+
async function judgedMedia(kind, file) {
|
|
9329
|
+
try {
|
|
9330
|
+
return await readItemMedia(kind, file);
|
|
9331
|
+
} catch (err) {
|
|
9332
|
+
if (!(err instanceof ItemMediaError)) throw err;
|
|
9333
|
+
return { error: kind === MediaKind.Picture ? "picture_invalid" : "model_invalid", field: kind, message: err.message };
|
|
9334
|
+
}
|
|
9335
|
+
}
|
|
9336
|
+
async function prepareAttachments(opts, refuse) {
|
|
9337
|
+
const refused = (refusal2) => {
|
|
9338
|
+
refuse(refusal2);
|
|
9339
|
+
return null;
|
|
9340
|
+
};
|
|
9341
|
+
if (opts.shared === true && opts.type === "consumable") {
|
|
9342
|
+
return refused({
|
|
9343
|
+
error: "not_durable",
|
|
9344
|
+
field: "type",
|
|
9345
|
+
message: "Only a durable item can be shared \u2014 other games keep it, and a consumable is used up. Use --type durable, or drop --shared."
|
|
9346
|
+
});
|
|
9347
|
+
}
|
|
9348
|
+
if (opts.icon && opts.picture) {
|
|
9349
|
+
return refused(usageRefusal("Pass --icon or --image, not both: --icon takes a picture URL, --image a picture file on this machine."));
|
|
9350
|
+
}
|
|
9351
|
+
const media = [];
|
|
9352
|
+
for (const [kind, file] of [[MediaKind.Picture, opts.picture], [MediaKind.Model, opts.modelFile]]) {
|
|
9353
|
+
if (!file) continue;
|
|
9354
|
+
const judged = await judgedMedia(kind, file);
|
|
9355
|
+
if ("error" in judged) return refused(judged);
|
|
9356
|
+
media.push(judged);
|
|
9357
|
+
}
|
|
9358
|
+
let pack2 = null;
|
|
9359
|
+
if (opts.packageDir) {
|
|
9360
|
+
try {
|
|
9361
|
+
pack2 = await packItemDirectory(opts.packageDir);
|
|
9362
|
+
} catch (err) {
|
|
9363
|
+
if (err instanceof ItemPackageError) return refused({ error: "package_invalid", field: "package", message: `${err.message} (--package)` });
|
|
9364
|
+
throw err;
|
|
9365
|
+
}
|
|
9366
|
+
}
|
|
9367
|
+
return { media, pack: pack2 };
|
|
9368
|
+
}
|
|
9369
|
+
var SKIP_REASON = {
|
|
9370
|
+
excluded: "machine state or a secret",
|
|
9371
|
+
symlink: "a link \u2014 links are never followed",
|
|
9372
|
+
not_a_file: "not a regular file",
|
|
9373
|
+
too_large: "too large for any package",
|
|
9374
|
+
unsafe_name: "a name other systems cannot write back",
|
|
9375
|
+
reserved: "genex items add writes its own item.json there",
|
|
9376
|
+
shared_item: "other creators' items are for this game only"
|
|
9377
|
+
};
|
|
9378
|
+
var ShopSession = class {
|
|
9379
|
+
game;
|
|
9380
|
+
log;
|
|
9381
|
+
opts;
|
|
9382
|
+
projectSkus;
|
|
9383
|
+
constructor(game, log, opts) {
|
|
9384
|
+
this.game = game;
|
|
9385
|
+
this.log = log;
|
|
9386
|
+
this.opts = opts;
|
|
9387
|
+
this.projectSkus = `${game.apiUrl}/api/coin/projects/${encodeURIComponent(game.meta.id)}/skus`;
|
|
9388
|
+
}
|
|
9389
|
+
skuUrl(skuId) {
|
|
9390
|
+
return `${this.game.apiUrl}/api/coin/skus/${encodeURIComponent(skuId)}`;
|
|
9391
|
+
}
|
|
9392
|
+
post(url, body) {
|
|
9393
|
+
return this.game.call(url, { method: "POST", ...body === void 0 ? {} : { body: JSON.stringify(body) } });
|
|
9394
|
+
}
|
|
9395
|
+
/** stdout, whole: the one document a `--json` run prints. */
|
|
9396
|
+
printJson(value, pretty = true) {
|
|
9397
|
+
writeSafely(process.stdout, `${pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)}
|
|
9398
|
+
`);
|
|
9399
|
+
}
|
|
9400
|
+
/**
|
|
9401
|
+
* The draft document: the item flattened with `kind` and `version`. Only an
|
|
9402
|
+
* API that speaks the store contract (it sends `state`) gets the stamp; an
|
|
9403
|
+
* older one's bare item prints as it always did, so the app can never
|
|
9404
|
+
* mistake it for a draft.
|
|
9405
|
+
*/
|
|
9406
|
+
printDraft(sku) {
|
|
9407
|
+
if (typeof sku.state === "string") this.printJson({ kind: ITEM_DRAFT_KIND, version: ITEM_DRAFT_VERSION, ...sku }, false);
|
|
9408
|
+
else this.printJson(sku);
|
|
9409
|
+
}
|
|
9410
|
+
/** Print a refusal this command decided itself (exit code 1). */
|
|
9411
|
+
refuse(refusal2) {
|
|
9412
|
+
reportItemRefusal(this.log, this.opts.json, refusal2);
|
|
9413
|
+
}
|
|
9414
|
+
/** The server's price grid, or null when it cannot be read. */
|
|
9415
|
+
async priceGrid() {
|
|
9416
|
+
const res = await this.game.call(this.projectSkus).catch(() => null);
|
|
9417
|
+
if (!res?.ok) return null;
|
|
9418
|
+
const body = await res.json().catch(() => null);
|
|
9419
|
+
const grid = body?.priceGrid;
|
|
9420
|
+
return Array.isArray(grid) && grid.every((n) => typeof n === "number") ? grid : null;
|
|
9421
|
+
}
|
|
9422
|
+
/**
|
|
9423
|
+
* Does this server take a price of 0 (Free)? Only while its item store is
|
|
9424
|
+
* on (contract D7); the store routes answer then and 404 otherwise, the
|
|
9425
|
+
* probe the MCP connector's `item_draft` makes too. Unknown reads as no, so
|
|
9426
|
+
* Free is never offered where it would be refused.
|
|
9427
|
+
*/
|
|
9428
|
+
async freeAllowed() {
|
|
9429
|
+
const res = await this.game.call(`${this.game.apiUrl}/api/store/catalog?limit=1`).catch(() => null);
|
|
9430
|
+
return res?.ok === true;
|
|
9431
|
+
}
|
|
9432
|
+
/** The grid line every price hint prints, or null when the grid cannot be read. */
|
|
9433
|
+
async gridLine() {
|
|
9434
|
+
const [grid, free] = await Promise.all([this.priceGrid(), this.freeAllowed()]);
|
|
9435
|
+
return grid ? priceGridLine(grid, free) : null;
|
|
9436
|
+
}
|
|
9437
|
+
/** Report a refused API answer (exit code 1): the person's line and hints, and the `--json` document. */
|
|
9438
|
+
async fail(res, what, skuId) {
|
|
9439
|
+
const printed = printedStructuredError(res);
|
|
9440
|
+
reportItemRefusal(this.log, this.opts.json, await this.refusalFor(res, what, skuId, printed), printed);
|
|
9441
|
+
}
|
|
9442
|
+
/** One place to turn an API refusal into something actionable. */
|
|
9443
|
+
async refusalFor(res, what, skuId, printed) {
|
|
9444
|
+
const refusal2 = refusalFromBody(res.status, await bodyOf(res), `Couldn't ${what}`);
|
|
9445
|
+
if (res.status === 404 && refusal2.error === "not_found") {
|
|
9446
|
+
return { ...refusal2, message: "In-game purchases aren't enabled on this environment yet." };
|
|
9447
|
+
}
|
|
9448
|
+
if (skuId && refusal2.error === "project_not_found") return { ...refusal2, message: "That item is not yours (or does not exist)." };
|
|
9449
|
+
if (printed) return refusal2;
|
|
9450
|
+
return this.withHints(refusal2, skuId ?? "<id>");
|
|
9451
|
+
}
|
|
9452
|
+
/** The next step for each refusal the creator can act on. */
|
|
9453
|
+
async withHints(refusal2, id) {
|
|
9454
|
+
const hint = (...hints) => ({ ...refusal2, hints });
|
|
9455
|
+
const media = refusal2.field === "model" ? { flag: "--model", file: ANOTHER_FILE.model } : { flag: "--image", file: ANOTHER_FILE.picture };
|
|
9456
|
+
switch (refusal2.error) {
|
|
9457
|
+
case "price_off_grid":
|
|
9458
|
+
case "bad_price": {
|
|
9459
|
+
const grid = await this.gridLine();
|
|
9460
|
+
const price = this.opts.price;
|
|
9461
|
+
const named = grid && refusal2.error === "price_off_grid" && price !== void 0;
|
|
9462
|
+
return {
|
|
9463
|
+
...refusal2,
|
|
9464
|
+
...named ? { message: `${formatCoins(price)} coin isn't one of this game's prices.` } : {},
|
|
9465
|
+
hints: [...grid ? [`Prices available: ${grid}`] : [], `See the full list any time: ${c.cyan("genex shop list")}`]
|
|
9466
|
+
};
|
|
9467
|
+
}
|
|
9468
|
+
case "not_resellable_type":
|
|
9469
|
+
return hint(`Add it with ${c.cyan("--type durable")} \u2014 a consumable is used up, so there is nothing left to resell.`);
|
|
9470
|
+
case "not_durable":
|
|
9471
|
+
return hint(`Only a durable item can be shared: ${c.cyan(`genex shop set ${id} --type durable`)} first.`);
|
|
9472
|
+
case "picture_missing":
|
|
9473
|
+
return hint(`Render the item on a transparent background, then: ${c.cyan(`genex shop set ${id} --image <picture.png>`)}`);
|
|
9474
|
+
case "picture_invalid":
|
|
9475
|
+
case "media_type":
|
|
9476
|
+
case "media_too_large":
|
|
9477
|
+
return hint(`A picture is a PNG or WebP up to 2 MB, a model a .glb up to 20 MB: ${c.cyan(`genex shop set ${id} ${media.flag} ${media.file}`)}`);
|
|
9478
|
+
case "model_invalid":
|
|
9479
|
+
return hint(`Replace it with another GLB: ${c.cyan(`genex shop set ${id} --model ${ANOTHER_FILE.model}`)}`);
|
|
9480
|
+
case "package_invalid":
|
|
9481
|
+
return hint(
|
|
9482
|
+
`The package holds ${refusal2.path ?? "a path"} that a package may not (a link, an absolute or a ../ path). Fix the folder, then ${c.cyan(`genex shop set ${id} --package <dir>`)}.`
|
|
9483
|
+
);
|
|
9484
|
+
case "package_too_large":
|
|
9485
|
+
return hint("A package may be at most 50 MB zipped.");
|
|
9486
|
+
case "name_refused":
|
|
9487
|
+
return hint(`Rename it: ${c.cyan(`genex shop set ${id} --rename "<name>"`)}`);
|
|
9488
|
+
case "description_refused":
|
|
9489
|
+
return hint(`Rewrite it: ${c.cyan(`genex shop set ${id} --description "<text>"`)}`);
|
|
9490
|
+
case "taken_down":
|
|
9491
|
+
return hint("An admin took this item off sale after a complaint. It can't go back on sale; its owners keep it.");
|
|
9492
|
+
case "publish_rate_limited":
|
|
9493
|
+
return hint(`Too many items put on sale today. Try again in ${Math.max(1, Math.ceil((refusal2.retryAfterMs ?? 0) / 6e4))} minutes.`);
|
|
9494
|
+
case "too_many_skus":
|
|
9495
|
+
return hint(`Retire an item this game no longer sells: ${c.cyan("genex shop remove <id>")}`);
|
|
9496
|
+
case "type_locked":
|
|
9497
|
+
return hint("Someone already owns it, so its type can no longer change.");
|
|
9498
|
+
default:
|
|
9499
|
+
return refusal2;
|
|
9500
|
+
}
|
|
9501
|
+
}
|
|
9502
|
+
async list() {
|
|
9503
|
+
const [res, free] = await Promise.all([this.game.call(this.projectSkus), this.opts.json ? false : this.freeAllowed()]);
|
|
9504
|
+
if (!res.ok) return this.fail(res, "read your shop");
|
|
9505
|
+
const body = await res.json();
|
|
9506
|
+
if (this.opts.json) return this.printJson(body);
|
|
9507
|
+
const { log } = this;
|
|
9508
|
+
const prices = `Prices available: ${priceGridLine(body.priceGrid, free)}`;
|
|
8639
9509
|
if (body.items.length === 0) {
|
|
8640
9510
|
log.plain("This game sells nothing yet.");
|
|
8641
9511
|
log.dim(` Add something: ${c.cyan('genex shop add "Iron Key" --price 100')}`);
|
|
8642
|
-
log.dim(`
|
|
9512
|
+
log.dim(` ${prices}`);
|
|
8643
9513
|
return;
|
|
8644
9514
|
}
|
|
8645
9515
|
log.plain(`${body.items.length} item${body.items.length === 1 ? "" : "s"}:`);
|
|
8646
9516
|
for (const s of body.items) {
|
|
8647
|
-
const
|
|
9517
|
+
const label = s.retired ? "retired" : s.state === void 0 ? s.active ? null : "retired" : STATE_LABEL[s.state] ?? null;
|
|
9518
|
+
const state = label ? c.dim(` (${label})`) : "";
|
|
9519
|
+
const shared = s.shareable ? c.dim(" \xB7 other games can use it") : "";
|
|
8648
9520
|
const resale = s.resellable ? c.dim(" \xB7 resellable") : "";
|
|
8649
|
-
log.plain(
|
|
8650
|
-
` ${c.cyan(s.id)} ${s.name} \u2014 ${s.priceCoins} coin${money(s.priceDisplayUsdCents)} ${c.dim(s.type)}${resale}${state}`
|
|
8651
|
-
);
|
|
9521
|
+
log.plain(` ${c.cyan(s.id)} ${s.name} \u2014 ${coinPrice(s.priceCoins, s.priceDisplayUsdCents)} ${c.dim(s.type)}${shared}${resale}${state}`);
|
|
8652
9522
|
}
|
|
8653
|
-
log.dim(
|
|
8654
|
-
return;
|
|
9523
|
+
log.dim(prices);
|
|
8655
9524
|
}
|
|
8656
|
-
|
|
9525
|
+
async add(attachments) {
|
|
9526
|
+
const { log, opts } = this;
|
|
8657
9527
|
const name = opts.hostname?.trim();
|
|
8658
|
-
if (!name) {
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
body: JSON.stringify({
|
|
8671
|
-
name,
|
|
8672
|
-
priceCoins: opts.price,
|
|
8673
|
-
type: opts.type === "durable" ? "durable" : "consumable",
|
|
8674
|
-
...opts.icon ? { iconUrl: opts.icon } : {},
|
|
8675
|
-
...opts.resellable !== void 0 ? { resellable: opts.resellable } : {}
|
|
8676
|
-
})
|
|
9528
|
+
if (!name) return this.refuse(usageRefusal(`\`genex shop add\` needs a name, e.g. ${c.cyan('genex shop add "Iron Key" --price 100')}.`));
|
|
9529
|
+
if (opts.price === void 0) return this.refuse(usageRefusal(`\`genex shop add\` needs ${c.cyan("--price")}, in coin (0 is Free).`));
|
|
9530
|
+
const type = opts.type ?? (opts.shared ? "durable" : "consumable");
|
|
9531
|
+
const res = await this.post(this.projectSkus, {
|
|
9532
|
+
name,
|
|
9533
|
+
priceCoins: opts.price,
|
|
9534
|
+
type,
|
|
9535
|
+
...opts.description ? { description: opts.description } : {},
|
|
9536
|
+
...opts.itemTags?.length ? { tags: opts.itemTags } : {},
|
|
9537
|
+
...opts.icon ? { iconUrl: opts.icon } : {},
|
|
9538
|
+
...opts.shared !== void 0 ? { shareable: opts.shared } : {},
|
|
9539
|
+
...opts.resellable !== void 0 ? { resellable: opts.resellable } : {}
|
|
8677
9540
|
});
|
|
8678
|
-
if (!
|
|
8679
|
-
const
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
9541
|
+
if (!res.ok) return this.fail(res, "add that item");
|
|
9542
|
+
const created = await res.json();
|
|
9543
|
+
const attached = await this.attach(created.id, created, attachments);
|
|
9544
|
+
if (!attached) return;
|
|
9545
|
+
const sku = attached.sku ?? created;
|
|
9546
|
+
if (opts.json) return this.printDraft(sku);
|
|
9547
|
+
const draft = sku.state === "draft";
|
|
9548
|
+
log.success(`Added ${c.cyan(sku.name)} \u2014 ${coinPrice(sku.priceCoins, sku.priceDisplayUsdCents)}${draft ? ", as a draft" : ""}.`);
|
|
8685
9549
|
log.plain(` id: ${c.cyan(sku.id)}`);
|
|
8686
9550
|
log.dim(` Use it in the game: buy({ skuId: "${sku.id}" })`);
|
|
8687
9551
|
if (sku.resellable) log.dim(" Players can resell this to each other on the market.");
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
log.error(`\`genex shop ${sub}\` needs an item id \u2014 see ${c.cyan("genex shop list")}.`);
|
|
8693
|
-
process.exitCode = 1;
|
|
8694
|
-
return;
|
|
9552
|
+
if (draft) {
|
|
9553
|
+
log.dim(" It is not on sale yet: the person puts it on sale in the Genex app (Create \u203A Items \u203A Selling);");
|
|
9554
|
+
log.dim(` from a terminal: ${c.cyan(`genex shop publish ${sku.id}`)}`);
|
|
9555
|
+
}
|
|
8695
9556
|
}
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
const
|
|
8699
|
-
if (
|
|
8700
|
-
|
|
9557
|
+
async set(skuId, attachments) {
|
|
9558
|
+
const { log, opts } = this;
|
|
9559
|
+
const patch = {};
|
|
9560
|
+
if (opts.rename) patch.name = opts.rename;
|
|
9561
|
+
if (opts.price !== void 0) patch.priceCoins = opts.price;
|
|
9562
|
+
if (opts.type !== void 0) patch.type = opts.type;
|
|
9563
|
+
if (opts.description !== void 0) patch.description = opts.description.trim() === "" ? null : opts.description;
|
|
9564
|
+
if (opts.itemTags?.length) patch.tags = opts.itemTags;
|
|
9565
|
+
if (opts.clearTags) patch.tags = [];
|
|
9566
|
+
if (opts.icon) patch.iconUrl = opts.icon;
|
|
9567
|
+
if (opts.shared !== void 0) patch.shareable = opts.shared;
|
|
9568
|
+
if (opts.resellable !== void 0) patch.resellable = opts.resellable;
|
|
9569
|
+
const uploads = attachments.media.length > 0 || attachments.pack !== null;
|
|
9570
|
+
if (Object.keys(patch).length === 0 && !uploads) {
|
|
9571
|
+
const flags = ["--rename", "--price", "--icon", "--image", "--model", "--package", "--description", "--tag", "--clear-tags", "--shared"].map((f) => c.cyan(f));
|
|
9572
|
+
return this.refuse(usageRefusal(`Nothing to change. Pass ${flags.join(", ")} or ${c.cyan("--resellable")}.`));
|
|
9573
|
+
}
|
|
9574
|
+
let sku = null;
|
|
9575
|
+
if (Object.keys(patch).length > 0) {
|
|
9576
|
+
const res = await this.game.call(this.skuUrl(skuId), { method: "PATCH", body: JSON.stringify(patch) });
|
|
9577
|
+
if (!res.ok) return this.fail(res, "update that item", skuId);
|
|
9578
|
+
sku = await res.json();
|
|
9579
|
+
}
|
|
9580
|
+
const attached = await this.attach(skuId, sku, attachments);
|
|
9581
|
+
const finished = attached?.sku;
|
|
9582
|
+
if (!finished) return;
|
|
9583
|
+
if (opts.json) return this.printDraft(finished);
|
|
9584
|
+
log.success(`Updated ${c.cyan(finished.name)} \u2014 ${coinPrice(finished.priceCoins, finished.priceDisplayUsdCents)}.`);
|
|
9585
|
+
if (opts.resellable === false) log.dim(" Listings already on the market stay up.");
|
|
9586
|
+
if (opts.shared === false) log.dim(" Games that already added it keep it; no new game can add it.");
|
|
9587
|
+
}
|
|
9588
|
+
/**
|
|
9589
|
+
* Upload the picture, the model and the package to an item that exists:
|
|
9590
|
+
* upload-url → PUT to the signed URL → commit, each answering the item as it
|
|
9591
|
+
* now stands. Resolves the item after the last commit (`sku` itself when
|
|
9592
|
+
* there was nothing to upload), or null when an upload was refused —
|
|
9593
|
+
* printed, with the item's id and the command that finishes it, because the
|
|
9594
|
+
* item itself already exists.
|
|
9595
|
+
*/
|
|
9596
|
+
async attach(skuId, sku, attachments) {
|
|
9597
|
+
let current = sku;
|
|
9598
|
+
const skuUrl = this.skuUrl(skuId);
|
|
9599
|
+
const parts = [
|
|
9600
|
+
...attachments.media.map((m) => ({ flag: m.kind === MediaKind.Picture ? "--image" : "--model", source: m.file, kind: m.kind, bytes: m.bytes, contentType: m.contentType })),
|
|
9601
|
+
...attachments.pack ? [{ flag: "--package", source: this.opts.packageDir ?? "<dir>", kind: "package", bytes: attachments.pack.zip, contentType: MediaContentType.Zip }] : []
|
|
9602
|
+
];
|
|
9603
|
+
if (attachments.pack) this.reportSkipped(attachments.pack);
|
|
9604
|
+
for (const part of parts) {
|
|
9605
|
+
const unfinished = (refusal2, printed = false) => {
|
|
9606
|
+
const file = REFUSED_FILE_CODES.has(refusal2.error) ? ANOTHER_FILE[part.kind] : part.source;
|
|
9607
|
+
const finish2 = `The item ${skuId} exists, but its ${part.kind} did not upload. Finish it with: genex shop set ${skuId} ${part.flag} ${file}`;
|
|
9608
|
+
reportItemRefusal(this.log, this.opts.json, { ...refusal2, id: skuId, hints: [...refusal2.hints ?? [], finish2] }, printed);
|
|
9609
|
+
if (printed) this.log.error(finish2);
|
|
9610
|
+
return null;
|
|
9611
|
+
};
|
|
9612
|
+
const refused = async (res, what) => {
|
|
9613
|
+
const printed = printedStructuredError(res);
|
|
9614
|
+
return unfinished(await this.refusalFor(res, what, skuId, printed), printed);
|
|
9615
|
+
};
|
|
9616
|
+
const isPackage = part.kind === "package";
|
|
9617
|
+
const base = isPackage ? `${skuUrl}/package` : `${skuUrl}/media`;
|
|
9618
|
+
this.log.dim(`Uploading the ${part.kind} (${part.bytes.length} bytes)\u2026`);
|
|
9619
|
+
const urlRes = await this.post(`${base}/upload-url`, isPackage ? { bytes: part.bytes.length } : { kind: part.kind, bytes: part.bytes.length, contentType: part.contentType });
|
|
9620
|
+
if (!urlRes.ok) return refused(urlRes, `upload the ${part.kind}`);
|
|
9621
|
+
const { uploadUrl, key } = await urlRes.json();
|
|
9622
|
+
try {
|
|
9623
|
+
await putSigned(uploadUrl, part.bytes, part.contentType);
|
|
9624
|
+
} catch (err) {
|
|
9625
|
+
return unfinished({ error: "media_upload_failed", field: part.kind, message: err instanceof Error ? err.message : String(err) });
|
|
9626
|
+
}
|
|
9627
|
+
const commit = await this.post(base, isPackage ? { key } : { kind: part.kind, key });
|
|
9628
|
+
if (!commit.ok) return refused(commit, `attach the ${part.kind}`);
|
|
9629
|
+
current = await commit.json();
|
|
9630
|
+
}
|
|
9631
|
+
return { sku: current };
|
|
9632
|
+
}
|
|
9633
|
+
reportSkipped(pack2) {
|
|
9634
|
+
this.log.dim(`Packaging ${pack2.files.length} file${pack2.files.length === 1 ? "" : "s"}.`);
|
|
9635
|
+
if (pack2.skipped.length === 0) return;
|
|
9636
|
+
this.log.dim("Left out of the package:");
|
|
9637
|
+
for (const s of pack2.skipped) this.log.dim(` ${s.path} (${SKIP_REASON[s.reason]})`);
|
|
9638
|
+
}
|
|
9639
|
+
async testGrant(skuId) {
|
|
9640
|
+
const { log } = this;
|
|
9641
|
+
const res = await this.post(`${this.skuUrl(skuId)}/test-grant`);
|
|
9642
|
+
if (!res.ok) return this.fail(res, "grant yourself a test copy", skuId);
|
|
9643
|
+
const got = await res.json();
|
|
8701
9644
|
log.success(`You now own ${c.cyan(got.name)} in this game.`);
|
|
8702
9645
|
log.dim(" It cost nothing and no sale was recorded \u2014 it is a test copy.");
|
|
8703
9646
|
log.dim(
|
|
8704
9647
|
got.skuType === "durable" ? " Reload the game: it should appear in your inventory." : " Reload the game: it should be delivered and consumed once."
|
|
8705
9648
|
);
|
|
8706
|
-
return;
|
|
8707
9649
|
}
|
|
8708
|
-
|
|
8709
|
-
const
|
|
8710
|
-
if (
|
|
8711
|
-
|
|
8712
|
-
if (opts.
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
9650
|
+
async publish(skuId) {
|
|
9651
|
+
const res = await this.post(`${this.skuUrl(skuId)}/publish`);
|
|
9652
|
+
if (!res.ok) return this.fail(res, "put that item on sale", skuId);
|
|
9653
|
+
const sku = await res.json();
|
|
9654
|
+
if (this.opts.json) return this.printJson(sku);
|
|
9655
|
+
this.log.success(`${c.cyan(sku.name)} is on sale \u2014 ${coinPrice(sku.priceCoins, sku.priceDisplayUsdCents)}.`);
|
|
9656
|
+
this.log.dim(" Players can buy it in this game's store now.");
|
|
9657
|
+
}
|
|
9658
|
+
async unpublish(skuId) {
|
|
9659
|
+
const res = await this.post(`${this.skuUrl(skuId)}/unpublish`);
|
|
9660
|
+
if (!res.ok) return this.fail(res, "take that item off sale", skuId);
|
|
9661
|
+
const sku = await res.json();
|
|
9662
|
+
if (this.opts.json) return this.printJson(sku);
|
|
9663
|
+
this.log.success(`${c.cyan(sku.name)} is off sale.`);
|
|
9664
|
+
this.log.dim(" Players who already own it keep it.");
|
|
9665
|
+
}
|
|
9666
|
+
async remove(skuId) {
|
|
9667
|
+
const res = await this.game.call(this.skuUrl(skuId), { method: "DELETE" });
|
|
9668
|
+
if (!res.ok) return this.fail(res, "remove that item", skuId);
|
|
9669
|
+
this.log.success("Removed from the shop.");
|
|
9670
|
+
this.log.dim(" Players who already bought it keep it.");
|
|
9671
|
+
}
|
|
9672
|
+
};
|
|
9673
|
+
|
|
9674
|
+
// src/commands/items.ts
|
|
9675
|
+
import crypto6 from "crypto";
|
|
9676
|
+
import fs26 from "fs/promises";
|
|
9677
|
+
import path25 from "path";
|
|
9678
|
+
var SUBS2 = ["search", "add", "remove", "list"];
|
|
9679
|
+
var ITEMS_DIR = SHARED_ITEMS_DIR;
|
|
9680
|
+
var REPLACED_DIR = ".genex/replaced-items";
|
|
9681
|
+
var ITEM_FILE = ITEM_FILE_NAME;
|
|
9682
|
+
var ITEM_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
9683
|
+
async function runItems(opts) {
|
|
9684
|
+
const log = createLogger({ quiet: opts.quiet, toStderr: opts.json });
|
|
9685
|
+
const usage = (message, hints) => reportItemRefusal(log, opts.json, { error: ItemErrorCode.Usage, message, ...hints ? { hints } : {} });
|
|
9686
|
+
const sub = opts.name?.trim() || "list";
|
|
9687
|
+
if (!SUBS2.includes(sub)) return usage(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS2.join(", ")}.`);
|
|
9688
|
+
const arg = opts.query?.trim() ?? "";
|
|
9689
|
+
if (sub === "search" && !arg && !opts.itemTags?.length) {
|
|
9690
|
+
return usage(`\`genex items search\` needs words or a --tag, e.g. ${c.cyan('genex items search "wings" --tag fire')}.`);
|
|
9691
|
+
}
|
|
9692
|
+
if ((sub === "add" || sub === "remove") && !ITEM_ID.test(arg)) {
|
|
9693
|
+
return usage(arg ? `${JSON.stringify(arg)} isn't an item id.` : `\`genex items ${sub}\` needs an item id.`, [
|
|
9694
|
+
`Find one with ${c.cyan('genex items search "<words>"')} (or ${c.cyan("genex items list")}).`
|
|
9695
|
+
]);
|
|
9696
|
+
}
|
|
9697
|
+
const game = await openLinkedGame(opts, log, `genex items ${sub}`);
|
|
9698
|
+
if (!game) return;
|
|
9699
|
+
const items = new ItemsSession(game, log, opts);
|
|
9700
|
+
if (sub === "search") return items.search(arg);
|
|
9701
|
+
if (sub === "add") return items.add(arg);
|
|
9702
|
+
if (sub === "remove") return items.remove(arg);
|
|
9703
|
+
return items.list();
|
|
9704
|
+
}
|
|
9705
|
+
var ADD_REFUSAL_HINT = {
|
|
9706
|
+
not_shareable: "Its author no longer lets other games add it; games that already did keep it.",
|
|
9707
|
+
home_game: "This is the item's own game: it is already in its store.",
|
|
9708
|
+
item_off_sale: "Only an item on sale can be added.",
|
|
9709
|
+
taken_down: "Only an item on sale can be added."
|
|
9710
|
+
};
|
|
9711
|
+
function sharePercent(bps) {
|
|
9712
|
+
if (typeof bps !== "number" || !Number.isInteger(bps) || bps < 0 || bps > 1e4) return null;
|
|
9713
|
+
return `${Number((bps / 100).toFixed(2))}%`;
|
|
9714
|
+
}
|
|
9715
|
+
var ItemsSession = class _ItemsSession {
|
|
9716
|
+
game;
|
|
9717
|
+
log;
|
|
9718
|
+
opts;
|
|
9719
|
+
allowances;
|
|
9720
|
+
constructor(game, log, opts) {
|
|
9721
|
+
this.game = game;
|
|
9722
|
+
this.log = log;
|
|
9723
|
+
this.opts = opts;
|
|
9724
|
+
this.allowances = `${game.apiUrl}/api/store/projects/${encodeURIComponent(game.meta.id)}/allowances`;
|
|
9725
|
+
}
|
|
9726
|
+
printJson(value) {
|
|
9727
|
+
writeSafely(process.stdout, `${JSON.stringify(value, null, 2)}
|
|
9728
|
+
`);
|
|
9729
|
+
}
|
|
9730
|
+
/** One place to turn an API refusal into a sentence (and, under --json, the refusal document). */
|
|
9731
|
+
async fail(res, what) {
|
|
9732
|
+
const printed = printedStructuredError(res);
|
|
9733
|
+
const refusal2 = refusalFromBody(res.status, await bodyOf(res), `Couldn't ${what}`);
|
|
9734
|
+
reportItemRefusal(this.log, this.opts.json, _ItemsSession.worded(res.status, refusal2), printed);
|
|
9735
|
+
}
|
|
9736
|
+
/** The refusal in this command's words: the store switched off, and the hints a host can act on. */
|
|
9737
|
+
static worded(status2, refusal2) {
|
|
9738
|
+
if (status2 === 404 && refusal2.error === "not_found") return { ...refusal2, message: "The item store isn't enabled on this environment yet." };
|
|
9739
|
+
const hint = ADD_REFUSAL_HINT[refusal2.error];
|
|
9740
|
+
return hint ? { ...refusal2, hints: [hint] } : refusal2;
|
|
9741
|
+
}
|
|
9742
|
+
/** Print a refusal this command decided itself (exit code 1). */
|
|
9743
|
+
refuse(refusal2) {
|
|
9744
|
+
reportItemRefusal(this.log, this.opts.json, refusal2);
|
|
9745
|
+
}
|
|
9746
|
+
async search(words2) {
|
|
9747
|
+
const qs = new URLSearchParams();
|
|
9748
|
+
if (words2) qs.set("q", words2);
|
|
9749
|
+
if (this.opts.itemTags?.length) qs.set("tags", this.opts.itemTags.join(","));
|
|
9750
|
+
const res = await this.game.call(`${this.game.apiUrl}/api/store/catalog?${qs}`);
|
|
9751
|
+
if (!res.ok) return this.fail(res, "search the items");
|
|
9752
|
+
const body = await res.json();
|
|
9753
|
+
if (this.opts.json) return this.printJson(body);
|
|
9754
|
+
const { log } = this;
|
|
9755
|
+
if (body.items.length === 0) {
|
|
9756
|
+
log.plain("No shared items match.");
|
|
8719
9757
|
return;
|
|
8720
9758
|
}
|
|
8721
|
-
const
|
|
8722
|
-
if (
|
|
8723
|
-
const
|
|
8724
|
-
|
|
8725
|
-
|
|
9759
|
+
const share = sharePercent(body.sellerShareBps);
|
|
9760
|
+
if (share) log.plain(` Your game gets ${share} of every sale of an item it adds; the author and Genex share the rest.`);
|
|
9761
|
+
for (const item of body.items) {
|
|
9762
|
+
const reach = item.gamesCount === 1 ? "in 1 game" : `in ${item.gamesCount} games`;
|
|
9763
|
+
log.plain(` ${c.cyan(item.id)} ${item.name} \u2014 ${coinPrice(item.priceCoins, item.priceDisplayUsdCents)} \xB7 ${reach} \xB7 by ${item.author.name}`);
|
|
9764
|
+
log.dim(` you get ${formatCoins(item.youGetPerSale)} coin per sale${item.tags.length ? ` \xB7 ${item.tags.join(", ")}` : ""}`);
|
|
9765
|
+
if (item.description) log.dim(` ${item.description.length > 120 ? `${item.description.slice(0, 117)}\u2026` : item.description}`);
|
|
9766
|
+
}
|
|
9767
|
+
if (body.nextCursor) log.dim(" More match: narrow the search with more words or a --tag.");
|
|
9768
|
+
log.dim(` Add one to this game: ${c.cyan(`genex items add ${body.items[0]?.id ?? "<id>"}`)} (free; players who own it can use it here)`);
|
|
9769
|
+
}
|
|
9770
|
+
/**
|
|
9771
|
+
* Allow the item here, then write its folder: the package (validated whole)
|
|
9772
|
+
* and item.json. The allowance is server state; when the folder cannot be
|
|
9773
|
+
* written the item stays added and the command says how to fetch it again.
|
|
9774
|
+
*/
|
|
9775
|
+
async add(skuId) {
|
|
9776
|
+
const { log } = this;
|
|
9777
|
+
const added = await this.game.call(this.allowances, { method: "POST", body: JSON.stringify({ skuId }) });
|
|
9778
|
+
if (!added.ok) return this.fail(added, "add that item");
|
|
9779
|
+
const { allowance, package: link, replayed } = await added.json();
|
|
9780
|
+
const item = await this.describe(skuId);
|
|
9781
|
+
if (!item) return;
|
|
9782
|
+
const pkg = link ? await this.packageLink(skuId) : null;
|
|
9783
|
+
if (link && !pkg) return;
|
|
9784
|
+
const itemFile = { ...item, manifest: pkg?.manifest ?? null, addedAt: allowance.addedAt };
|
|
9785
|
+
let files;
|
|
9786
|
+
try {
|
|
9787
|
+
files = await this.writeItemFolder(skuId, itemFile, pkg);
|
|
9788
|
+
} catch (err) {
|
|
9789
|
+
if (err instanceof ItemPackageError) return this.refuse(packageRefusal(skuId, item.name, err));
|
|
9790
|
+
if (err instanceof FolderRefused) return this.refuse(folderRefusal(skuId, item.name, err));
|
|
9791
|
+
throw err;
|
|
9792
|
+
}
|
|
9793
|
+
const dir = `${ITEMS_DIR}/${skuId}`;
|
|
9794
|
+
if (this.opts.json) return this.printJson({ allowance, item: itemFile, dir, files, replayed });
|
|
9795
|
+
const author = item.author ? ` by ${item.author.name}` : "";
|
|
9796
|
+
log.success(`${c.cyan(item.name)}${author} is added to this game \u2014 ${coinPrice(item.priceCoins, item.priceDisplayUsdCents)}.`);
|
|
9797
|
+
log.plain(` ${dir}/${ITEM_FILE}${files.length ? ` and its package (${files.length} file${files.length === 1 ? "" : "s"})` : ""}.`);
|
|
9798
|
+
log.dim(" Another creator's files: use them as reference material, and review any code before you run it.");
|
|
9799
|
+
log.dim(` Next, follow the genex-items skill: decide how it looks and works in this game, wire it to`);
|
|
9800
|
+
log.dim(` buy({ skuId: "${skuId}" }) and apply it from getEntitlements() at boot.`);
|
|
9801
|
+
log.dim(" It shows in this game's store once you release a new build.");
|
|
9802
|
+
log.dim(` Release one with ${c.cyan("genex publish")} (or ${c.cyan("genex preview")}, then ${c.cyan("genex promote")}) once it works in the game.`);
|
|
9803
|
+
}
|
|
9804
|
+
/**
|
|
9805
|
+
* The item's details for item.json. Browse's detail first; the caller's own
|
|
9806
|
+
* item is not browsable (contract D29), so it falls back to this game's
|
|
9807
|
+
* allowances, which carry everything but the description, tags and type.
|
|
9808
|
+
*/
|
|
9809
|
+
async describe(skuId) {
|
|
9810
|
+
const res = await this.game.call(`${this.game.apiUrl}/api/store/catalog/${encodeURIComponent(skuId)}`);
|
|
9811
|
+
if (res.ok) {
|
|
9812
|
+
const { item } = await res.json();
|
|
9813
|
+
return {
|
|
9814
|
+
skuId,
|
|
9815
|
+
name: item.name,
|
|
9816
|
+
description: item.description,
|
|
9817
|
+
type: item.type,
|
|
9818
|
+
tags: item.tags,
|
|
9819
|
+
iconUrl: item.iconUrl,
|
|
9820
|
+
modelUrl: item.modelUrl,
|
|
9821
|
+
priceCoins: item.priceCoins,
|
|
9822
|
+
priceDisplayUsdCents: item.priceDisplayUsdCents,
|
|
9823
|
+
author: item.author,
|
|
9824
|
+
homeGame: item.homeGame
|
|
9825
|
+
};
|
|
9826
|
+
}
|
|
9827
|
+
if (res.status !== 404) {
|
|
9828
|
+
await this.fail(res, "read that item");
|
|
9829
|
+
return null;
|
|
9830
|
+
}
|
|
9831
|
+
const list = await this.game.call(this.allowances);
|
|
9832
|
+
if (!list.ok) {
|
|
9833
|
+
await this.fail(list, "read this game's items");
|
|
9834
|
+
return null;
|
|
9835
|
+
}
|
|
9836
|
+
const { items } = await list.json();
|
|
9837
|
+
const row = items.find((r) => r.item.id === skuId);
|
|
9838
|
+
if (!row) {
|
|
9839
|
+
this.refuse({ error: "sku_not_found", message: `Couldn't read ${skuId} back from this game's items.` });
|
|
9840
|
+
return null;
|
|
9841
|
+
}
|
|
9842
|
+
return {
|
|
9843
|
+
skuId,
|
|
9844
|
+
name: row.item.name,
|
|
9845
|
+
description: null,
|
|
9846
|
+
// Only a durable can be shared (P3).
|
|
9847
|
+
type: "durable",
|
|
9848
|
+
tags: [],
|
|
9849
|
+
iconUrl: row.item.iconUrl,
|
|
9850
|
+
modelUrl: row.item.modelUrl,
|
|
9851
|
+
priceCoins: row.item.priceCoins,
|
|
9852
|
+
priceDisplayUsdCents: row.item.priceDisplayUsdCents,
|
|
9853
|
+
author: row.item.author,
|
|
9854
|
+
homeGame: row.item.homeGame
|
|
9855
|
+
};
|
|
9856
|
+
}
|
|
9857
|
+
/** The package's signed link and its manifest, read together so the two always describe the same upload. */
|
|
9858
|
+
async packageLink(skuId) {
|
|
9859
|
+
const res = await this.game.call(`${this.game.apiUrl}/api/store/items/${encodeURIComponent(skuId)}/package`);
|
|
9860
|
+
if (!res.ok) {
|
|
9861
|
+
await this.fail(res, "fetch the item's package");
|
|
9862
|
+
return null;
|
|
9863
|
+
}
|
|
9864
|
+
return await res.json();
|
|
9865
|
+
}
|
|
9866
|
+
/**
|
|
9867
|
+
* Write `genex-items/<skuId>/`: download and unpack into a fresh staging
|
|
9868
|
+
* folder beside it, add item.json, then swap it in — moving a previous copy
|
|
9869
|
+
* under `.genex/` first. A refusal anywhere leaves the game folder as it was.
|
|
9870
|
+
*/
|
|
9871
|
+
async writeItemFolder(skuId, itemFile, pkg) {
|
|
9872
|
+
const root = process.cwd();
|
|
9873
|
+
const itemsDir = path25.join(root, ITEMS_DIR);
|
|
9874
|
+
await ensureRealDir(itemsDir, ITEMS_DIR);
|
|
9875
|
+
const final = path25.join(itemsDir, skuId);
|
|
9876
|
+
const existing = await lstatOrNull(final);
|
|
9877
|
+
if (existing && !existing.isDirectory()) {
|
|
9878
|
+
throw new FolderRefused(`${ITEMS_DIR}/${skuId} is a link or a file, not a folder. Move it aside first; nothing was written through it.`);
|
|
9879
|
+
}
|
|
9880
|
+
const keep = existing ? await replacedItemsDir(root) : null;
|
|
9881
|
+
const zip = pkg ? await downloadItemPackage(pkg.url) : null;
|
|
9882
|
+
const staging = path25.join(itemsDir, `.${skuId}.incoming-${crypto6.randomBytes(4).toString("hex")}`);
|
|
9883
|
+
try {
|
|
9884
|
+
const files = zip && pkg ? await extractItemPackage(zip, staging, pkg.manifest) : [];
|
|
9885
|
+
if (!zip) await fs26.mkdir(staging);
|
|
9886
|
+
await fs26.writeFile(path25.join(staging, ITEM_FILE), `${JSON.stringify(itemFile, null, 2)}
|
|
9887
|
+
`, { flag: "wx" });
|
|
9888
|
+
if (keep) await fs26.rename(final, path25.join(keep, `${skuId}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`));
|
|
9889
|
+
await fs26.rename(staging, final);
|
|
9890
|
+
return files;
|
|
9891
|
+
} finally {
|
|
9892
|
+
await fs26.rm(staging, { recursive: true, force: true });
|
|
9893
|
+
}
|
|
9894
|
+
}
|
|
9895
|
+
async remove(skuId) {
|
|
9896
|
+
const res = await this.game.call(`${this.allowances}/${encodeURIComponent(skuId)}`, { method: "DELETE" });
|
|
9897
|
+
if (!res.ok) return this.fail(res, "remove that item");
|
|
9898
|
+
const body = await res.json();
|
|
9899
|
+
if (this.opts.json) return this.printJson(body);
|
|
9900
|
+
this.log.success(`Removed ${c.cyan(skuId)} from this game's store.`);
|
|
9901
|
+
this.log.dim(" Players who own it keep it; in this game it shows as Not in this game.");
|
|
9902
|
+
this.log.dim(` ${ITEMS_DIR}/${skuId}/ is still on disk: take its code out of the game, then delete the folder.`);
|
|
9903
|
+
}
|
|
9904
|
+
async list() {
|
|
9905
|
+
const res = await this.game.call(this.allowances);
|
|
9906
|
+
if (!res.ok) return this.fail(res, "read this game's items");
|
|
9907
|
+
const body = await res.json();
|
|
9908
|
+
if (this.opts.json) return this.printJson(body);
|
|
9909
|
+
const { log } = this;
|
|
9910
|
+
if (body.items.length === 0) {
|
|
9911
|
+
log.plain("This game has added no shared items.");
|
|
9912
|
+
log.dim(` Find one: ${c.cyan('genex items search "<words>"')}`);
|
|
9913
|
+
return;
|
|
9914
|
+
}
|
|
9915
|
+
for (const { allowance, item } of body.items) {
|
|
9916
|
+
const author = item.author ? ` \xB7 by ${item.author.name}` : "";
|
|
9917
|
+
const live = allowance.liveFromBuildAt ? "" : c.dim(" \xB7 not live yet: it reaches the store with the next published build");
|
|
9918
|
+
log.plain(` ${c.cyan(item.id)} ${item.name} \u2014 ${coinPrice(item.priceCoins, item.priceDisplayUsdCents)}${author}${live}`);
|
|
9919
|
+
}
|
|
9920
|
+
}
|
|
9921
|
+
};
|
|
9922
|
+
var FolderRefused = class extends Error {
|
|
9923
|
+
};
|
|
9924
|
+
function packageRefusal(skuId, name, err) {
|
|
9925
|
+
return {
|
|
9926
|
+
error: "package_invalid",
|
|
9927
|
+
field: "package",
|
|
9928
|
+
message: `The package was refused: ${err.message}`,
|
|
9929
|
+
id: skuId,
|
|
9930
|
+
hints: [
|
|
9931
|
+
`${name} is added to this game, but nothing was written to ${ITEMS_DIR}/${skuId}/.`,
|
|
9932
|
+
`The package is its author's to fix, not yours. Try again later: ${c.cyan(`genex items add ${skuId}`)}`,
|
|
9933
|
+
"If it keeps failing, report the item as Doesn't work from its game's store, or pick another item."
|
|
9934
|
+
]
|
|
9935
|
+
};
|
|
9936
|
+
}
|
|
9937
|
+
function folderRefusal(skuId, name, err) {
|
|
9938
|
+
return {
|
|
9939
|
+
error: ItemErrorCode.FolderRefused,
|
|
9940
|
+
message: err.message,
|
|
9941
|
+
id: skuId,
|
|
9942
|
+
hints: [`${name} is added to this game, but nothing was written to ${ITEMS_DIR}/${skuId}/. Fix that, then run: genex items add ${skuId}`]
|
|
9943
|
+
};
|
|
9944
|
+
}
|
|
9945
|
+
async function lstatOrNull(p) {
|
|
9946
|
+
try {
|
|
9947
|
+
return await fs26.lstat(p);
|
|
9948
|
+
} catch (err) {
|
|
9949
|
+
if (err.code === "ENOENT") return null;
|
|
9950
|
+
throw err;
|
|
9951
|
+
}
|
|
9952
|
+
}
|
|
9953
|
+
async function replacedItemsDir(root) {
|
|
9954
|
+
await fs26.mkdir(path25.join(root, ".genex"), { recursive: true });
|
|
9955
|
+
const keep = path25.join(root, ...REPLACED_DIR.split("/"));
|
|
9956
|
+
await ensureRealDir(keep, REPLACED_DIR);
|
|
9957
|
+
return keep;
|
|
9958
|
+
}
|
|
9959
|
+
async function ensureRealDir(dir, label) {
|
|
9960
|
+
const stat = await lstatOrNull(dir);
|
|
9961
|
+
if (!stat) {
|
|
9962
|
+
await fs26.mkdir(dir);
|
|
8726
9963
|
return;
|
|
8727
9964
|
}
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
log.dim(" Players who already bought it keep it.");
|
|
9965
|
+
if (!stat.isDirectory()) {
|
|
9966
|
+
throw new FolderRefused(`${label} is a link or a file, not a folder. Move it aside first; nothing was written through it.`);
|
|
9967
|
+
}
|
|
8732
9968
|
}
|
|
8733
9969
|
|
|
8734
9970
|
// src/lib/promote.ts
|
|
@@ -8922,6 +10158,8 @@ async function runPublish(opts) {
|
|
|
8922
10158
|
process.exitCode = 1;
|
|
8923
10159
|
return;
|
|
8924
10160
|
}
|
|
10161
|
+
const listedOnDisk = await readProject();
|
|
10162
|
+
await writeProject({ ...listedOnDisk ?? meta, status: "published" });
|
|
8925
10163
|
const listed = await res.json().catch(() => null);
|
|
8926
10164
|
if (opts.regenerateCover && listed?.project?.coverAiEnabled === false) {
|
|
8927
10165
|
log.dim(" --regenerate-cover did nothing: this stand doesn't make AI covers.");
|
|
@@ -9217,8 +10455,8 @@ async function runRollback(opts) {
|
|
|
9217
10455
|
}
|
|
9218
10456
|
|
|
9219
10457
|
// src/commands/cover.ts
|
|
9220
|
-
import
|
|
9221
|
-
import
|
|
10458
|
+
import fs27 from "fs/promises";
|
|
10459
|
+
import path26 from "path";
|
|
9222
10460
|
var WHAT_A_COVER_IS = "one real frame of the game: its signature moment, real gameplay, well lit, no menus, popups or busy HUD, landscape 16:9, no text";
|
|
9223
10461
|
async function runCover(opts) {
|
|
9224
10462
|
const log = createLogger({ quiet: opts.quiet || opts.json });
|
|
@@ -9243,7 +10481,7 @@ async function runCover(opts) {
|
|
|
9243
10481
|
}
|
|
9244
10482
|
let bytes;
|
|
9245
10483
|
try {
|
|
9246
|
-
const stat = await
|
|
10484
|
+
const stat = await fs27.stat(file);
|
|
9247
10485
|
if (!stat.isFile()) throw new Error("not a file");
|
|
9248
10486
|
if (stat.size > COVER_MAX_BYTES) {
|
|
9249
10487
|
return finish(
|
|
@@ -9253,43 +10491,43 @@ async function runCover(opts) {
|
|
|
9253
10491
|
log
|
|
9254
10492
|
);
|
|
9255
10493
|
}
|
|
9256
|
-
bytes = await
|
|
10494
|
+
bytes = await fs27.readFile(file);
|
|
9257
10495
|
} catch {
|
|
9258
10496
|
return finish({ kind: "invalid", message: `Can't read ${file} \u2014 pass the path of a PNG, JPEG or WebP image.` }, file, opts, log);
|
|
9259
10497
|
}
|
|
9260
|
-
log.step(`Uploading ${
|
|
10498
|
+
log.step(`Uploading ${path26.basename(file)} as the cover\u2026`);
|
|
9261
10499
|
const outcome2 = await uploadCover({ apiUrl, token, projectId: meta.id, bytes, label: file });
|
|
9262
10500
|
await rememberNomination(bytes, file, outcome2);
|
|
9263
10501
|
finish(outcome2, file, opts, log);
|
|
9264
10502
|
}
|
|
9265
10503
|
async function rememberNomination(bytes, file, outcome2) {
|
|
9266
10504
|
const scratch = await findScratchCover();
|
|
9267
|
-
if (!scratch ||
|
|
10505
|
+
if (!scratch || path26.resolve(scratch) === path26.resolve(file)) {
|
|
9268
10506
|
await rememberCoverUpload(bytes, file, outcome2);
|
|
9269
10507
|
return;
|
|
9270
10508
|
}
|
|
9271
10509
|
if (outcome2.kind !== "applied" && outcome2.kind !== "outranked") return;
|
|
9272
|
-
const stale = await
|
|
10510
|
+
const stale = await fs27.readFile(scratch).catch(() => null);
|
|
9273
10511
|
if (stale) await rememberCoverUpload(stale, scratch, outcome2);
|
|
9274
10512
|
}
|
|
9275
10513
|
async function sendClip(apiUrl, token, projectId, clip, opts, log) {
|
|
9276
10514
|
const file = clip ?? "";
|
|
9277
|
-
const
|
|
9278
|
-
if (!clip) return
|
|
9279
|
-
if (opts.imagePath?.trim()) return
|
|
9280
|
-
if (!opts.commit?.trim()) return
|
|
10515
|
+
const invalid2 = (message) => finishClip({ kind: "invalid", message }, file, opts, log);
|
|
10516
|
+
if (!clip) return invalid2("--commit goes with --clip <file>: pass the recording to send.");
|
|
10517
|
+
if (opts.imagePath?.trim()) return invalid2("Pass either an image (the cover) or --clip <file> (the cover clip), not both.");
|
|
10518
|
+
if (!opts.commit?.trim()) return invalid2("--clip needs --commit <sha>: the git commit of the build the recording shows.");
|
|
9281
10519
|
let bytes;
|
|
9282
10520
|
try {
|
|
9283
|
-
const stat = await
|
|
10521
|
+
const stat = await fs27.stat(clip);
|
|
9284
10522
|
if (!stat.isFile()) throw new Error("not a file");
|
|
9285
10523
|
if (stat.size > CLIP_MAX_BYTES) {
|
|
9286
|
-
return
|
|
10524
|
+
return invalid2(`${clip} is ${(stat.size / 1024 / 1024).toFixed(1)} MB; a cover clip recording can be at most ${CLIP_MAX_BYTES / 1024 / 1024} MB.`);
|
|
9287
10525
|
}
|
|
9288
|
-
bytes = await
|
|
10526
|
+
bytes = await fs27.readFile(clip);
|
|
9289
10527
|
} catch {
|
|
9290
|
-
return
|
|
10528
|
+
return invalid2(`Can't read ${clip} \u2014 pass the path of an MP4 or WebM recording.`);
|
|
9291
10529
|
}
|
|
9292
|
-
log.step(`Uploading ${
|
|
10530
|
+
log.step(`Uploading ${path26.basename(clip)} as the cover clip\u2026`);
|
|
9293
10531
|
const outcome2 = await uploadClip({ apiUrl, token, projectId, bytes, label: clip, buildCommit: opts.commit });
|
|
9294
10532
|
finishClip(outcome2, clip, opts, log);
|
|
9295
10533
|
}
|
|
@@ -9347,8 +10585,8 @@ async function showCover(apiUrl, token, slug, opts, log) {
|
|
|
9347
10585
|
}
|
|
9348
10586
|
|
|
9349
10587
|
// src/commands/generate.ts
|
|
9350
|
-
import
|
|
9351
|
-
import
|
|
10588
|
+
import fs30 from "fs/promises";
|
|
10589
|
+
import path29 from "path";
|
|
9352
10590
|
import { PNG as PNG4 } from "pngjs";
|
|
9353
10591
|
|
|
9354
10592
|
// src/lib/glass.ts
|
|
@@ -9535,13 +10773,13 @@ function ceilingVerdict(input) {
|
|
|
9535
10773
|
}
|
|
9536
10774
|
|
|
9537
10775
|
// src/lib/generation-admission.ts
|
|
9538
|
-
import
|
|
9539
|
-
import
|
|
9540
|
-
import { createHash, randomUUID } from "crypto";
|
|
10776
|
+
import fs29 from "fs/promises";
|
|
10777
|
+
import path28 from "path";
|
|
10778
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
9541
10779
|
|
|
9542
10780
|
// src/lib/asset-budget.ts
|
|
9543
|
-
import
|
|
9544
|
-
import
|
|
10781
|
+
import fs28 from "fs/promises";
|
|
10782
|
+
import path27 from "path";
|
|
9545
10783
|
var DEFAULT_ASSET_BUDGET_SHARE = 0.5;
|
|
9546
10784
|
var DEFAULT_ASSET_BUDGET_CAP = 500;
|
|
9547
10785
|
function envShare(raw, fallback) {
|
|
@@ -9568,10 +10806,10 @@ function allowanceFor(input) {
|
|
|
9568
10806
|
if (!Number.isFinite(spendable) || spendable <= 0) return 0;
|
|
9569
10807
|
return Math.max(0, Math.min(Math.floor(spendable * config.share), config.cap));
|
|
9570
10808
|
}
|
|
9571
|
-
var storePath = (cwd) =>
|
|
10809
|
+
var storePath = (cwd) => path27.join(cwd, ".genex", "asset-budget.json");
|
|
9572
10810
|
async function readBudgetStore(cwd = process.cwd()) {
|
|
9573
10811
|
try {
|
|
9574
|
-
const raw = await
|
|
10812
|
+
const raw = await fs28.readFile(storePath(cwd), "utf8");
|
|
9575
10813
|
const parsed = JSON.parse(raw);
|
|
9576
10814
|
if (!parsed || parsed.v !== 1 || typeof parsed.allowanceCredits !== "number" || !Number.isFinite(parsed.allowanceCredits) || parsed.setBy !== "default" && parsed.setBy !== "user") {
|
|
9577
10815
|
return null;
|
|
@@ -9590,8 +10828,8 @@ async function readBudgetStore(cwd = process.cwd()) {
|
|
|
9590
10828
|
}
|
|
9591
10829
|
async function writeBudgetStore(cwd, store) {
|
|
9592
10830
|
try {
|
|
9593
|
-
await
|
|
9594
|
-
await
|
|
10831
|
+
await fs28.access(path27.join(cwd, ".genex"));
|
|
10832
|
+
await fs28.writeFile(storePath(cwd), `${JSON.stringify(store, null, 2)}
|
|
9595
10833
|
`, "utf8");
|
|
9596
10834
|
return true;
|
|
9597
10835
|
} catch {
|
|
@@ -9602,7 +10840,7 @@ async function ensureBudgetStore(cwd, input) {
|
|
|
9602
10840
|
const existing = await readBudgetStore(cwd);
|
|
9603
10841
|
if (existing) return existing;
|
|
9604
10842
|
try {
|
|
9605
|
-
await
|
|
10843
|
+
await fs28.stat(storePath(cwd));
|
|
9606
10844
|
throw new Error("Stored asset allowance is unreadable or invalid; reconcile it before paid generation.");
|
|
9607
10845
|
} catch (error) {
|
|
9608
10846
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -9706,13 +10944,13 @@ async function assetBudgetGate(input) {
|
|
|
9706
10944
|
|
|
9707
10945
|
// src/lib/generation-admission.ts
|
|
9708
10946
|
async function withAdmissionLock(cwd, work, waitMs = 3e4) {
|
|
9709
|
-
const lock =
|
|
9710
|
-
await
|
|
10947
|
+
const lock = path28.join(cwd, ".genex", "generation-admission.lock");
|
|
10948
|
+
await fs29.mkdir(path28.dirname(lock), { recursive: true });
|
|
9711
10949
|
const deadline = Date.now() + waitMs;
|
|
9712
10950
|
let file;
|
|
9713
10951
|
while (!file) {
|
|
9714
10952
|
try {
|
|
9715
|
-
file = await
|
|
10953
|
+
file = await fs29.open(lock, "wx");
|
|
9716
10954
|
} catch (error) {
|
|
9717
10955
|
if (error.code !== "EEXIST") throw error;
|
|
9718
10956
|
if (Date.now() >= deadline) throw new Error("Another generation admission holds .genex/generation-admission.lock. If its process died, reconcile pending reservations before removing that lock.");
|
|
@@ -9724,7 +10962,7 @@ async function withAdmissionLock(cwd, work, waitMs = 3e4) {
|
|
|
9724
10962
|
return await work();
|
|
9725
10963
|
} finally {
|
|
9726
10964
|
await file.close();
|
|
9727
|
-
await
|
|
10965
|
+
await fs29.unlink(lock);
|
|
9728
10966
|
}
|
|
9729
10967
|
}
|
|
9730
10968
|
async function enqueueWithAllowance(input) {
|
|
@@ -9734,7 +10972,7 @@ async function enqueueWithAllowance(input) {
|
|
|
9734
10972
|
const createPath = input.createPath ?? "/api/generations";
|
|
9735
10973
|
const generic = createPath === "/api/generations";
|
|
9736
10974
|
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${input.token}` };
|
|
9737
|
-
const fingerprint =
|
|
10975
|
+
const fingerprint = createHash2("sha256").update(JSON.stringify([input.apiUrl, createPath, input.body])).digest("hex");
|
|
9738
10976
|
const rows = await readAdmissionRows(cwd);
|
|
9739
10977
|
const pending2 = /* @__PURE__ */ new Map();
|
|
9740
10978
|
for (const row of rows) {
|
|
@@ -9973,7 +11211,7 @@ var isRemoteRef = (value) => /^(https?:\/\/|data:)/i.test(value);
|
|
|
9973
11211
|
async function inlineLocalImage(filePath, flag) {
|
|
9974
11212
|
let bytes;
|
|
9975
11213
|
try {
|
|
9976
|
-
bytes = await
|
|
11214
|
+
bytes = await fs30.readFile(filePath);
|
|
9977
11215
|
} catch {
|
|
9978
11216
|
return { ok: false, error: `Couldn't read the ${flag} file at ${filePath}.` };
|
|
9979
11217
|
}
|
|
@@ -9983,7 +11221,7 @@ async function inlineLocalImage(filePath, flag) {
|
|
|
9983
11221
|
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.`
|
|
9984
11222
|
};
|
|
9985
11223
|
}
|
|
9986
|
-
const mime = IMAGE_MIME_BY_EXT[
|
|
11224
|
+
const mime = IMAGE_MIME_BY_EXT[path29.extname(filePath).toLowerCase()] ?? "image/png";
|
|
9987
11225
|
return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
|
|
9988
11226
|
}
|
|
9989
11227
|
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.";
|
|
@@ -10178,7 +11416,7 @@ async function runGenerate(kind, opts) {
|
|
|
10178
11416
|
let typedPrompt = opts.prompt?.trim();
|
|
10179
11417
|
if (!typedPrompt && kind === "model" && opts.imageUrl) {
|
|
10180
11418
|
const ref = opts.imageUrl.startsWith("data:") ? "local image" : opts.imageUrl;
|
|
10181
|
-
typedPrompt = `from image: ${
|
|
11419
|
+
typedPrompt = `from image: ${path29.basename(ref).slice(0, 120)}`;
|
|
10182
11420
|
}
|
|
10183
11421
|
if (kind === "model" && opts.texture !== void 0 && !MODEL_TEXTURE_TIERS.includes(opts.texture)) {
|
|
10184
11422
|
log.error(`--texture ${opts.texture} is a character texture size. \`genex model\` takes a texture TIER: ${MODEL_TEXTURE_TIERS.join("|")} (default detailed).`);
|
|
@@ -10227,7 +11465,7 @@ async function runGenerate(kind, opts) {
|
|
|
10227
11465
|
return;
|
|
10228
11466
|
}
|
|
10229
11467
|
try {
|
|
10230
|
-
const bytes = await
|
|
11468
|
+
const bytes = await fs30.readFile(opts.inpaintUrl);
|
|
10231
11469
|
opts = { ...opts, inpaintUrl: `data:image/png;base64,${bytes.toString("base64")}` };
|
|
10232
11470
|
} catch {
|
|
10233
11471
|
log.error(`Couldn't read the --inpaint mask at ${opts.inpaintUrl}.`);
|
|
@@ -10396,7 +11634,7 @@ async function reportGlassTerminal(view, outDir, log, json) {
|
|
|
10396
11634
|
return;
|
|
10397
11635
|
}
|
|
10398
11636
|
await recordTerminal(view.id, "completed", files.map((f) => f.url));
|
|
10399
|
-
await
|
|
11637
|
+
await fs30.mkdir(outDir, { recursive: true });
|
|
10400
11638
|
const solved = [];
|
|
10401
11639
|
for (let i = 0; i < files.length; i++) {
|
|
10402
11640
|
const f = files[i];
|
|
@@ -10428,8 +11666,8 @@ async function reportGlassTerminal(view, outDir, log, json) {
|
|
|
10428
11666
|
});
|
|
10429
11667
|
continue;
|
|
10430
11668
|
}
|
|
10431
|
-
const outPath =
|
|
10432
|
-
await
|
|
11669
|
+
const outPath = path29.join(outDir, `glass-${i + 1}.png`);
|
|
11670
|
+
await fs30.writeFile(outPath, PNG4.sync.write(r.png));
|
|
10433
11671
|
solved.push({
|
|
10434
11672
|
path: outPath,
|
|
10435
11673
|
url: f.url,
|
|
@@ -11059,8 +12297,8 @@ function writeJson(value) {
|
|
|
11059
12297
|
}
|
|
11060
12298
|
|
|
11061
12299
|
// src/commands/model-sub.ts
|
|
11062
|
-
import
|
|
11063
|
-
import
|
|
12300
|
+
import fs31 from "fs/promises";
|
|
12301
|
+
import path30 from "path";
|
|
11064
12302
|
var MODEL_SUBCOMMANDS = ["segment", "rig", "animate", "import"];
|
|
11065
12303
|
function apiErrorMessage(data, fallback) {
|
|
11066
12304
|
if (typeof data !== "object" || data === null) return fallback;
|
|
@@ -11075,29 +12313,29 @@ async function importModelFile(args) {
|
|
|
11075
12313
|
const { log } = args;
|
|
11076
12314
|
const filePath = args.filePath.trim();
|
|
11077
12315
|
if (!/\.glb$/i.test(filePath)) {
|
|
11078
|
-
log.error(`\`import\` takes a .glb file (binary glTF 2.0). Export ${
|
|
12316
|
+
log.error(`\`import\` takes a .glb file (binary glTF 2.0). Export ${path30.basename(filePath) || "the model"} as GLB first \u2014 Blender: File \u2192 Export \u2192 glTF 2.0, format "glTF Binary".`);
|
|
11079
12317
|
return null;
|
|
11080
12318
|
}
|
|
11081
12319
|
let bytes;
|
|
11082
12320
|
try {
|
|
11083
|
-
bytes = await
|
|
12321
|
+
bytes = await fs31.readFile(filePath);
|
|
11084
12322
|
} catch {
|
|
11085
12323
|
log.error(`Couldn't read ${filePath}.`);
|
|
11086
12324
|
return null;
|
|
11087
12325
|
}
|
|
11088
12326
|
if (bytes.byteLength > MODEL_IMPORT_MAX_BYTES) {
|
|
11089
|
-
log.error(`${
|
|
12327
|
+
log.error(`${path30.basename(filePath)} is ${(bytes.byteLength / 1e6).toFixed(1)} MB; imports are capped at ${MODEL_IMPORT_MAX_BYTES / 1024 / 1024} MB. Shrink its textures (they are usually the bulk) and try again.`);
|
|
11090
12328
|
return null;
|
|
11091
12329
|
}
|
|
11092
12330
|
if (bytes.byteLength < 12 || bytes.readUInt32LE(0) !== GLB_MAGIC) {
|
|
11093
|
-
log.error(`${
|
|
12331
|
+
log.error(`${path30.basename(filePath)} is not a GLB (no glTF magic). A .gltf + .bin pair must be exported as one binary .glb.`);
|
|
11094
12332
|
return null;
|
|
11095
12333
|
}
|
|
11096
12334
|
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${args.token}` };
|
|
11097
12335
|
const minted = await apiFetch(`${args.apiUrl}/api/generations/import`, {
|
|
11098
12336
|
method: "POST",
|
|
11099
12337
|
headers,
|
|
11100
|
-
body: JSON.stringify({ filename:
|
|
12338
|
+
body: JSON.stringify({ filename: path30.basename(filePath), bytes: bytes.byteLength, contentType: "model/gltf-binary" })
|
|
11101
12339
|
});
|
|
11102
12340
|
if (printedStructuredError(minted)) return null;
|
|
11103
12341
|
if (!minted.ok) {
|
|
@@ -11106,7 +12344,7 @@ async function importModelFile(args) {
|
|
|
11106
12344
|
return null;
|
|
11107
12345
|
}
|
|
11108
12346
|
const { id, uploadUrl, url } = await minted.json();
|
|
11109
|
-
log.dim(` uploading ${
|
|
12347
|
+
log.dim(` uploading ${path30.basename(filePath)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
|
|
11110
12348
|
const put = await fetch(uploadUrl, {
|
|
11111
12349
|
method: "PUT",
|
|
11112
12350
|
headers: { "Content-Type": "model/gltf-binary", "Content-Length": String(bytes.byteLength) },
|
|
@@ -11254,7 +12492,7 @@ async function runModelAnimate(opts) {
|
|
|
11254
12492
|
}
|
|
11255
12493
|
|
|
11256
12494
|
// src/commands/wait.ts
|
|
11257
|
-
import
|
|
12495
|
+
import path31 from "path";
|
|
11258
12496
|
var MAX_TIMEOUT_SEC = 2e6;
|
|
11259
12497
|
var STILL_RUNNING_EXIT = 2;
|
|
11260
12498
|
var TERMINAL2 = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
@@ -11461,7 +12699,7 @@ async function runWaitAll(opts) {
|
|
|
11461
12699
|
if (v?.status !== "completed" || !v.files?.length) continue;
|
|
11462
12700
|
const local = localDeliveryFor("tools", { ...opts, outDir: opts.outDir ?? e.outDir }, e.prompt || e.kind);
|
|
11463
12701
|
if (!local) continue;
|
|
11464
|
-
const outDir =
|
|
12702
|
+
const outDir = path31.join(path31.relative(process.cwd(), cwd) || ".", local.outDir);
|
|
11465
12703
|
const target = { kind: e.kind, prompt: local.prompt, id: e.id, outDir };
|
|
11466
12704
|
const missing = await undeliveredFiles(v.files, target);
|
|
11467
12705
|
if (missing.length === 0) continue;
|
|
@@ -11573,11 +12811,11 @@ async function toRow(e, v, cwd) {
|
|
|
11573
12811
|
}
|
|
11574
12812
|
|
|
11575
12813
|
// src/commands/controller.ts
|
|
11576
|
-
import
|
|
11577
|
-
import
|
|
12814
|
+
import fs33 from "fs/promises";
|
|
12815
|
+
import path33 from "path";
|
|
11578
12816
|
|
|
11579
12817
|
// ../../packages/meshy-animation-catalog/src/index.ts
|
|
11580
|
-
import { createHash as
|
|
12818
|
+
import { createHash as createHash3 } from "crypto";
|
|
11581
12819
|
|
|
11582
12820
|
// ../../packages/meshy-animation-catalog/src/generated.ts
|
|
11583
12821
|
var MESHY_ANIMATION_CATALOG = [
|
|
@@ -20295,7 +21533,7 @@ function normalizedPackDefinition(definition) {
|
|
|
20295
21533
|
return { key, version: definition.version, actionIds, bindings };
|
|
20296
21534
|
}
|
|
20297
21535
|
function packFingerprint(definition) {
|
|
20298
|
-
return
|
|
21536
|
+
return createHash3("sha256").update(JSON.stringify(definition)).digest("hex");
|
|
20299
21537
|
}
|
|
20300
21538
|
function createMeshyControllerPackSnapshot(definition) {
|
|
20301
21539
|
const normalized = normalizedPackDefinition(definition);
|
|
@@ -20418,9 +21656,9 @@ function searchMeshyAnimations(query, options = {}) {
|
|
|
20418
21656
|
}
|
|
20419
21657
|
|
|
20420
21658
|
// src/lib/anims.ts
|
|
20421
|
-
import
|
|
20422
|
-
import
|
|
20423
|
-
var ANIMS_DEST =
|
|
21659
|
+
import fs32 from "fs/promises";
|
|
21660
|
+
import path32 from "path";
|
|
21661
|
+
var ANIMS_DEST = path32.join("public", "assets", "anims");
|
|
20424
21662
|
var HIDDEN_TAG = "reference";
|
|
20425
21663
|
async function runAnims(opts) {
|
|
20426
21664
|
const log = createLogger({ quiet: opts.quiet });
|
|
@@ -20436,7 +21674,7 @@ async function runAnims(opts) {
|
|
|
20436
21674
|
printCatalog(log, manifest, selectors);
|
|
20437
21675
|
return;
|
|
20438
21676
|
}
|
|
20439
|
-
const controllerMarker =
|
|
21677
|
+
const controllerMarker = path32.join(root, "src", "controllers", "character");
|
|
20440
21678
|
if (!await exists3(controllerMarker)) {
|
|
20441
21679
|
log.error(
|
|
20442
21680
|
`No character controller in this game (missing ${c.cyan("src/controllers/character/")}).`
|
|
@@ -20445,11 +21683,11 @@ async function runAnims(opts) {
|
|
|
20445
21683
|
process.exitCode = 1;
|
|
20446
21684
|
return;
|
|
20447
21685
|
}
|
|
20448
|
-
const destDir =
|
|
20449
|
-
const gameManifestPath =
|
|
21686
|
+
const destDir = path32.join(root, ANIMS_DEST);
|
|
21687
|
+
const gameManifestPath = path32.join(destDir, "manifest.json");
|
|
20450
21688
|
if (opts.reset) {
|
|
20451
|
-
await
|
|
20452
|
-
log.step(`Cleared ${c.cyan(ANIMS_DEST +
|
|
21689
|
+
await fs32.rm(destDir, { recursive: true, force: true });
|
|
21690
|
+
log.step(`Cleared ${c.cyan(ANIMS_DEST + path32.sep)} (--reset)`);
|
|
20453
21691
|
}
|
|
20454
21692
|
if (selectors.length === 0) {
|
|
20455
21693
|
const installed = await readGameManifest(gameManifestPath);
|
|
@@ -20475,7 +21713,7 @@ async function runAnims(opts) {
|
|
|
20475
21713
|
return;
|
|
20476
21714
|
}
|
|
20477
21715
|
const coreNames = new Set(manifest.core);
|
|
20478
|
-
const
|
|
21716
|
+
const byName2 = /* @__PURE__ */ new Map();
|
|
20479
21717
|
let bundledSkips = 0;
|
|
20480
21718
|
for (const entries of resolved.values()) {
|
|
20481
21719
|
for (const entry of entries) {
|
|
@@ -20483,39 +21721,39 @@ async function runAnims(opts) {
|
|
|
20483
21721
|
bundledSkips++;
|
|
20484
21722
|
continue;
|
|
20485
21723
|
}
|
|
20486
|
-
|
|
21724
|
+
byName2.set(entry.name, entry);
|
|
20487
21725
|
}
|
|
20488
21726
|
}
|
|
20489
|
-
const wanted = [...
|
|
20490
|
-
const cacheDir =
|
|
21727
|
+
const wanted = [...byName2.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
21728
|
+
const cacheDir = path32.join(
|
|
20491
21729
|
opts.cacheDir ?? getAnimsCacheDir(),
|
|
20492
21730
|
`${manifest.library}-v${manifest.version}`
|
|
20493
21731
|
);
|
|
20494
|
-
await
|
|
20495
|
-
await
|
|
21732
|
+
await fs32.mkdir(cacheDir, { recursive: true });
|
|
21733
|
+
await fs32.mkdir(destDir, { recursive: true });
|
|
20496
21734
|
const base = getAnimsBase(opts.animsBase);
|
|
20497
21735
|
let installedCount = 0;
|
|
20498
21736
|
let presentCount = 0;
|
|
20499
21737
|
let addedBytes = 0;
|
|
20500
21738
|
const failures = [];
|
|
20501
21739
|
for (const entry of wanted) {
|
|
20502
|
-
const dest =
|
|
21740
|
+
const dest = path32.join(destDir, entry.file);
|
|
20503
21741
|
if (await hasSize(dest, entry.bytes)) {
|
|
20504
21742
|
presentCount++;
|
|
20505
21743
|
continue;
|
|
20506
21744
|
}
|
|
20507
21745
|
try {
|
|
20508
|
-
const cached =
|
|
21746
|
+
const cached = path32.join(cacheDir, entry.file);
|
|
20509
21747
|
if (!await hasSize(cached, entry.bytes)) {
|
|
20510
21748
|
const res = await fetch(base + entry.file);
|
|
20511
21749
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
20512
21750
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
20513
|
-
await
|
|
21751
|
+
await fs32.writeFile(cached, buf);
|
|
20514
21752
|
}
|
|
20515
|
-
await
|
|
21753
|
+
await fs32.copyFile(cached, dest);
|
|
20516
21754
|
installedCount++;
|
|
20517
21755
|
addedBytes += entry.bytes;
|
|
20518
|
-
log.dim(` ${
|
|
21756
|
+
log.dim(` ${path32.join(ANIMS_DEST, entry.file)} (${formatMb(entry.bytes)})`);
|
|
20519
21757
|
} catch (err) {
|
|
20520
21758
|
failures.push(`${entry.name} (${err instanceof Error ? err.message : String(err)})`);
|
|
20521
21759
|
}
|
|
@@ -20531,13 +21769,13 @@ async function runAnims(opts) {
|
|
|
20531
21769
|
version: manifest.version,
|
|
20532
21770
|
clips: [...union].sort((a, b) => a.localeCompare(b))
|
|
20533
21771
|
};
|
|
20534
|
-
await
|
|
21772
|
+
await fs32.writeFile(gameManifestPath, JSON.stringify(gameManifest, null, 2) + "\n");
|
|
20535
21773
|
log.plain("");
|
|
20536
21774
|
const parts = [`${installedCount} clip${installedCount === 1 ? "" : "s"} installed`];
|
|
20537
21775
|
if (presentCount > 0) parts.push(`${presentCount} already present`);
|
|
20538
21776
|
if (bundledSkips > 0) parts.push(`${bundledSkips} already bundled in animation-library.glb`);
|
|
20539
21777
|
log.success(
|
|
20540
|
-
`${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST +
|
|
21778
|
+
`${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path32.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
|
|
20541
21779
|
);
|
|
20542
21780
|
for (const [selector, entries] of resolved) {
|
|
20543
21781
|
const names2 = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
|
|
@@ -20569,12 +21807,12 @@ async function loadManifest(baseOverride) {
|
|
|
20569
21807
|
}
|
|
20570
21808
|
} catch {
|
|
20571
21809
|
}
|
|
20572
|
-
const snapshotPath =
|
|
20573
|
-
const manifest = JSON.parse(await
|
|
21810
|
+
const snapshotPath = path32.join(getTemplatesDir(), "controllers", "assets", "anims-manifest.json");
|
|
21811
|
+
const manifest = JSON.parse(await fs32.readFile(snapshotPath, "utf8"));
|
|
20574
21812
|
return { manifest, source: "snapshot" };
|
|
20575
21813
|
}
|
|
20576
21814
|
function resolveSelectors(manifest, selectors) {
|
|
20577
|
-
const
|
|
21815
|
+
const byName2 = new Map(manifest.clips.map((entry) => [entry.name, entry]));
|
|
20578
21816
|
const byLowerName = new Map(manifest.clips.map((entry) => [entry.name.toLowerCase(), entry]));
|
|
20579
21817
|
const tags2 = /* @__PURE__ */ new Map();
|
|
20580
21818
|
for (const entry of manifest.clips) {
|
|
@@ -20586,7 +21824,7 @@ function resolveSelectors(manifest, selectors) {
|
|
|
20586
21824
|
}
|
|
20587
21825
|
const out = /* @__PURE__ */ new Map();
|
|
20588
21826
|
for (const selector of selectors) {
|
|
20589
|
-
const exact =
|
|
21827
|
+
const exact = byName2.get(selector) ?? byLowerName.get(selector.toLowerCase());
|
|
20590
21828
|
if (exact) {
|
|
20591
21829
|
out.set(selector, [exact]);
|
|
20592
21830
|
continue;
|
|
@@ -20596,7 +21834,7 @@ function resolveSelectors(manifest, selectors) {
|
|
|
20596
21834
|
out.set(selector, tagHit);
|
|
20597
21835
|
continue;
|
|
20598
21836
|
}
|
|
20599
|
-
const candidates = [...tags2.keys(), ...
|
|
21837
|
+
const candidates = [...tags2.keys(), ...byName2.keys()];
|
|
20600
21838
|
const close = suggest(selector, candidates);
|
|
20601
21839
|
throw new Error(
|
|
20602
21840
|
`unknown clip/tag "${selector}"${close.length > 0 ? ` \u2014 closest: ${close.join(", ")}` : ""}. Run ${c.cyan(
|
|
@@ -20688,21 +21926,21 @@ function printCatalog(log, manifest, selectors) {
|
|
|
20688
21926
|
}
|
|
20689
21927
|
async function readGameManifest(file) {
|
|
20690
21928
|
try {
|
|
20691
|
-
return JSON.parse(await
|
|
21929
|
+
return JSON.parse(await fs32.readFile(file, "utf8"));
|
|
20692
21930
|
} catch {
|
|
20693
21931
|
return null;
|
|
20694
21932
|
}
|
|
20695
21933
|
}
|
|
20696
21934
|
async function hasSize(file, bytes) {
|
|
20697
21935
|
try {
|
|
20698
|
-
return (await
|
|
21936
|
+
return (await fs32.stat(file)).size === bytes;
|
|
20699
21937
|
} catch {
|
|
20700
21938
|
return false;
|
|
20701
21939
|
}
|
|
20702
21940
|
}
|
|
20703
21941
|
async function exists3(p) {
|
|
20704
21942
|
try {
|
|
20705
|
-
await
|
|
21943
|
+
await fs32.access(p);
|
|
20706
21944
|
return true;
|
|
20707
21945
|
} catch {
|
|
20708
21946
|
return false;
|
|
@@ -20903,8 +22141,8 @@ var CONTROLLER_FILE_SETS = {
|
|
|
20903
22141
|
]
|
|
20904
22142
|
}
|
|
20905
22143
|
};
|
|
20906
|
-
var CODE_DEST =
|
|
20907
|
-
var ASSETS_DEST =
|
|
22144
|
+
var CODE_DEST = path33.join("src", "controllers");
|
|
22145
|
+
var ASSETS_DEST = path33.join("public", "assets");
|
|
20908
22146
|
async function runController(opts) {
|
|
20909
22147
|
const log = createLogger({ quiet: opts.quiet });
|
|
20910
22148
|
if (opts.kind?.trim() === "anims") {
|
|
@@ -20921,31 +22159,31 @@ async function runController(opts) {
|
|
|
20921
22159
|
process.exitCode = 1;
|
|
20922
22160
|
return;
|
|
20923
22161
|
}
|
|
20924
|
-
const srcDir =
|
|
22162
|
+
const srcDir = path33.join(getTemplatesDir(), "controllers");
|
|
20925
22163
|
const root = opts.cwd ?? process.cwd();
|
|
20926
22164
|
const set = CONTROLLER_FILE_SETS[kind];
|
|
20927
22165
|
log.plain(c.bold(`genex controller ${kind}`));
|
|
20928
22166
|
log.plain("");
|
|
20929
|
-
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST +
|
|
22167
|
+
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path33.sep)}`);
|
|
20930
22168
|
const plan = [
|
|
20931
|
-
...set.code.map((rel) => ({ from: rel, rel:
|
|
22169
|
+
...set.code.map((rel) => ({ from: rel, rel: path33.join(CODE_DEST, rel) })),
|
|
20932
22170
|
...set.assets.map((rel) => ({
|
|
20933
22171
|
from: rel,
|
|
20934
|
-
rel:
|
|
22172
|
+
rel: path33.join(ASSETS_DEST, path33.basename(rel))
|
|
20935
22173
|
}))
|
|
20936
22174
|
];
|
|
20937
22175
|
let copied = 0;
|
|
20938
22176
|
let skipped = 0;
|
|
20939
22177
|
try {
|
|
20940
22178
|
for (const file of plan) {
|
|
20941
|
-
const dest =
|
|
22179
|
+
const dest = path33.join(root, file.rel);
|
|
20942
22180
|
if (!opts.force && await exists4(dest)) {
|
|
20943
22181
|
skipped++;
|
|
20944
22182
|
log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
|
|
20945
22183
|
continue;
|
|
20946
22184
|
}
|
|
20947
|
-
await
|
|
20948
|
-
await
|
|
22185
|
+
await fs33.mkdir(path33.dirname(dest), { recursive: true });
|
|
22186
|
+
await fs33.copyFile(path33.join(srcDir, file.from), dest);
|
|
20949
22187
|
copied++;
|
|
20950
22188
|
log.dim(` ${file.rel}`);
|
|
20951
22189
|
}
|
|
@@ -20998,7 +22236,7 @@ async function runController(opts) {
|
|
|
20998
22236
|
for (const line of set.sketch) {
|
|
20999
22237
|
log.dim(` ${line}`);
|
|
21000
22238
|
}
|
|
21001
|
-
if (kind === "character" && !await exists4(
|
|
22239
|
+
if (kind === "character" && !await exists4(path33.join(root, ASSETS_DEST, "meshy-character.json"))) {
|
|
21002
22240
|
log.plain("");
|
|
21003
22241
|
log.plain(
|
|
21004
22242
|
` ${stepOffset + 3}. This game has no generated character yet \u2014 ${c.cyan(
|
|
@@ -21030,9 +22268,9 @@ async function installMeshyCharacterManifest(args) {
|
|
|
21030
22268
|
throw new Error("The API returned an invalid Meshy character manifest.");
|
|
21031
22269
|
}
|
|
21032
22270
|
assertCompleteMeshyControllerPack(manifest);
|
|
21033
|
-
const destination =
|
|
21034
|
-
await
|
|
21035
|
-
await
|
|
22271
|
+
const destination = path33.join(args.root, ASSETS_DEST, "meshy-character.json");
|
|
22272
|
+
await fs33.mkdir(path33.dirname(destination), { recursive: true });
|
|
22273
|
+
await fs33.writeFile(
|
|
21036
22274
|
destination,
|
|
21037
22275
|
`${JSON.stringify(manifest, null, 2)}
|
|
21038
22276
|
`
|
|
@@ -21177,14 +22415,14 @@ function assertCompleteMeshyControllerPack(manifest) {
|
|
|
21177
22415
|
}
|
|
21178
22416
|
async function installFallbackAvatar(args) {
|
|
21179
22417
|
const { root, srcDir, log } = args;
|
|
21180
|
-
const dest =
|
|
21181
|
-
await
|
|
21182
|
-
await
|
|
22418
|
+
const dest = path33.join(root, ASSETS_DEST, "avatar.vrm");
|
|
22419
|
+
await fs33.mkdir(path33.dirname(dest), { recursive: true });
|
|
22420
|
+
await fs33.copyFile(path33.join(srcDir, "assets", "default-avatar.vrm"), dest);
|
|
21183
22421
|
log.dim(" public/assets/avatar.vrm (fallback avatar \u2014 bundled CC0 default)");
|
|
21184
22422
|
}
|
|
21185
22423
|
async function exists4(p) {
|
|
21186
22424
|
try {
|
|
21187
|
-
await
|
|
22425
|
+
await fs33.access(p);
|
|
21188
22426
|
return true;
|
|
21189
22427
|
} catch {
|
|
21190
22428
|
return false;
|
|
@@ -21192,8 +22430,8 @@ async function exists4(p) {
|
|
|
21192
22430
|
}
|
|
21193
22431
|
|
|
21194
22432
|
// src/commands/character.ts
|
|
21195
|
-
import
|
|
21196
|
-
import
|
|
22433
|
+
import fs34 from "fs/promises";
|
|
22434
|
+
import path34 from "path";
|
|
21197
22435
|
function exactAnimation(selector) {
|
|
21198
22436
|
const trimmed = selector.trim();
|
|
21199
22437
|
if (/^-?\d+$/.test(trimmed)) return animationById(Number(trimmed));
|
|
@@ -21278,7 +22516,7 @@ function showAmbiguity(selector, candidates, log, json = false) {
|
|
|
21278
22516
|
}
|
|
21279
22517
|
process.exitCode = 1;
|
|
21280
22518
|
}
|
|
21281
|
-
var INSTALLED_MANIFEST =
|
|
22519
|
+
var INSTALLED_MANIFEST = path34.join("public", "assets", "meshy-character.json");
|
|
21282
22520
|
async function resolveAdoptTarget(selector) {
|
|
21283
22521
|
const trimmed = selector?.trim();
|
|
21284
22522
|
if (trimmed && !trimmed.endsWith(".json")) {
|
|
@@ -21287,7 +22525,7 @@ async function resolveAdoptTarget(selector) {
|
|
|
21287
22525
|
const file = trimmed ?? INSTALLED_MANIFEST;
|
|
21288
22526
|
let raw;
|
|
21289
22527
|
try {
|
|
21290
|
-
raw = await
|
|
22528
|
+
raw = await fs34.readFile(file, "utf8");
|
|
21291
22529
|
} catch {
|
|
21292
22530
|
return {
|
|
21293
22531
|
ok: false,
|
|
@@ -21412,7 +22650,7 @@ async function runCharacterImport(opts) {
|
|
|
21412
22650
|
opts,
|
|
21413
22651
|
ctx,
|
|
21414
22652
|
kind: "character",
|
|
21415
|
-
prompt: `Import ${
|
|
22653
|
+
prompt: `Import ${path34.basename(filePath)} as a rigged character`,
|
|
21416
22654
|
createPath: "/api/characters/import",
|
|
21417
22655
|
body,
|
|
21418
22656
|
quote: price,
|
|
@@ -21957,22 +23195,22 @@ async function context2(opts) {
|
|
|
21957
23195
|
if (!token) return null;
|
|
21958
23196
|
return { token, apiUrl };
|
|
21959
23197
|
}
|
|
21960
|
-
async function readVideo(
|
|
23198
|
+
async function readVideo(path49, log) {
|
|
21961
23199
|
let bytes;
|
|
21962
23200
|
try {
|
|
21963
|
-
bytes = await readFile(
|
|
23201
|
+
bytes = await readFile(path49);
|
|
21964
23202
|
} catch {
|
|
21965
|
-
log.error(`Can't read ${
|
|
23203
|
+
log.error(`Can't read ${path49}.`);
|
|
21966
23204
|
return null;
|
|
21967
23205
|
}
|
|
21968
23206
|
if (bytes.byteLength > MAX_VIDEO_BYTES) {
|
|
21969
|
-
log.error(`${basename(
|
|
23207
|
+
log.error(`${basename(path49)} is ${(bytes.byteLength / 1e6).toFixed(0)} MB \u2014 the limit is ${MAX_VIDEO_BYTES / 1e6} MB.`);
|
|
21970
23208
|
return null;
|
|
21971
23209
|
}
|
|
21972
23210
|
return bytes;
|
|
21973
23211
|
}
|
|
21974
|
-
async function uploadVideo(apiUrl, token, characterId,
|
|
21975
|
-
const contentType = /\.mov$/i.test(
|
|
23212
|
+
async function uploadVideo(apiUrl, token, characterId, path49, bytes, log) {
|
|
23213
|
+
const contentType = /\.mov$/i.test(path49) ? "video/quicktime" : "video/mp4";
|
|
21976
23214
|
const minted = await apiFetch(
|
|
21977
23215
|
`${apiUrl}/api/characters/${encodeURIComponent(characterId)}/motions/video-url`,
|
|
21978
23216
|
{
|
|
@@ -21987,7 +23225,7 @@ async function uploadVideo(apiUrl, token, characterId, path47, bytes, log) {
|
|
|
21987
23225
|
return null;
|
|
21988
23226
|
}
|
|
21989
23227
|
const { uploadUrl, videoUrl } = await minted.json();
|
|
21990
|
-
log.dim(` uploading ${basename(
|
|
23228
|
+
log.dim(` uploading ${basename(path49)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
|
|
21991
23229
|
const put = await fetch(uploadUrl, {
|
|
21992
23230
|
method: "PUT",
|
|
21993
23231
|
headers: { "Content-Type": contentType, "Content-Length": String(bytes.byteLength) },
|
|
@@ -22319,11 +23557,11 @@ function rank(items, query) {
|
|
|
22319
23557
|
}
|
|
22320
23558
|
|
|
22321
23559
|
// src/commands/motion.ts
|
|
22322
|
-
import
|
|
22323
|
-
import
|
|
23560
|
+
import fs35 from "fs/promises";
|
|
23561
|
+
import path35 from "path";
|
|
22324
23562
|
|
|
22325
23563
|
// src/lib/motion/npz.ts
|
|
22326
|
-
import
|
|
23564
|
+
import zlib2 from "zlib";
|
|
22327
23565
|
function decodeUcs4(b) {
|
|
22328
23566
|
let out = "";
|
|
22329
23567
|
for (let i = 0; i + 4 <= b.length; i += 4) {
|
|
@@ -22374,7 +23612,7 @@ function readNpz(buf) {
|
|
|
22374
23612
|
const lExtraLen = buf.readUInt16LE(lho + 28);
|
|
22375
23613
|
const dataStart = lho + 30 + lNameLen + lExtraLen;
|
|
22376
23614
|
const raw = buf.subarray(dataStart, dataStart + csize);
|
|
22377
|
-
const bytes = method === 8 ?
|
|
23615
|
+
const bytes = method === 8 ? zlib2.inflateRawSync(raw) : Buffer.from(raw);
|
|
22378
23616
|
if (method !== 0 && method !== 8) throw new Error(`unsupported ZIP method ${method} for ${name}`);
|
|
22379
23617
|
out[name.replace(/\.npy$/, "")] = readNpy(bytes);
|
|
22380
23618
|
off += 46 + nameLen + extraLen + cmtLen;
|
|
@@ -23571,7 +24809,7 @@ async function motionGen(opts, log) {
|
|
|
23571
24809
|
}
|
|
23572
24810
|
if (opts.constraintsPath !== void 0) {
|
|
23573
24811
|
try {
|
|
23574
|
-
const raw = await
|
|
24812
|
+
const raw = await fs35.readFile(opts.constraintsPath, "utf8");
|
|
23575
24813
|
generationOptions.constraints = JSON.parse(raw);
|
|
23576
24814
|
} catch {
|
|
23577
24815
|
log.error(`Couldn't read the --constraints JSON at ${opts.constraintsPath}.`);
|
|
@@ -23595,10 +24833,10 @@ async function motionGen(opts, log) {
|
|
|
23595
24833
|
async function expandTakes(selectors) {
|
|
23596
24834
|
const out = [];
|
|
23597
24835
|
for (const sel of selectors) {
|
|
23598
|
-
const st = await
|
|
24836
|
+
const st = await fs35.stat(sel).catch(() => null);
|
|
23599
24837
|
if (st?.isDirectory()) {
|
|
23600
|
-
const names2 = await
|
|
23601
|
-
for (const n of names2.sort()) if (n.endsWith(".npz")) out.push(
|
|
24838
|
+
const names2 = await fs35.readdir(sel);
|
|
24839
|
+
for (const n of names2.sort()) if (n.endsWith(".npz")) out.push(path35.join(sel, n));
|
|
23602
24840
|
} else if (st?.isFile()) {
|
|
23603
24841
|
out.push(sel);
|
|
23604
24842
|
} else {
|
|
@@ -23633,7 +24871,7 @@ async function motionVerify(opts, log) {
|
|
|
23633
24871
|
let gates = DEFAULT_GATES;
|
|
23634
24872
|
if (opts.gatesPath) {
|
|
23635
24873
|
try {
|
|
23636
|
-
gates = mergeGates(DEFAULT_GATES, JSON.parse(await
|
|
24874
|
+
gates = mergeGates(DEFAULT_GATES, JSON.parse(await fs35.readFile(opts.gatesPath, "utf8")));
|
|
23637
24875
|
} catch {
|
|
23638
24876
|
log.error(`Couldn't read the --gates JSON at ${opts.gatesPath}.`);
|
|
23639
24877
|
process.exitCode = 1;
|
|
@@ -23655,9 +24893,9 @@ async function motionVerify(opts, log) {
|
|
|
23655
24893
|
}
|
|
23656
24894
|
const reports = [];
|
|
23657
24895
|
for (const file of files) {
|
|
23658
|
-
const stem =
|
|
24896
|
+
const stem = path35.basename(file).replace(/\.npz$/, "");
|
|
23659
24897
|
try {
|
|
23660
|
-
reports.push(analyzeTake(stem, await
|
|
24898
|
+
reports.push(analyzeTake(stem, await fs35.readFile(file), gates));
|
|
23661
24899
|
} catch (err) {
|
|
23662
24900
|
reports.push({
|
|
23663
24901
|
take: stem,
|
|
@@ -23695,7 +24933,7 @@ async function motionCompile(opts, log) {
|
|
|
23695
24933
|
let cfg = DEFAULT_MOTION_CONFIG;
|
|
23696
24934
|
if (opts.configPath) {
|
|
23697
24935
|
try {
|
|
23698
|
-
const patch = JSON.parse(await
|
|
24936
|
+
const patch = JSON.parse(await fs35.readFile(opts.configPath, "utf8"));
|
|
23699
24937
|
cfg = { ...DEFAULT_MOTION_CONFIG, ...patch, idleLoop: { ...DEFAULT_MOTION_CONFIG.idleLoop, ...patch.idleLoop } };
|
|
23700
24938
|
} catch {
|
|
23701
24939
|
log.error(`Couldn't read the --config JSON at ${opts.configPath}.`);
|
|
@@ -23713,16 +24951,16 @@ async function motionCompile(opts, log) {
|
|
|
23713
24951
|
}
|
|
23714
24952
|
const inputs = [];
|
|
23715
24953
|
for (const file of files) {
|
|
23716
|
-
const stem =
|
|
24954
|
+
const stem = path35.basename(file).replace(/\.npz$/, "");
|
|
23717
24955
|
try {
|
|
23718
|
-
inputs.push({ stem, take: loadTake(await
|
|
24956
|
+
inputs.push({ stem, take: loadTake(await fs35.readFile(file)) });
|
|
23719
24957
|
} catch (err) {
|
|
23720
24958
|
log.error(`${stem}: ${err instanceof Error ? err.message : String(err)}`);
|
|
23721
24959
|
process.exitCode = 1;
|
|
23722
24960
|
return;
|
|
23723
24961
|
}
|
|
23724
24962
|
}
|
|
23725
|
-
const setName = opts.set ??
|
|
24963
|
+
const setName = opts.set ?? path35.basename(opts.out).replace(/\.json$/, "");
|
|
23726
24964
|
let result;
|
|
23727
24965
|
try {
|
|
23728
24966
|
result = compileSet(inputs, setName, cfg);
|
|
@@ -23737,9 +24975,9 @@ async function motionCompile(opts, log) {
|
|
|
23737
24975
|
process.exitCode = 1;
|
|
23738
24976
|
return;
|
|
23739
24977
|
}
|
|
23740
|
-
await
|
|
24978
|
+
await fs35.mkdir(path35.dirname(path35.resolve(opts.out)), { recursive: true });
|
|
23741
24979
|
const json = JSON.stringify(result.data);
|
|
23742
|
-
await
|
|
24980
|
+
await fs35.writeFile(opts.out, json);
|
|
23743
24981
|
if (opts.json) {
|
|
23744
24982
|
writeJson({ out: opts.out, set: setName, tracks: Object.keys(result.data.gaits), bytes: json.length });
|
|
23745
24983
|
return;
|
|
@@ -23757,9 +24995,9 @@ var MOTION_RUNTIME_FILES = [
|
|
|
23757
24995
|
var MOTION_PRESETS = {
|
|
23758
24996
|
rifle: ["sets/rifle.json", "sets/jumps.json"]
|
|
23759
24997
|
};
|
|
23760
|
-
var MOTION_DEST =
|
|
24998
|
+
var MOTION_DEST = path35.join("src", "motion");
|
|
23761
24999
|
async function motionInstall(opts, log) {
|
|
23762
|
-
const srcDir =
|
|
25000
|
+
const srcDir = path35.join(getTemplatesDir(), "motion");
|
|
23763
25001
|
const root = opts.cwd ?? process.cwd();
|
|
23764
25002
|
const preset = opts.set;
|
|
23765
25003
|
if (preset !== void 0 && !MOTION_PRESETS[preset]) {
|
|
@@ -23770,21 +25008,21 @@ async function motionInstall(opts, log) {
|
|
|
23770
25008
|
const files = [...MOTION_RUNTIME_FILES, ...preset ? MOTION_PRESETS[preset] : []];
|
|
23771
25009
|
log.plain(c.bold(`genex motion install${preset ? ` --set ${preset}` : ""}`));
|
|
23772
25010
|
log.plain("");
|
|
23773
|
-
log.step(`Vendoring the motion runtime into ${c.cyan(MOTION_DEST +
|
|
25011
|
+
log.step(`Vendoring the motion runtime into ${c.cyan(MOTION_DEST + path35.sep)}`);
|
|
23774
25012
|
let copied = 0, skipped = 0;
|
|
23775
25013
|
try {
|
|
23776
25014
|
for (const rel of files) {
|
|
23777
|
-
const dest =
|
|
23778
|
-
const exists5 = await
|
|
25015
|
+
const dest = path35.join(root, MOTION_DEST, rel);
|
|
25016
|
+
const exists5 = await fs35.access(dest).then(() => true, () => false);
|
|
23779
25017
|
if (!opts.force && exists5) {
|
|
23780
25018
|
skipped++;
|
|
23781
|
-
log.dim(` skipped ${
|
|
25019
|
+
log.dim(` skipped ${path35.join(MOTION_DEST, rel)} (exists \u2014 use --force to overwrite)`);
|
|
23782
25020
|
continue;
|
|
23783
25021
|
}
|
|
23784
|
-
await
|
|
23785
|
-
await
|
|
25022
|
+
await fs35.mkdir(path35.dirname(dest), { recursive: true });
|
|
25023
|
+
await fs35.copyFile(path35.join(srcDir, rel), dest);
|
|
23786
25024
|
copied++;
|
|
23787
|
-
log.dim(` ${
|
|
25025
|
+
log.dim(` ${path35.join(MOTION_DEST, rel)}`);
|
|
23788
25026
|
}
|
|
23789
25027
|
} catch (err) {
|
|
23790
25028
|
log.error(`Copy failed: ${String(err)}`);
|
|
@@ -23825,7 +25063,7 @@ async function motionConstraints(opts, log) {
|
|
|
23825
25063
|
}
|
|
23826
25064
|
const doc = directionConstraint(dir, speed, duration);
|
|
23827
25065
|
const out = opts.out ?? "constraints.json";
|
|
23828
|
-
await
|
|
25066
|
+
await fs35.writeFile(out, JSON.stringify(doc));
|
|
23829
25067
|
if (opts.json) {
|
|
23830
25068
|
writeJson({ out, dir: dirName, speed, duration, waypoints: doc[0].frame_indices.length });
|
|
23831
25069
|
return;
|
|
@@ -23862,14 +25100,14 @@ async function runMotion(opts) {
|
|
|
23862
25100
|
}
|
|
23863
25101
|
|
|
23864
25102
|
// src/commands/blender.ts
|
|
23865
|
-
import
|
|
23866
|
-
import
|
|
23867
|
-
var
|
|
25103
|
+
import fs36 from "fs/promises";
|
|
25104
|
+
import path36 from "path";
|
|
25105
|
+
var SUBS3 = ["demo", "exec", "snap", "scene", "import", "export", "reset", "mcp", "serve", "seat", "release"];
|
|
23868
25106
|
var DEFAULT_OUT_DIR = "assets/blender";
|
|
23869
25107
|
async function writeB64(dir, name, b64) {
|
|
23870
|
-
await
|
|
23871
|
-
const p =
|
|
23872
|
-
await
|
|
25108
|
+
await fs36.mkdir(dir, { recursive: true });
|
|
25109
|
+
const p = path36.join(dir, name);
|
|
25110
|
+
await fs36.writeFile(p, Buffer.from(b64, "base64"));
|
|
23873
25111
|
return p;
|
|
23874
25112
|
}
|
|
23875
25113
|
function reportScene(log, s) {
|
|
@@ -23881,12 +25119,12 @@ function reportScene(log, s) {
|
|
|
23881
25119
|
async function runBlender(opts) {
|
|
23882
25120
|
const log = createLogger();
|
|
23883
25121
|
const sub = opts.sub;
|
|
23884
|
-
if (!sub || !
|
|
23885
|
-
log.error(`Usage: genex blender <${
|
|
25122
|
+
if (!sub || !SUBS3.includes(sub)) {
|
|
25123
|
+
log.error(`Usage: genex blender <${SUBS3.join("|")}>`);
|
|
23886
25124
|
return 1;
|
|
23887
25125
|
}
|
|
23888
25126
|
if (sub === "serve") {
|
|
23889
|
-
const { serveLocalBlender } = await import("./blender-serve-
|
|
25127
|
+
const { serveLocalBlender } = await import("./blender-serve-EH2MOW3U.js");
|
|
23890
25128
|
const port = Number(process.env.GENEX_BLENDER_PORT ?? 8088);
|
|
23891
25129
|
log.step(`Starting a local Blender service on port ${port}`);
|
|
23892
25130
|
log.plain(
|
|
@@ -23895,7 +25133,7 @@ async function runBlender(opts) {
|
|
|
23895
25133
|
return serveLocalBlender({ port, log });
|
|
23896
25134
|
}
|
|
23897
25135
|
if (sub === "mcp") {
|
|
23898
|
-
const { runBlenderMcp } = await import("./blender-mcp-
|
|
25136
|
+
const { runBlenderMcp } = await import("./blender-mcp-WSURPZET.js");
|
|
23899
25137
|
return runBlenderMcp();
|
|
23900
25138
|
}
|
|
23901
25139
|
if (sub === "seat") {
|
|
@@ -23953,7 +25191,7 @@ async function runBlender(opts) {
|
|
|
23953
25191
|
log.plain(rest.join("\n"));
|
|
23954
25192
|
return 1;
|
|
23955
25193
|
}
|
|
23956
|
-
const outDir =
|
|
25194
|
+
const outDir = path36.resolve(opts.outDir ?? DEFAULT_OUT_DIR);
|
|
23957
25195
|
const mode = opts.mode;
|
|
23958
25196
|
if (mode !== void 0 && !isRenderMode(mode)) {
|
|
23959
25197
|
log.error(`Unknown --mode ${mode}. Use one of: ${RENDER_MODES.join(", ")}.`);
|
|
@@ -23993,14 +25231,14 @@ async function runBlender(opts) {
|
|
|
23993
25231
|
return 0;
|
|
23994
25232
|
}
|
|
23995
25233
|
case "export": {
|
|
23996
|
-
const target = opts.out ??
|
|
25234
|
+
const target = opts.out ?? path36.join(outDir, "scene.glb");
|
|
23997
25235
|
const r = await blenderCall(base, "/export", { path: "/tmp/genex-export.glb" });
|
|
23998
25236
|
if (!r.glbBase64) {
|
|
23999
25237
|
log.error(`/export answered with no GLB bytes${r.uploaded ? " (it was uploaded, not inlined)" : ""}`);
|
|
24000
25238
|
return 1;
|
|
24001
25239
|
}
|
|
24002
|
-
await
|
|
24003
|
-
await
|
|
25240
|
+
await fs36.mkdir(path36.dirname(target), { recursive: true });
|
|
25241
|
+
await fs36.writeFile(target, Buffer.from(r.glbBase64, "base64"));
|
|
24004
25242
|
log.success(`Exported ${r.bytes ?? 0} bytes`);
|
|
24005
25243
|
log.plain(` ${c.cyan(target)}`);
|
|
24006
25244
|
return 0;
|
|
@@ -24033,12 +25271,12 @@ async function runBlender(opts) {
|
|
|
24033
25271
|
return 1;
|
|
24034
25272
|
}
|
|
24035
25273
|
try {
|
|
24036
|
-
script = await
|
|
25274
|
+
script = await fs36.readFile(opts.input, "utf8");
|
|
24037
25275
|
} catch {
|
|
24038
25276
|
log.error(`Can't read ${opts.input}`);
|
|
24039
25277
|
return 1;
|
|
24040
25278
|
}
|
|
24041
|
-
label =
|
|
25279
|
+
label = path36.basename(opts.input);
|
|
24042
25280
|
}
|
|
24043
25281
|
const r = await blenderCall(base, "/exec", { script, ...mode ? { mode } : {}, sheet: { formats: SHEET_FORMATS } });
|
|
24044
25282
|
if (r.stdout?.trim()) log.plain(r.stdout.trimEnd());
|
|
@@ -24157,9 +25395,9 @@ print(f"castle: {n} objects")
|
|
|
24157
25395
|
`;
|
|
24158
25396
|
|
|
24159
25397
|
// src/commands/asset-new.ts
|
|
24160
|
-
import
|
|
25398
|
+
import fs37 from "fs";
|
|
24161
25399
|
import fsp from "fs/promises";
|
|
24162
|
-
import
|
|
25400
|
+
import path37 from "path";
|
|
24163
25401
|
import { pathToFileURL } from "url";
|
|
24164
25402
|
var EXTRA_FILES = [
|
|
24165
25403
|
"genex-asset.example.json",
|
|
@@ -24253,7 +25491,7 @@ Every milestone ends with \`npm run verify\`, then \`npx genex preview --no-buil
|
|
|
24253
25491
|
}
|
|
24254
25492
|
async function runAssetNew(options) {
|
|
24255
25493
|
const log = createLogger();
|
|
24256
|
-
const cwd = options.dir ?
|
|
25494
|
+
const cwd = options.dir ? path37.resolve(options.dir) : process.cwd();
|
|
24257
25495
|
const slug = options.assetSlug;
|
|
24258
25496
|
if (!slug) {
|
|
24259
25497
|
log.error('Usage: genex asset new <slug> [--title "<Name>"] [--dims WxDxH] [--tri-band LOW-HIGH] [--holder "<your name>"] [--out <dir>]');
|
|
@@ -24263,14 +25501,14 @@ async function runAssetNew(options) {
|
|
|
24263
25501
|
log.error(`The slug must be kebab-case (letters, digits, dashes), got: ${slug}`);
|
|
24264
25502
|
return 1;
|
|
24265
25503
|
}
|
|
24266
|
-
const templateDir =
|
|
24267
|
-
if (!
|
|
25504
|
+
const templateDir = path37.join(getTemplatesDir(), "asset-viewer");
|
|
25505
|
+
if (!fs37.existsSync(templateDir)) {
|
|
24268
25506
|
log.error(`Vendored asset-viewer template not found at ${templateDir} \u2014 reinstall the CLI.`);
|
|
24269
25507
|
return 1;
|
|
24270
25508
|
}
|
|
24271
|
-
const manifestTools = await import(pathToFileURL(
|
|
25509
|
+
const manifestTools = await import(pathToFileURL(path37.join(templateDir, "tools", "emit-manifest.mjs")).href);
|
|
24272
25510
|
const { SHARED_FILES, PARITY_FILENAME, hashSharedFiles, pascalCase } = manifestTools;
|
|
24273
|
-
const lockPath =
|
|
25511
|
+
const lockPath = path37.join(templateDir, "shared-files.sha256.json");
|
|
24274
25512
|
const lock = JSON.parse(await fsp.readFile(lockPath, "utf8"));
|
|
24275
25513
|
const actual = hashSharedFiles(templateDir);
|
|
24276
25514
|
const drifted = SHARED_FILES.filter((rel) => lock.files[rel] !== actual[rel]);
|
|
@@ -24283,8 +25521,8 @@ async function runAssetNew(options) {
|
|
|
24283
25521
|
const triBand = parseBand(options.triBand ?? "500-8000");
|
|
24284
25522
|
const holder = options.holder?.trim() || HOLDER_PLACEHOLDER;
|
|
24285
25523
|
const year = (/* @__PURE__ */ new Date()).getUTCFullYear();
|
|
24286
|
-
const outDir =
|
|
24287
|
-
if (
|
|
25524
|
+
const outDir = path37.resolve(cwd, options.out ?? slug);
|
|
25525
|
+
if (fs37.existsSync(outDir) && fs37.readdirSync(outDir).length > 0 && !options.force) {
|
|
24288
25526
|
log.error(`${outDir} already holds files. Nothing was touched \u2014 pass --force to write into it anyway.`);
|
|
24289
25527
|
return 1;
|
|
24290
25528
|
}
|
|
@@ -24312,24 +25550,24 @@ async function runAssetNew(options) {
|
|
|
24312
25550
|
};
|
|
24313
25551
|
await fsp.mkdir(outDir, { recursive: true });
|
|
24314
25552
|
for (const rel of [...SHARED_FILES, ...EXTRA_FILES]) {
|
|
24315
|
-
const to =
|
|
24316
|
-
await fsp.mkdir(
|
|
24317
|
-
await fsp.copyFile(
|
|
25553
|
+
const to = path37.join(outDir, rel);
|
|
25554
|
+
await fsp.mkdir(path37.dirname(to), { recursive: true });
|
|
25555
|
+
await fsp.copyFile(path37.join(templateDir, rel), to);
|
|
24318
25556
|
}
|
|
24319
|
-
const pkg = fillTemplate(await fsp.readFile(
|
|
25557
|
+
const pkg = fillTemplate(await fsp.readFile(path37.join(templateDir, "package.json"), "utf8"), {
|
|
24320
25558
|
slug,
|
|
24321
25559
|
name,
|
|
24322
25560
|
version
|
|
24323
25561
|
});
|
|
24324
|
-
await fsp.writeFile(
|
|
24325
|
-
await fsp.writeFile(
|
|
24326
|
-
await fsp.writeFile(
|
|
25562
|
+
await fsp.writeFile(path37.join(outDir, "package.json"), pkg, "utf8");
|
|
25563
|
+
await fsp.writeFile(path37.join(outDir, "asset.config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
25564
|
+
await fsp.writeFile(path37.join(outDir, ".gitignore"), GITIGNORE, "utf8");
|
|
24327
25565
|
await fsp.writeFile(
|
|
24328
|
-
|
|
25566
|
+
path37.join(outDir, "DESIGN.md"),
|
|
24329
25567
|
designDoc({ name, slug, sizeMeters, triBand, holder }),
|
|
24330
25568
|
"utf8"
|
|
24331
25569
|
);
|
|
24332
|
-
const placeholder = await fsp.readFile(
|
|
25570
|
+
const placeholder = await fsp.readFile(path37.join(templateDir, "src", "asset", "PLACEHOLDER.ts"), "utf8");
|
|
24333
25571
|
const seeded = seedAssetSource(placeholder, {
|
|
24334
25572
|
slug,
|
|
24335
25573
|
name,
|
|
@@ -24340,8 +25578,8 @@ async function runAssetNew(options) {
|
|
|
24340
25578
|
pascalCase
|
|
24341
25579
|
});
|
|
24342
25580
|
const assetFilename = `${slug.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase())}.ts`;
|
|
24343
|
-
await fsp.mkdir(
|
|
24344
|
-
await fsp.writeFile(
|
|
25581
|
+
await fsp.mkdir(path37.join(outDir, "src", "asset"), { recursive: true });
|
|
25582
|
+
await fsp.writeFile(path37.join(outDir, "src", "asset", assetFilename), seeded, "utf8");
|
|
24345
25583
|
const copied = hashSharedFiles(outDir);
|
|
24346
25584
|
const mismatched = SHARED_FILES.filter((rel) => copied[rel] !== lock.files[rel]);
|
|
24347
25585
|
if (mismatched.length) {
|
|
@@ -24349,7 +25587,7 @@ async function runAssetNew(options) {
|
|
|
24349
25587
|
return 1;
|
|
24350
25588
|
}
|
|
24351
25589
|
await fsp.writeFile(
|
|
24352
|
-
|
|
25590
|
+
path37.join(outDir, PARITY_FILENAME),
|
|
24353
25591
|
JSON.stringify(
|
|
24354
25592
|
{
|
|
24355
25593
|
note: "Recorded by `genex asset new`. Gate G13 recomputes these; do not hand-edit the viewer.",
|
|
@@ -24393,17 +25631,22 @@ async function runAssetNew(options) {
|
|
|
24393
25631
|
}
|
|
24394
25632
|
|
|
24395
25633
|
// src/commands/tools.ts
|
|
24396
|
-
import
|
|
25634
|
+
import path40 from "path";
|
|
24397
25635
|
|
|
24398
25636
|
// src/commands/doctor.ts
|
|
24399
|
-
import
|
|
25637
|
+
import path39 from "path";
|
|
24400
25638
|
|
|
24401
25639
|
// src/commands/llm.ts
|
|
24402
|
-
import
|
|
24403
|
-
import
|
|
25640
|
+
import fs38 from "fs/promises";
|
|
25641
|
+
import path38 from "path";
|
|
24404
25642
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
24405
|
-
var
|
|
24406
|
-
var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN
|
|
25643
|
+
var SUBS4 = ["models", "bench", "price", "status", "cancel"];
|
|
25644
|
+
var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN credits \u2014 your balance, not a player's, and a spend this build's asset allowance does not count. Re-run with --max-credits <n> --user-approved only after the person at the keyboard agreed to the number.";
|
|
25645
|
+
var LLM_MAX_COINS_DEPRECATED = "--max-coins is deprecated: it is read as --max-credits, the same number \u2014 a bench spends credits now.";
|
|
25646
|
+
function benchBalanceShortLine(spendable, maxCredits) {
|
|
25647
|
+
return `Your spendable credits (${spendable}) cannot cover one attempt at --max-credits ${maxCredits} \u2014 nothing was sent, nothing was spent. Add credits, or re-run with a lower --max-credits once the person at the keyboard agrees to it.`;
|
|
25648
|
+
}
|
|
25649
|
+
var LLM_BENCH_UNVERIFIED_LINE = "Credits pay only for a verified email, and this account's is not verified yet (credits_unverified) \u2014 nothing was sent, nothing was spent. Verify it, then re-run.";
|
|
24407
25650
|
var LLM_LANE_OFF_LINE = "The runtime LLM lane is off on this stand \u2014 in-game generate() answers 404 here, and nothing can be benchmarked.";
|
|
24408
25651
|
var LLM_TOOLS_CONVERT_HINT = [
|
|
24409
25652
|
"In-game model calls need a hosted game. This folder is a Genex Tools workspace, so nothing here",
|
|
@@ -24416,7 +25659,7 @@ function serverSlotCounts(body) {
|
|
|
24416
25659
|
return { held: body.slotsHeld, limit: body.slotLimit };
|
|
24417
25660
|
}
|
|
24418
25661
|
var PENDING_SLOT_RELEASE_MINUTES = 10;
|
|
24419
|
-
var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so
|
|
25662
|
+
var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so what it reserved stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
|
|
24420
25663
|
var LLM_STATUS_NO_COUNT_LINE = "This stand does not report how many slots are held across your projects, so no count is shown.";
|
|
24421
25664
|
var LLM_CANCEL_AWAITING_BILL = "this call already stopped; its bill is awaiting the provider";
|
|
24422
25665
|
var LLM_CANCEL_ALREADY_FINAL = "this call is already final";
|
|
@@ -24430,11 +25673,33 @@ var DEFAULT_SAMPLE_TIMEOUT_SEC = 180;
|
|
|
24430
25673
|
function parseProviderKey(value) {
|
|
24431
25674
|
return value === "ok" || value === "rejected" || value === "unknown" ? value : null;
|
|
24432
25675
|
}
|
|
25676
|
+
function bpsOrNull(value) {
|
|
25677
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
25678
|
+
}
|
|
25679
|
+
function standingCreditGrantOrNull(value) {
|
|
25680
|
+
if (!value || typeof value !== "object") return null;
|
|
25681
|
+
const v = value;
|
|
25682
|
+
const whole = (x) => typeof x === "number" && Number.isSafeInteger(x) && x >= 0;
|
|
25683
|
+
if (!whole(v.minCredits) || !whole(v.maxCredits) || !whole(v.stepCredits) || !whole(v.defaultCredits)) return null;
|
|
25684
|
+
return {
|
|
25685
|
+
minCredits: v.minCredits,
|
|
25686
|
+
maxCredits: v.maxCredits,
|
|
25687
|
+
stepCredits: v.stepCredits,
|
|
25688
|
+
defaultCredits: v.defaultCredits,
|
|
25689
|
+
...whole(v.minUsdCents) ? { minUsdCents: v.minUsdCents } : {},
|
|
25690
|
+
...whole(v.maxUsdCents) ? { maxUsdCents: v.maxUsdCents } : {},
|
|
25691
|
+
...whole(v.stepUsdCents) ? { stepUsdCents: v.stepUsdCents } : {}
|
|
25692
|
+
};
|
|
25693
|
+
}
|
|
24433
25694
|
async function readRuntimeLane(apiUrl, token) {
|
|
24434
25695
|
const empty = (state) => ({
|
|
24435
25696
|
state,
|
|
24436
25697
|
models: null,
|
|
24437
25698
|
recommendedDeclaredHeadroomBps: null,
|
|
25699
|
+
currency: null,
|
|
25700
|
+
feeBps: null,
|
|
25701
|
+
usdCentsPerCredit: null,
|
|
25702
|
+
standingCreditGrant: null,
|
|
24438
25703
|
externalProviders: null,
|
|
24439
25704
|
total: null,
|
|
24440
25705
|
featuredCount: null,
|
|
@@ -24453,7 +25718,11 @@ async function readRuntimeLane(apiUrl, token) {
|
|
|
24453
25718
|
return {
|
|
24454
25719
|
state: "live",
|
|
24455
25720
|
models: body.models,
|
|
24456
|
-
recommendedDeclaredHeadroomBps:
|
|
25721
|
+
recommendedDeclaredHeadroomBps: bpsOrNull(body.recommendedDeclaredHeadroomBps),
|
|
25722
|
+
currency: body.currency === "credits" || body.currency === "coins" ? body.currency : null,
|
|
25723
|
+
feeBps: bpsOrNull(body.feeBps),
|
|
25724
|
+
usdCentsPerCredit: typeof body.usdCentsPerCredit === "number" && Number.isSafeInteger(body.usdCentsPerCredit) && body.usdCentsPerCredit >= 1 ? body.usdCentsPerCredit : null,
|
|
25725
|
+
standingCreditGrant: standingCreditGrantOrNull(body.standingCreditGrant),
|
|
24457
25726
|
externalProviders: Array.isArray(body.externalProviders) ? body.externalProviders : null,
|
|
24458
25727
|
total: typeof body.total === "number" && Number.isFinite(body.total) ? body.total : null,
|
|
24459
25728
|
featuredCount: typeof body.featuredCount === "number" && Number.isFinite(body.featuredCount) ? body.featuredCount : null,
|
|
@@ -24469,18 +25738,45 @@ function percentile2(values, p) {
|
|
|
24469
25738
|
const rank2 = Math.ceil(p / 100 * sorted.length);
|
|
24470
25739
|
return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
|
|
24471
25740
|
}
|
|
24472
|
-
function
|
|
24473
|
-
|
|
25741
|
+
function percentilePicos(values, p) {
|
|
25742
|
+
if (values.length === 0) return null;
|
|
25743
|
+
const sorted = [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
25744
|
+
const rank2 = Math.ceil(p / 100 * sorted.length);
|
|
25745
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
|
|
24474
25746
|
}
|
|
24475
|
-
|
|
24476
|
-
|
|
25747
|
+
var PICOS_PER_CENT = 10000000000n;
|
|
25748
|
+
function ceilingCreditsFor(costPicos, terms) {
|
|
25749
|
+
if (costPicos <= 0n) return 1;
|
|
25750
|
+
const num = costPicos * BigInt(1e4 + terms.feeBps) * BigInt(1e4 + terms.headroomBps);
|
|
25751
|
+
const den = 100000000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
|
|
25752
|
+
return Math.max(1, Number((num + den - 1n) / den));
|
|
25753
|
+
}
|
|
25754
|
+
function averageCreditsPerCall(costsPicos, terms) {
|
|
25755
|
+
if (costsPicos.length === 0) return null;
|
|
25756
|
+
const sum = costsPicos.reduce((a, b) => a + b, 0n);
|
|
25757
|
+
return Number(sum * BigInt(1e4 + terms.feeBps)) / Number(BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT);
|
|
25758
|
+
}
|
|
25759
|
+
function perCallEstimateCreditsFor(costsPicos, terms) {
|
|
25760
|
+
if (costsPicos.length === 0) return null;
|
|
25761
|
+
const sum = costsPicos.reduce((a, b) => a + b, 0n);
|
|
25762
|
+
const num = sum * BigInt(1e4 + terms.feeBps);
|
|
25763
|
+
const den = BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
|
|
25764
|
+
return Math.max(1, Number((num + den - 1n) / den));
|
|
25765
|
+
}
|
|
25766
|
+
function formatCredits(value) {
|
|
25767
|
+
if (!Number.isFinite(value)) return "\u2014";
|
|
25768
|
+
if (value >= 100) return String(Math.round(value));
|
|
25769
|
+
return String(Number(value.toPrecision(value < 1 ? 2 : 3)));
|
|
25770
|
+
}
|
|
25771
|
+
function usdFromPicos(picos) {
|
|
25772
|
+
if (picos === null || picos === void 0) return "unknown";
|
|
25773
|
+
const value = typeof picos === "bigint" ? picos : /^\d+$/.test(picos) ? BigInt(picos) : null;
|
|
25774
|
+
if (value === null) return "unknown";
|
|
25775
|
+
return `$${(Number(value) / 1e12).toFixed(6)}`;
|
|
24477
25776
|
}
|
|
24478
25777
|
function usdPerMillion(value) {
|
|
24479
25778
|
return `$${value.toFixed(value < 1 ? 4 : 2)}`;
|
|
24480
25779
|
}
|
|
24481
|
-
function usd(value) {
|
|
24482
|
-
return value === null ? "unknown" : `$${value.toFixed(6)}`;
|
|
24483
|
-
}
|
|
24484
25780
|
async function runLlm(opts = {}) {
|
|
24485
25781
|
const log = createLogger({ quiet: opts.quiet || opts.json });
|
|
24486
25782
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -24490,8 +25786,8 @@ async function runLlm(opts = {}) {
|
|
|
24490
25786
|
else log.error(message);
|
|
24491
25787
|
process.exitCode = 1;
|
|
24492
25788
|
};
|
|
24493
|
-
if (!
|
|
24494
|
-
fail5(`llm ${sub}`, `Unknown subcommand ${sub}. Use: ${
|
|
25789
|
+
if (!SUBS4.includes(sub)) {
|
|
25790
|
+
fail5(`llm ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS4.join(", ")}.`);
|
|
24495
25791
|
return;
|
|
24496
25792
|
}
|
|
24497
25793
|
if (sub === "price") {
|
|
@@ -24506,16 +25802,23 @@ async function runLlm(opts = {}) {
|
|
|
24506
25802
|
return;
|
|
24507
25803
|
}
|
|
24508
25804
|
if (sub === "bench") {
|
|
24509
|
-
if (opts.maxCoins
|
|
25805
|
+
if (opts.maxCoins !== void 0) process.stderr.write(`${c.yellow("!")} ${LLM_MAX_COINS_DEPRECATED}
|
|
25806
|
+
`);
|
|
25807
|
+
if (opts.maxCoins !== void 0 && opts.maxCredits !== void 0 && opts.maxCoins !== opts.maxCredits) {
|
|
25808
|
+
fail5("llm bench", `Pass --max-credits alone \u2014 --max-coins is its deprecated name, and the two disagree (${opts.maxCredits} vs ${opts.maxCoins}).`);
|
|
25809
|
+
return;
|
|
25810
|
+
}
|
|
25811
|
+
const approvedCredits = opts.maxCredits ?? opts.maxCoins;
|
|
25812
|
+
if (approvedCredits === void 0 || opts.userApproved !== true) {
|
|
24510
25813
|
fail5("llm bench", LLM_BENCH_APPROVAL_REQUIRED);
|
|
24511
25814
|
return;
|
|
24512
25815
|
}
|
|
24513
|
-
if (!Number.isInteger(
|
|
24514
|
-
fail5("llm bench", `--max-
|
|
25816
|
+
if (!Number.isInteger(approvedCredits) || approvedCredits < 1) {
|
|
25817
|
+
fail5("llm bench", `--max-credits takes a whole number of credits, 1 or more (got ${String(approvedCredits)}).`);
|
|
24515
25818
|
return;
|
|
24516
25819
|
}
|
|
24517
25820
|
if (!opts.benchPrompt?.trim()) {
|
|
24518
|
-
fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-
|
|
25821
|
+
fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-credits <n> --user-approved.');
|
|
24519
25822
|
return;
|
|
24520
25823
|
}
|
|
24521
25824
|
if (opts.jsonOutput && opts.textOutput) {
|
|
@@ -24607,6 +25910,10 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
|
|
|
24607
25910
|
models: null,
|
|
24608
25911
|
externalProviders: null,
|
|
24609
25912
|
recommendedDeclaredHeadroomBps: null,
|
|
25913
|
+
currency: null,
|
|
25914
|
+
feeBps: null,
|
|
25915
|
+
usdCentsPerCredit: null,
|
|
25916
|
+
standingCreditGrant: null,
|
|
24610
25917
|
total: null,
|
|
24611
25918
|
featuredCount: null,
|
|
24612
25919
|
// The stand was never asked, so the verdict is unknown — but the key is
|
|
@@ -24632,6 +25939,12 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
|
|
|
24632
25939
|
models: lane.models,
|
|
24633
25940
|
externalProviders: lane.externalProviders,
|
|
24634
25941
|
recommendedDeclaredHeadroomBps: lane.recommendedDeclaredHeadroomBps,
|
|
25942
|
+
// The credit terms a call is billed on, the server's — null on a stand
|
|
25943
|
+
// that predates credit billing, never a number the CLI filled in.
|
|
25944
|
+
currency: lane.currency,
|
|
25945
|
+
feeBps: lane.feeBps,
|
|
25946
|
+
usdCentsPerCredit: lane.usdCentsPerCredit,
|
|
25947
|
+
standingCreditGrant: lane.standingCreditGrant,
|
|
24635
25948
|
total: lane.total ?? lane.models?.length ?? null,
|
|
24636
25949
|
featuredCount: lane.featuredCount ?? (lane.models ? lane.models.filter((m) => m.featured).length : null),
|
|
24637
25950
|
// `null` when the lane is not live or the server predates the probe.
|
|
@@ -24678,8 +25991,9 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
|
|
|
24678
25991
|
);
|
|
24679
25992
|
for (const m of shown) {
|
|
24680
25993
|
const plan = m.personalPlan ? ` \xB7 personal plan: ${m.personalPlan}` : "";
|
|
25994
|
+
const kind = m.kind === "typesafe" ? " \xB7 judge (classifier): judge() under a budget, not generate() or bench" : "";
|
|
24681
25995
|
log.plain(
|
|
24682
|
-
` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}`
|
|
25996
|
+
` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}${kind}`
|
|
24683
25997
|
);
|
|
24684
25998
|
}
|
|
24685
25999
|
if (hiddenCount > 0) {
|
|
@@ -24695,74 +26009,104 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
|
|
|
24695
26009
|
}
|
|
24696
26010
|
log.plain("");
|
|
24697
26011
|
if (lane.recommendedDeclaredHeadroomBps === null) {
|
|
24698
|
-
log.dim(" This stand serves no recommended headroom, so no
|
|
26012
|
+
log.dim(" This stand serves no recommended headroom, so no ceiling can be recommended from a bench.");
|
|
26013
|
+
} else {
|
|
26014
|
+
log.dim(` Recommended headroom over a benchmarked call's cost on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
|
|
26015
|
+
}
|
|
26016
|
+
if (lane.feeBps === null) {
|
|
26017
|
+
log.dim(" This stand serves no platform fee \u2014 it predates credit billing, so no ceiling can be recommended from a bench.");
|
|
24699
26018
|
} else {
|
|
24700
|
-
log.dim(`
|
|
26019
|
+
log.dim(` In-game calls are billed in the player's credits, as used: the provider's cost plus this stand's platform fee (${lane.feeBps} bps).`);
|
|
24701
26020
|
}
|
|
24702
26021
|
if (toolsOnly) {
|
|
24703
26022
|
printConvertHint();
|
|
24704
26023
|
return;
|
|
24705
26024
|
}
|
|
24706
|
-
log.dim(" Those per-million rates are the PROVIDER's; what
|
|
24707
|
-
log.dim(`
|
|
26025
|
+
log.dim(" Those per-million rates are the PROVIDER's list; what one call of YOUR prompt really costs,");
|
|
26026
|
+
log.dim(` and the per-call ceiling to declare, only a real call measures: ${c.cyan('genex llm bench "<prompt>" --max-credits <n> --user-approved')}`);
|
|
24708
26027
|
}
|
|
24709
26028
|
function wholeOrNull(value) {
|
|
24710
26029
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
24711
26030
|
}
|
|
26031
|
+
function costPicosOf(usage) {
|
|
26032
|
+
if (typeof usage?.costUsdPicos === "string" && /^\d+$/.test(usage.costUsdPicos)) return usage.costUsdPicos;
|
|
26033
|
+
if (typeof usage?.costUsd === "number" && Number.isFinite(usage.costUsd) && usage.costUsd >= 0) {
|
|
26034
|
+
return String(BigInt(Math.round(usage.costUsd * 1e12)));
|
|
26035
|
+
}
|
|
26036
|
+
return null;
|
|
26037
|
+
}
|
|
24712
26038
|
function sampleFrom(id, settled) {
|
|
24713
26039
|
const usage = settled?.usage;
|
|
26040
|
+
const picos = costPicosOf(usage);
|
|
24714
26041
|
return {
|
|
24715
26042
|
id,
|
|
24716
26043
|
status: settled?.status ?? null,
|
|
24717
26044
|
billingStatus: settled?.billingStatus ?? null,
|
|
24718
|
-
|
|
24719
|
-
costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : null,
|
|
26045
|
+
chargedCredits: wholeOrNull(settled?.chargedCredits),
|
|
26046
|
+
costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : picos !== null ? Number(picos) / 1e12 : null,
|
|
26047
|
+
costUsdPicos: picos,
|
|
24720
26048
|
error: settled?.error ?? (settled ? null : "timed_out"),
|
|
24721
26049
|
providerMessage: typeof settled?.providerMessage === "string" && settled.providerMessage.trim() ? settled.providerMessage.trim() : null,
|
|
24722
26050
|
inputTokens: wholeOrNull(usage?.inputTokens),
|
|
24723
26051
|
outputTokens: wholeOrNull(usage?.outputTokens),
|
|
24724
26052
|
maxOutputTokens: wholeOrNull(usage?.maxOutputTokens),
|
|
24725
|
-
|
|
26053
|
+
fitCredits: typeof usage?.fitCredits === "number" && Number.isSafeInteger(usage.fitCredits) && usage.fitCredits >= 1 ? usage.fitCredits : null,
|
|
24726
26054
|
fitExceedsCap: typeof usage?.fitExceedsCap === "boolean" ? usage.fitExceedsCap : null,
|
|
24727
26055
|
truncated: typeof usage?.truncated === "boolean" ? usage.truncated : settled?.error === "provider_token_limit" ? true : null
|
|
24728
26056
|
};
|
|
24729
26057
|
}
|
|
24730
26058
|
var PROVIDER_TOKEN_LIMIT = "provider_token_limit";
|
|
24731
|
-
function benchRecommendation(results,
|
|
24732
|
-
const settled = results.filter(
|
|
24733
|
-
|
|
24734
|
-
|
|
24735
|
-
const
|
|
24736
|
-
const
|
|
26059
|
+
function benchRecommendation(results, terms) {
|
|
26060
|
+
const settled = results.filter(
|
|
26061
|
+
(r) => r.status === "succeeded" && r.billingStatus === "final" && r.chargedCredits !== null && r.costUsdPicos !== null
|
|
26062
|
+
);
|
|
26063
|
+
const costs = settled.map((r) => BigInt(r.costUsdPicos));
|
|
26064
|
+
const charged = settled.map((r) => r.chargedCredits);
|
|
26065
|
+
const fits = settled.map((r) => r.fitCredits).filter((v) => typeof v === "number");
|
|
26066
|
+
const costP95 = percentilePicos(costs, 95);
|
|
26067
|
+
const costMax = costs.length ? costs.reduce((a, b) => b > a ? b : a) : null;
|
|
24737
26068
|
const fitP95 = percentile2(fits, 95);
|
|
24738
26069
|
const fitMax = fits.length ? Math.max(...fits) : null;
|
|
24739
26070
|
const exceedsStandCap = results.some((r) => r.fitExceedsCap === true);
|
|
24740
26071
|
const cutOffSamples = results.filter((r) => r.error === PROVIDER_TOKEN_LIMIT && r.fitExceedsCap !== true).length;
|
|
24741
26072
|
const noRecommendationReason = exceedsStandCap ? "answer_exceeds_stand_cap" : cutOffSamples > 0 ? "answer_cut_off" : null;
|
|
24742
|
-
const
|
|
24743
|
-
const
|
|
24744
|
-
const
|
|
26073
|
+
const { headroomBps, feeBps, usdCentsPerCredit } = terms;
|
|
26074
|
+
const missingTerms = headroomBps === null ? "headroom" : feeBps === null || usdCentsPerCredit === null ? "credit_terms" : null;
|
|
26075
|
+
const full = headroomBps !== null && feeBps !== null && usdCentsPerCredit !== null ? { headroomBps, feeBps, usdCentsPerCredit } : null;
|
|
26076
|
+
const billing = feeBps !== null && usdCentsPerCredit !== null ? { feeBps, usdCentsPerCredit } : null;
|
|
26077
|
+
const costBased = full !== null && costP95 !== null ? ceilingCreditsFor(costP95, full) : null;
|
|
26078
|
+
const recommended = costBased === null || noRecommendationReason !== null ? null : Math.max(costBased, fitP95 ?? 0);
|
|
26079
|
+
const ceiling = recommended === null || costMax === null || full === null ? null : Math.max(ceilingCreditsFor(costMax, full), recommended, fitMax ?? 0);
|
|
26080
|
+
const average = billing !== null ? averageCreditsPerCall(costs, billing) : null;
|
|
26081
|
+
const estimate = ceiling === null || billing === null ? null : Math.min(ceiling, perCallEstimateCreditsFor(costs, billing) ?? 1);
|
|
24745
26082
|
return {
|
|
26083
|
+
costs,
|
|
24746
26084
|
charged,
|
|
24747
|
-
|
|
24748
|
-
|
|
24749
|
-
|
|
26085
|
+
costP50: percentilePicos(costs, 50),
|
|
26086
|
+
costP95,
|
|
26087
|
+
costMax,
|
|
26088
|
+
chargedP50: percentile2(charged, 50),
|
|
26089
|
+
chargedP95: percentile2(charged, 95),
|
|
26090
|
+
chargedMax: charged.length ? Math.max(...charged) : null,
|
|
24750
26091
|
fits,
|
|
24751
26092
|
fitP95,
|
|
24752
26093
|
fitMax,
|
|
24753
|
-
|
|
24754
|
-
|
|
24755
|
-
|
|
24756
|
-
|
|
26094
|
+
costBasedMaxCredits: costBased,
|
|
26095
|
+
recommendedMaxCredits: recommended,
|
|
26096
|
+
recommendedPerCallMaxCredits: ceiling,
|
|
26097
|
+
averageCreditsPerCall: average,
|
|
26098
|
+
recommendedPerCallEstimateCredits: estimate,
|
|
26099
|
+
lengthRaisedCeiling: recommended !== null && costBased !== null && recommended > costBased,
|
|
24757
26100
|
cutOffSamples,
|
|
24758
26101
|
exceedsStandCap,
|
|
24759
|
-
noRecommendationReason
|
|
26102
|
+
noRecommendationReason,
|
|
26103
|
+
missingTerms
|
|
24760
26104
|
};
|
|
24761
26105
|
}
|
|
24762
|
-
var LLM_LENGTH_RAISED_LINE = "The
|
|
24763
|
-
var LLM_EXCEEDS_STAND_CAP_LINE = "No
|
|
24764
|
-
function benchCutOffLine(cutOff,
|
|
24765
|
-
return `No
|
|
26106
|
+
var LLM_LENGTH_RAISED_LINE = "The ceiling also decides how long the answer may be: at the cost-based ceiling the answer would be cut off, so declare this one.";
|
|
26107
|
+
var LLM_EXCEEDS_STAND_CAP_LINE = "No ceiling recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
|
|
26108
|
+
function benchCutOffLine(cutOff, maxCredits) {
|
|
26109
|
+
return `No ceiling recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-credits ${maxCredits}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-credits.`;
|
|
24766
26110
|
}
|
|
24767
26111
|
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["requires_confirmation", "queued", "dispatching", "awaiting_external"]);
|
|
24768
26112
|
function rowState(row) {
|
|
@@ -24773,7 +26117,22 @@ function rowState(row) {
|
|
|
24773
26117
|
function rowHoldsSlot(row) {
|
|
24774
26118
|
if (typeof row.slotHeld === "boolean") return row.slotHeld;
|
|
24775
26119
|
const state = rowState(row);
|
|
24776
|
-
return state === "active" || state === "awaiting bill" && (row.reservedCoins ?? 0) > 0;
|
|
26120
|
+
return state === "active" || state === "awaiting bill" && ((row.reservedCoins ?? 0) > 0 || (row.reservedCredits ?? 0) > 0);
|
|
26121
|
+
}
|
|
26122
|
+
function rowMoney(row) {
|
|
26123
|
+
const credits = row.currency === "credits" || row.currency === void 0 && typeof row.reservedCredits === "number" && typeof row.reservedCoins !== "number";
|
|
26124
|
+
if (credits) {
|
|
26125
|
+
return {
|
|
26126
|
+
unit: "credits",
|
|
26127
|
+
reserved: typeof row.reservedCredits === "number" ? row.reservedCredits : null,
|
|
26128
|
+
charged: typeof row.chargedCredits === "number" ? row.chargedCredits : null
|
|
26129
|
+
};
|
|
26130
|
+
}
|
|
26131
|
+
return {
|
|
26132
|
+
unit: "coin",
|
|
26133
|
+
reserved: typeof row.reservedCoins === "number" ? row.reservedCoins : null,
|
|
26134
|
+
charged: typeof row.chargedCoins === "number" ? row.chargedCoins : null
|
|
26135
|
+
};
|
|
24777
26136
|
}
|
|
24778
26137
|
function providerRefusalStatus(error) {
|
|
24779
26138
|
const m = /^provider_http_(\d{3})$/.exec(error ?? "");
|
|
@@ -24792,7 +26151,7 @@ function formatAge(iso, now = Date.now()) {
|
|
|
24792
26151
|
}
|
|
24793
26152
|
async function runBench(args) {
|
|
24794
26153
|
const { apiUrl, token, projectId, cwd, opts, log } = args;
|
|
24795
|
-
const
|
|
26154
|
+
const maxCredits = opts.maxCredits ?? opts.maxCoins;
|
|
24796
26155
|
const samples = opts.samples ?? DEFAULT_BENCH_SAMPLES;
|
|
24797
26156
|
const prompt = opts.benchPrompt.trim();
|
|
24798
26157
|
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
@@ -24800,7 +26159,7 @@ async function runBench(args) {
|
|
|
24800
26159
|
let schema;
|
|
24801
26160
|
if (opts.schemaPath) {
|
|
24802
26161
|
try {
|
|
24803
|
-
schema = JSON.parse(await
|
|
26162
|
+
schema = JSON.parse(await fs38.readFile(path38.resolve(cwd, opts.schemaPath), "utf8"));
|
|
24804
26163
|
} catch (err) {
|
|
24805
26164
|
benchFailed(opts, log, `Couldn't read --schema ${opts.schemaPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
24806
26165
|
return;
|
|
@@ -24814,7 +26173,7 @@ async function runBench(args) {
|
|
|
24814
26173
|
const lane = await readRuntimeLane(apiUrl, token);
|
|
24815
26174
|
if (lane.state !== "live" || !lane.models || lane.models.length === 0) {
|
|
24816
26175
|
if (lane.state === "off") {
|
|
24817
|
-
if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples,
|
|
26176
|
+
if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts), status: "off", error: null });
|
|
24818
26177
|
else {
|
|
24819
26178
|
log.plain(LLM_LANE_OFF_LINE);
|
|
24820
26179
|
log.dim(" Nothing was spent.");
|
|
@@ -24841,17 +26200,27 @@ async function runBench(args) {
|
|
|
24841
26200
|
);
|
|
24842
26201
|
return;
|
|
24843
26202
|
}
|
|
24844
|
-
const
|
|
26203
|
+
const walletBefore = await fetchCreditsSnapshot(apiUrl, token);
|
|
26204
|
+
if (walletBefore?.emailVerified === false) {
|
|
26205
|
+
benchFailed(opts, log, LLM_BENCH_UNVERIFIED_LINE);
|
|
26206
|
+
return;
|
|
26207
|
+
}
|
|
26208
|
+
if (walletBefore !== null && walletBefore.spendable < maxCredits) {
|
|
26209
|
+
benchFailed(opts, log, benchBalanceShortLine(walletBefore.spendable, maxCredits));
|
|
26210
|
+
return;
|
|
26211
|
+
}
|
|
26212
|
+
const balanceBefore = walletBefore?.spendable ?? null;
|
|
24845
26213
|
if (!opts.json) {
|
|
24846
26214
|
log.plain(c.bold("genex llm bench"));
|
|
24847
26215
|
log.dim(` ${apiUrl}`);
|
|
24848
26216
|
log.plain("");
|
|
24849
26217
|
log.plain(` Model ${c.cyan(modelId)}`);
|
|
24850
26218
|
log.plain(` Samples ${samples} real attempt${samples === 1 ? "" : "s"}, ${outputFormat} output`);
|
|
24851
|
-
log.plain(` Approved ${
|
|
26219
|
+
log.plain(` Approved ${maxCredits} credit${maxCredits === 1 ? "" : "s"} per attempt \u2014 at worst ${maxCredits * samples} credits for this run, from your own balance`);
|
|
24852
26220
|
log.plain(
|
|
24853
|
-
` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore}
|
|
26221
|
+
` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} credits spendable`}`
|
|
24854
26222
|
);
|
|
26223
|
+
log.dim(" Billed as used: each attempt is charged its real cost plus the platform fee, rounded up to a whole credit.");
|
|
24855
26224
|
log.plain("");
|
|
24856
26225
|
}
|
|
24857
26226
|
const base = `${apiUrl}/api/runtime/development/projects/${encodeURIComponent(projectId)}/generations`;
|
|
@@ -24877,7 +26246,7 @@ async function runBench(args) {
|
|
|
24877
26246
|
const res = await call2(base, {
|
|
24878
26247
|
method: "POST",
|
|
24879
26248
|
body: JSON.stringify({
|
|
24880
|
-
|
|
26249
|
+
maxCredits,
|
|
24881
26250
|
request: {
|
|
24882
26251
|
idempotencyKey: `bench-${randomUUID2()}`,
|
|
24883
26252
|
modelId,
|
|
@@ -24907,13 +26276,14 @@ async function runBench(args) {
|
|
|
24907
26276
|
if (refusedAt !== null) providerRefused++;
|
|
24908
26277
|
if (!opts.json) {
|
|
24909
26278
|
const length = row.outputTokens !== null ? ` \xB7 ${row.outputTokens}${row.maxOutputTokens !== null ? ` of ${row.maxOutputTokens}` : ""} tokens out` : "";
|
|
26279
|
+
const charged = row.chargedCredits === null ? "\u2014" : String(row.chargedCredits);
|
|
24910
26280
|
log.plain(
|
|
24911
|
-
` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${
|
|
26281
|
+
` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${charged.padStart(4)} credit${row.chargedCredits === 1 ? "" : "s"} charged \xB7 cost ${usdFromPicos(row.costUsdPicos)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
|
|
24912
26282
|
);
|
|
24913
26283
|
if (refusedAt !== null) printProviderRefusal(log, refusedAt, row.providerMessage, row.status === "unknown");
|
|
24914
26284
|
if (row.error === PROVIDER_TOKEN_LIMIT) {
|
|
24915
26285
|
log.dim(
|
|
24916
|
-
row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no
|
|
26286
|
+
row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no ceiling makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-credits ${maxCredits}) before it was finished \u2014 it was charged, and it is not a sample.`
|
|
24917
26287
|
);
|
|
24918
26288
|
}
|
|
24919
26289
|
}
|
|
@@ -24921,16 +26291,19 @@ async function runBench(args) {
|
|
|
24921
26291
|
} finally {
|
|
24922
26292
|
process.removeListener("SIGINT", onSigint);
|
|
24923
26293
|
}
|
|
24924
|
-
const
|
|
24925
|
-
|
|
24926
|
-
|
|
24927
|
-
|
|
24928
|
-
|
|
24929
|
-
const
|
|
26294
|
+
const terms = {
|
|
26295
|
+
headroomBps: lane.recommendedDeclaredHeadroomBps,
|
|
26296
|
+
feeBps: lane.feeBps,
|
|
26297
|
+
usdCentsPerCredit: lane.usdCentsPerCredit
|
|
26298
|
+
};
|
|
26299
|
+
const rec = benchRecommendation(results, terms);
|
|
26300
|
+
const settledCount = rec.charged.length;
|
|
24930
26301
|
const lengthBlocked = rec.noRecommendationReason !== null;
|
|
24931
|
-
const balanceAfter = await
|
|
26302
|
+
const balanceAfter = (await fetchCreditsSnapshot(apiUrl, token))?.spendable ?? null;
|
|
26303
|
+
const picosOrNull = (v) => v === null ? null : String(v);
|
|
24932
26304
|
const record = {
|
|
24933
|
-
v:
|
|
26305
|
+
v: 2,
|
|
26306
|
+
currency: "credits",
|
|
24934
26307
|
ranAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
24935
26308
|
apiUrl,
|
|
24936
26309
|
projectId,
|
|
@@ -24939,28 +26312,34 @@ async function runBench(args) {
|
|
|
24939
26312
|
outputFormat,
|
|
24940
26313
|
samples,
|
|
24941
26314
|
completed: settledCount,
|
|
24942
|
-
|
|
24943
|
-
|
|
24944
|
-
|
|
24945
|
-
max,
|
|
24946
|
-
|
|
24947
|
-
|
|
24948
|
-
recommendedPerCallMaxCoins: ceiling,
|
|
24949
|
-
maxCoinsPerSample: maxCoins,
|
|
24950
|
-
fitCoins: rec.fits,
|
|
26315
|
+
maxCreditsPerSample: maxCredits,
|
|
26316
|
+
costUsdPicos: rec.costs.map(String),
|
|
26317
|
+
chargedCredits: rec.charged,
|
|
26318
|
+
cost: { p50: picosOrNull(rec.costP50), p95: picosOrNull(rec.costP95), max: picosOrNull(rec.costMax) },
|
|
26319
|
+
charged: { p50: rec.chargedP50, p95: rec.chargedP95, max: rec.chargedMax },
|
|
26320
|
+
fitCredits: rec.fits,
|
|
24951
26321
|
fitP95: rec.fitP95,
|
|
24952
|
-
|
|
24953
|
-
|
|
26322
|
+
fitMax: rec.fitMax,
|
|
26323
|
+
recommendedDeclaredHeadroomBps: terms.headroomBps,
|
|
26324
|
+
feeBps: terms.feeBps,
|
|
26325
|
+
usdCentsPerCredit: terms.usdCentsPerCredit,
|
|
26326
|
+
costBasedMaxCredits: rec.costBasedMaxCredits,
|
|
26327
|
+
recommendedMaxCredits: rec.recommendedMaxCredits,
|
|
26328
|
+
recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
|
|
26329
|
+
averageCreditsPerCall: rec.averageCreditsPerCall,
|
|
26330
|
+
recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
|
|
26331
|
+
lengthRaisedCeiling: rec.lengthRaisedCeiling,
|
|
24954
26332
|
cutOffSamples: rec.cutOffSamples,
|
|
24955
26333
|
noRecommendationReason: rec.noRecommendationReason
|
|
24956
26334
|
};
|
|
24957
26335
|
const savedTo = await saveBench(cwd, record);
|
|
24958
26336
|
if (opts.json) {
|
|
24959
|
-
const ok =
|
|
26337
|
+
const ok = settledCount > 0 && !lengthBlocked;
|
|
24960
26338
|
writeJsonLine({
|
|
24961
26339
|
command: "llm bench",
|
|
24962
26340
|
status: ok ? "ok" : "failed",
|
|
24963
26341
|
error: ok ? null : rec.noRecommendationReason ?? "no_sample_settled",
|
|
26342
|
+
currency: "credits",
|
|
24964
26343
|
apiUrl,
|
|
24965
26344
|
projectId,
|
|
24966
26345
|
modelId,
|
|
@@ -24969,35 +26348,54 @@ async function runBench(args) {
|
|
|
24969
26348
|
schemaPath: opts.schemaPath ?? null,
|
|
24970
26349
|
samples,
|
|
24971
26350
|
completed: settledCount,
|
|
24972
|
-
|
|
26351
|
+
maxCreditsPerSample: maxCredits,
|
|
26352
|
+
// Spendable credits before and after the run; null when unreadable.
|
|
24973
26353
|
balanceBefore,
|
|
24974
26354
|
balanceAfter,
|
|
24975
26355
|
results,
|
|
24976
26356
|
// The create-time refusal that ended the run, or null. Beside the
|
|
24977
26357
|
// samples, never among them: no attempt existed and nothing was spent.
|
|
24978
26358
|
refusal: refusal2,
|
|
24979
|
-
|
|
24980
|
-
//
|
|
26359
|
+
// Over the settled samples: the real provider cost (pico-dollar strings
|
|
26360
|
+
// and USD) and the whole credits charged.
|
|
26361
|
+
costUsdPicos: record.cost,
|
|
26362
|
+
costUsd: {
|
|
26363
|
+
p50: rec.costP50 === null ? null : Number(rec.costP50) / 1e12,
|
|
26364
|
+
p95: rec.costP95 === null ? null : Number(rec.costP95) / 1e12,
|
|
26365
|
+
max: rec.costMax === null ? null : Number(rec.costMax) / 1e12
|
|
26366
|
+
},
|
|
26367
|
+
chargedCredits: record.charged,
|
|
26368
|
+
// The answer's LENGTH, priced by the server per sample (`fitCredits`,
|
|
24981
26369
|
// headroom already on the tokens), over the settled samples.
|
|
24982
|
-
|
|
24983
|
-
|
|
24984
|
-
|
|
26370
|
+
fitCredits: { p95: rec.fitP95, max: rec.fitMax },
|
|
26371
|
+
recommendedDeclaredHeadroomBps: terms.headroomBps,
|
|
26372
|
+
feeBps: terms.feeBps,
|
|
26373
|
+
usdCentsPerCredit: terms.usdCentsPerCredit,
|
|
26374
|
+
missingTerms: rec.missingTerms,
|
|
26375
|
+
costBasedMaxCredits: rec.costBasedMaxCredits,
|
|
26376
|
+
lengthRaisedCeiling: rec.lengthRaisedCeiling,
|
|
24985
26377
|
cutOffSamples: rec.cutOffSamples,
|
|
24986
26378
|
exceedsStandCap: rec.exceedsStandCap,
|
|
24987
26379
|
noRecommendationReason: rec.noRecommendationReason,
|
|
24988
|
-
|
|
24989
|
-
|
|
24990
|
-
|
|
26380
|
+
recommendedMaxCredits: rec.recommendedMaxCredits,
|
|
26381
|
+
recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
|
|
26382
|
+
averageCreditsPerCall: rec.averageCreditsPerCall,
|
|
26383
|
+
recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
|
|
24991
26384
|
savedTo
|
|
24992
26385
|
});
|
|
24993
26386
|
if (!ok) process.exitCode = 1;
|
|
24994
26387
|
return;
|
|
24995
26388
|
}
|
|
24996
26389
|
log.plain("");
|
|
26390
|
+
const printMeasured = () => {
|
|
26391
|
+
log.plain(c.bold(" Real cost per call"));
|
|
26392
|
+
log.plain(` p50 ${usdFromPicos(rec.costP50)} p95 ${usdFromPicos(rec.costP95)} max ${usdFromPicos(rec.costMax)} (${settledCount} of ${samples} settled)`);
|
|
26393
|
+
log.plain(c.bold(" Charged credits"));
|
|
26394
|
+
log.plain(` p50 ${rec.chargedP50} p95 ${rec.chargedP95} max ${rec.chargedMax} (whole credits, rounded up per call)`);
|
|
26395
|
+
};
|
|
24997
26396
|
if (lengthBlocked) {
|
|
24998
|
-
if (
|
|
24999
|
-
|
|
25000
|
-
log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
|
|
26397
|
+
if (settledCount > 0) {
|
|
26398
|
+
printMeasured();
|
|
25001
26399
|
log.plain("");
|
|
25002
26400
|
}
|
|
25003
26401
|
printRecommendation(log, record);
|
|
@@ -25005,17 +26403,16 @@ async function runBench(args) {
|
|
|
25005
26403
|
process.exitCode = 1;
|
|
25006
26404
|
return;
|
|
25007
26405
|
}
|
|
25008
|
-
if (
|
|
26406
|
+
if (settledCount === 0) {
|
|
25009
26407
|
log.error(
|
|
25010
|
-
providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to
|
|
26408
|
+
providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to measure from." : " No sample settled, so there is nothing to measure from."
|
|
25011
26409
|
);
|
|
25012
26410
|
process.exitCode = 1;
|
|
25013
26411
|
return;
|
|
25014
26412
|
}
|
|
25015
|
-
|
|
25016
|
-
log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
|
|
26413
|
+
printMeasured();
|
|
25017
26414
|
if (balanceBefore !== null && balanceAfter !== null) {
|
|
25018
|
-
log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter}
|
|
26415
|
+
log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} credits spendable`);
|
|
25019
26416
|
}
|
|
25020
26417
|
log.plain("");
|
|
25021
26418
|
printRecommendation(log, record);
|
|
@@ -25027,43 +26424,58 @@ function printRecommendation(log, record) {
|
|
|
25027
26424
|
return;
|
|
25028
26425
|
}
|
|
25029
26426
|
if (record.noRecommendationReason === "answer_cut_off") {
|
|
25030
|
-
log.warn(` ${benchCutOffLine(record.cutOffSamples
|
|
26427
|
+
log.warn(` ${benchCutOffLine(record.cutOffSamples || 1, record.maxCreditsPerSample)}`);
|
|
25031
26428
|
return;
|
|
25032
26429
|
}
|
|
25033
|
-
|
|
25034
|
-
|
|
26430
|
+
const attempts = `${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}`;
|
|
26431
|
+
if (record.recommendedMaxCredits === null || record.cost.p95 === null) {
|
|
26432
|
+
log.warn(" No ceiling recommended.");
|
|
25035
26433
|
log.dim(
|
|
25036
|
-
record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : "
|
|
26434
|
+
record.cost.p95 === null ? " No sample settled, so there is no p95 to build a ceiling on \u2014" : record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " This stand served no platform fee or credit value, and those are the server's to set \u2014"
|
|
25037
26435
|
);
|
|
25038
26436
|
log.dim(" declaring a number from this run would be a guess dressed as a measurement.");
|
|
26437
|
+
printAverage(log, record, attempts);
|
|
25039
26438
|
return;
|
|
25040
26439
|
}
|
|
25041
|
-
log.plain(c.bold(` Declare
|
|
25042
|
-
if (record.
|
|
26440
|
+
log.plain(c.bold(` Declare maxCredits: ${record.recommendedMaxCredits}`));
|
|
26441
|
+
if (record.lengthRaisedCeiling) {
|
|
25043
26442
|
log.plain(` ${LLM_LENGTH_RAISED_LINE}`);
|
|
25044
26443
|
log.dim(
|
|
25045
|
-
` = the smallest
|
|
26444
|
+
` = the smallest ceiling whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
|
|
25046
26445
|
);
|
|
25047
26446
|
log.dim(
|
|
25048
|
-
`
|
|
26447
|
+
` cost-based ${record.costBasedMaxCredits ?? "\u2014"} (p95 real cost ${usdFromPicos(record.cost.p95)} with the platform fee, ${record.feeBps} bps, and ${record.recommendedDeclaredHeadroomBps} bps of headroom), over ${attempts}.`
|
|
25049
26448
|
);
|
|
25050
26449
|
} else {
|
|
25051
26450
|
log.dim(
|
|
25052
|
-
` = p95
|
|
26451
|
+
` = p95 real cost (${usdFromPicos(record.cost.p95)}) with this stand's platform fee (${record.feeBps} bps) and recommended headroom (${record.recommendedDeclaredHeadroomBps} bps), in whole credits,`
|
|
25053
26452
|
);
|
|
25054
|
-
log.dim(` measured over ${
|
|
26453
|
+
log.dim(` measured over ${attempts}.`);
|
|
25055
26454
|
}
|
|
25056
|
-
log.dim(" That number is a
|
|
25057
|
-
|
|
25058
|
-
|
|
26455
|
+
log.dim(" That number is a CEILING, not a price: a call is billed its real cost as used, never more than it \u2014");
|
|
26456
|
+
log.dim(" and it also sizes how long the answer may be.");
|
|
26457
|
+
if (record.recommendedPerCallMaxCredits !== null) {
|
|
26458
|
+
log.plain(c.bold(` Grant perCallMaxCredits: ${record.recommendedPerCallMaxCredits}`));
|
|
25059
26459
|
log.dim(
|
|
25060
|
-
record.
|
|
26460
|
+
record.lengthRaisedCeiling ? ` = the same over the worst sample (${usdFromPicos(record.cost.max)}) or the longest answer's ceiling, whichever is larger, and never below the ceiling above it.` : ` = the same over the worst sample (${usdFromPicos(record.cost.max)}), and never below the ceiling above it.`
|
|
25061
26461
|
);
|
|
25062
|
-
|
|
26462
|
+
}
|
|
26463
|
+
printAverage(log, record, attempts);
|
|
26464
|
+
}
|
|
26465
|
+
function printAverage(log, record, attempts) {
|
|
26466
|
+
if (record.averageCreditsPerCall === null) return;
|
|
26467
|
+
log.plain(` Per-call estimate: about ${formatCredits(record.averageCreditsPerCall)} credits per call`);
|
|
26468
|
+
log.dim(` = the average real cost with the platform fee, no headroom, over ${attempts}.`);
|
|
26469
|
+
if (record.recommendedPerCallEstimateCredits !== null) {
|
|
26470
|
+
log.plain(c.bold(` Grant perCallEstimateCredits: ${record.recommendedPerCallEstimateCredits}`));
|
|
26471
|
+
log.dim(` = that average rounded up to a whole credit. disclosure.estimatedCreditsPerPeriod = your calls per period \xD7 ${formatCredits(record.averageCreditsPerCall)}, rounded up.`);
|
|
25063
26472
|
}
|
|
25064
26473
|
}
|
|
25065
26474
|
async function explainBenchRefusal(res, call2, base, log, opts, index, settledSoFar) {
|
|
25066
|
-
if (printedStructuredError(res))
|
|
26475
|
+
if (printedStructuredError(res)) {
|
|
26476
|
+
const printed = await res.json().catch(() => ({}));
|
|
26477
|
+
return { stop: true, refusal: { status: res.status, error: typeof printed.error === "string" ? printed.error : null, slotsHeld: null, slotLimit: null } };
|
|
26478
|
+
}
|
|
25067
26479
|
const body = await res.json().catch(() => ({}));
|
|
25068
26480
|
const refusal2 = { status: res.status, error: body.error ?? null, slotsHeld: null, slotLimit: null };
|
|
25069
26481
|
if (res.status === 429 && body.error === "generation_limit") {
|
|
@@ -25073,6 +26485,10 @@ async function explainBenchRefusal(res, call2, base, log, opts, index, settledSo
|
|
|
25073
26485
|
if (!opts.json) log.error(` Sample ${index + 1} ${benchSlotsHeldSentence(counts)}`);
|
|
25074
26486
|
return { stop: true, refusal: refusal2 };
|
|
25075
26487
|
}
|
|
26488
|
+
if (res.status === 403 && body.error === "credits_unverified") {
|
|
26489
|
+
if (!opts.json) log.error(` ${LLM_BENCH_UNVERIFIED_LINE}`);
|
|
26490
|
+
return { stop: true, refusal: refusal2 };
|
|
26491
|
+
}
|
|
25076
26492
|
if (opts.json) return { stop: res.status !== 429, refusal: refusal2 };
|
|
25077
26493
|
if (res.status === 404 && body.error === "not_found") {
|
|
25078
26494
|
log.error(` ${LLM_LANE_OFF_LINE}`);
|
|
@@ -25088,7 +26504,7 @@ async function explainBenchRefusal(res, call2, base, log, opts, index, settledSo
|
|
|
25088
26504
|
return { stop: true, refusal: refusal2 };
|
|
25089
26505
|
}
|
|
25090
26506
|
if (res.status === 402) {
|
|
25091
|
-
log.error(" Not enough
|
|
26507
|
+
log.error(" Not enough credits to start the attempt \u2014 a bench is paid from your own balance.");
|
|
25092
26508
|
return { stop: true, refusal: refusal2 };
|
|
25093
26509
|
}
|
|
25094
26510
|
log.error(
|
|
@@ -25097,7 +26513,7 @@ async function explainBenchRefusal(res, call2, base, log, opts, index, settledSo
|
|
|
25097
26513
|
return { stop: res.status >= 500 || res.status === 401 || res.status === 403, refusal: refusal2 };
|
|
25098
26514
|
}
|
|
25099
26515
|
function printProviderRefusal(log, status2, message, awaitingBill = false) {
|
|
25100
|
-
if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so this call
|
|
26516
|
+
if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so what this call reserved stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
|
|
25101
26517
|
else log.dim(" The provider refused this call at its door \u2014 no inference ran, it cost nothing, and it is not a sample.");
|
|
25102
26518
|
if (message) log.dim(` Provider said: ${message}`);
|
|
25103
26519
|
if (status2 === 401 || status2 === 403) {
|
|
@@ -25123,24 +26539,11 @@ async function pollSettled(call2, url, timeoutSec) {
|
|
|
25123
26539
|
}
|
|
25124
26540
|
return last;
|
|
25125
26541
|
}
|
|
25126
|
-
async function readCoinBalance(apiUrl, token) {
|
|
25127
|
-
try {
|
|
25128
|
-
const res = await apiFetch(`${apiUrl}/api/coin/balance`, {
|
|
25129
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
25130
|
-
signal: AbortSignal.timeout(6e3)
|
|
25131
|
-
});
|
|
25132
|
-
if (!res.ok) return null;
|
|
25133
|
-
const body = await res.json().catch(() => null);
|
|
25134
|
-
return typeof body?.spendable === "number" ? body.spendable : null;
|
|
25135
|
-
} catch {
|
|
25136
|
-
return null;
|
|
25137
|
-
}
|
|
25138
|
-
}
|
|
25139
26542
|
async function saveBench(cwd, record) {
|
|
25140
26543
|
try {
|
|
25141
|
-
const file =
|
|
25142
|
-
await
|
|
25143
|
-
await
|
|
26544
|
+
const file = path38.join(cwd, LLM_BENCH_FILE);
|
|
26545
|
+
await fs38.mkdir(path38.dirname(file), { recursive: true });
|
|
26546
|
+
await fs38.writeFile(file, JSON.stringify(record, null, 2) + "\n");
|
|
25144
26547
|
return LLM_BENCH_FILE;
|
|
25145
26548
|
} catch {
|
|
25146
26549
|
return null;
|
|
@@ -25151,9 +26554,10 @@ function benchFailed(opts, log, message) {
|
|
|
25151
26554
|
else log.error(message);
|
|
25152
26555
|
process.exitCode = 1;
|
|
25153
26556
|
}
|
|
25154
|
-
function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples,
|
|
26557
|
+
function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts) {
|
|
25155
26558
|
return {
|
|
25156
26559
|
command: "llm bench",
|
|
26560
|
+
currency: "credits",
|
|
25157
26561
|
apiUrl,
|
|
25158
26562
|
projectId,
|
|
25159
26563
|
modelId: opts.modelId ?? null,
|
|
@@ -25162,21 +26566,28 @@ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoi
|
|
|
25162
26566
|
schemaPath: opts.schemaPath ?? null,
|
|
25163
26567
|
samples,
|
|
25164
26568
|
completed: 0,
|
|
25165
|
-
|
|
26569
|
+
maxCreditsPerSample: maxCredits,
|
|
25166
26570
|
balanceBefore: null,
|
|
25167
26571
|
balanceAfter: null,
|
|
25168
26572
|
results: [],
|
|
25169
26573
|
refusal: null,
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
26574
|
+
costUsdPicos: { p50: null, p95: null, max: null },
|
|
26575
|
+
costUsd: { p50: null, p95: null, max: null },
|
|
26576
|
+
chargedCredits: { p50: null, p95: null, max: null },
|
|
26577
|
+
fitCredits: { p95: null, max: null },
|
|
26578
|
+
recommendedDeclaredHeadroomBps: null,
|
|
26579
|
+
feeBps: null,
|
|
26580
|
+
usdCentsPerCredit: null,
|
|
26581
|
+
missingTerms: null,
|
|
26582
|
+
costBasedMaxCredits: null,
|
|
26583
|
+
lengthRaisedCeiling: false,
|
|
25174
26584
|
cutOffSamples: 0,
|
|
25175
26585
|
exceedsStandCap: false,
|
|
25176
26586
|
noRecommendationReason: null,
|
|
25177
|
-
|
|
25178
|
-
|
|
25179
|
-
|
|
26587
|
+
recommendedMaxCredits: null,
|
|
26588
|
+
recommendedPerCallMaxCredits: null,
|
|
26589
|
+
averageCreditsPerCall: null,
|
|
26590
|
+
recommendedPerCallEstimateCredits: null,
|
|
25180
26591
|
savedTo: null
|
|
25181
26592
|
};
|
|
25182
26593
|
}
|
|
@@ -25239,7 +26650,8 @@ async function reportStatus(args) {
|
|
|
25239
26650
|
for (const row of rows) {
|
|
25240
26651
|
const state = rowState(row);
|
|
25241
26652
|
const mark = state === "active" ? c.cyan("\u25CF") : state === "awaiting bill" ? c.yellow("\u25D0") : c.green("\u25CB");
|
|
25242
|
-
const
|
|
26653
|
+
const money = rowMoney(row);
|
|
26654
|
+
const reserved = money.reserved === null ? "reserved \u2014" : `${money.reserved} ${money.unit} reserved`;
|
|
25243
26655
|
log.plain(
|
|
25244
26656
|
` ${mark} ${row.id ?? "?"} ${row.modelId ?? "?"} ${state.padEnd(13)} ${formatAge(row.createdAt, now).padStart(4)} old ${reserved}${row.error ? ` ${row.error}` : ""}${rowHoldsSlot(row) ? "" : c.dim(" (no slot)")}`
|
|
25245
26657
|
);
|
|
@@ -25311,10 +26723,11 @@ async function cancelCall(args) {
|
|
|
25311
26723
|
if (!opts.json) {
|
|
25312
26724
|
log.warn(` ${row.id} (${row.status ?? "?"}) \u2014 ${sentence}.`);
|
|
25313
26725
|
if (state === "awaiting bill") {
|
|
25314
|
-
log.dim(` Nothing to cancel:
|
|
26726
|
+
log.dim(` Nothing to cancel: what it reserved releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
|
|
25315
26727
|
if (row.providerMessage?.trim()) log.dim(` Provider said: ${row.providerMessage.trim()}`);
|
|
25316
26728
|
} else {
|
|
25317
|
-
|
|
26729
|
+
const money = rowMoney(row);
|
|
26730
|
+
log.dim(` ${money.charged === null ? "charge unknown" : `${money.charged} ${money.unit} charged`}${row.error ? ` \xB7 ${row.error}` : ""}.`);
|
|
25318
26731
|
}
|
|
25319
26732
|
}
|
|
25320
26733
|
return done("ok", null, state === "awaiting bill" ? "awaiting_bill" : "already_final", row);
|
|
@@ -25336,78 +26749,126 @@ async function cancelCall(args) {
|
|
|
25336
26749
|
const after = await res.json().catch(() => null) ?? row;
|
|
25337
26750
|
if (!opts.json) {
|
|
25338
26751
|
log.success(` Cancelled ${row.id} (was ${row.status ?? "active"}).`);
|
|
25339
|
-
log.dim(`
|
|
26752
|
+
log.dim(` What it reserved releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
|
|
25340
26753
|
}
|
|
25341
26754
|
return done("ok", null, "cancelled", after);
|
|
25342
26755
|
}
|
|
25343
|
-
async function
|
|
25344
|
-
let
|
|
26756
|
+
async function readSavedBench(cwd) {
|
|
26757
|
+
let parsed;
|
|
25345
26758
|
try {
|
|
25346
|
-
|
|
26759
|
+
parsed = JSON.parse(await fs38.readFile(path38.join(cwd, LLM_BENCH_FILE), "utf8"));
|
|
25347
26760
|
} catch {
|
|
25348
|
-
|
|
26761
|
+
return null;
|
|
25349
26762
|
}
|
|
25350
|
-
if (!
|
|
26763
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
26764
|
+
const v = parsed;
|
|
26765
|
+
if (v.v === 2 && v.currency === "credits" && v.cost && v.charged) return { kind: "credits", record: parsed };
|
|
26766
|
+
return { kind: "coins", record: parsed };
|
|
26767
|
+
}
|
|
26768
|
+
var LLM_PRICE_COIN_TERMS_LINE = "This bench was measured under the old COIN terms, when a game declared a fixed price per call. In-game calls are now billed in the player's credits, as used, and a game declares a per-call CEILING (maxCredits) instead \u2014 a coin price is not that number. Re-run the bench for the figures to declare.";
|
|
26769
|
+
async function reportSavedPrice(cwd, opts, log) {
|
|
26770
|
+
const saved = await readSavedBench(cwd);
|
|
26771
|
+
const rerun = 'genex llm bench "<prompt>" --max-credits <n> --user-approved';
|
|
26772
|
+
const creditKeys = (record2) => ({
|
|
26773
|
+
costUsdPicos: record2?.cost ?? { p50: null, p95: null, max: null },
|
|
26774
|
+
chargedCredits: record2?.charged ?? { p50: null, p95: null, max: null },
|
|
26775
|
+
fitP95: record2?.fitP95 ?? null,
|
|
26776
|
+
fitMax: record2?.fitMax ?? null,
|
|
26777
|
+
recommendedDeclaredHeadroomBps: record2?.recommendedDeclaredHeadroomBps ?? null,
|
|
26778
|
+
feeBps: record2?.feeBps ?? null,
|
|
26779
|
+
usdCentsPerCredit: record2?.usdCentsPerCredit ?? null,
|
|
26780
|
+
costBasedMaxCredits: record2?.costBasedMaxCredits ?? null,
|
|
26781
|
+
lengthRaisedCeiling: record2?.lengthRaisedCeiling ?? false,
|
|
26782
|
+
cutOffSamples: record2?.cutOffSamples ?? 0,
|
|
26783
|
+
noRecommendationReason: record2?.noRecommendationReason ?? null,
|
|
26784
|
+
recommendedMaxCredits: record2?.recommendedMaxCredits ?? null,
|
|
26785
|
+
recommendedPerCallMaxCredits: record2?.recommendedPerCallMaxCredits ?? null,
|
|
26786
|
+
averageCreditsPerCall: record2?.averageCreditsPerCall ?? null,
|
|
26787
|
+
recommendedPerCallEstimateCredits: record2?.recommendedPerCallEstimateCredits ?? null
|
|
26788
|
+
});
|
|
26789
|
+
if (!saved) {
|
|
25351
26790
|
if (opts.json) {
|
|
25352
26791
|
writeJsonLine({
|
|
25353
26792
|
command: "llm price",
|
|
25354
26793
|
status: "failed",
|
|
25355
26794
|
error: "no_bench",
|
|
26795
|
+
currency: null,
|
|
25356
26796
|
ranAt: null,
|
|
25357
26797
|
modelId: null,
|
|
25358
26798
|
samples: null,
|
|
25359
26799
|
completed: null,
|
|
25360
|
-
|
|
25361
|
-
|
|
25362
|
-
max: null,
|
|
25363
|
-
fitP95: null,
|
|
25364
|
-
chargedBasedEstimateCoins: null,
|
|
25365
|
-
lengthRaisedPrice: false,
|
|
25366
|
-
cutOffSamples: 0,
|
|
25367
|
-
noRecommendationReason: null,
|
|
25368
|
-
recommendedDeclaredHeadroomBps: null,
|
|
25369
|
-
recommendedEstimateCoins: null,
|
|
25370
|
-
recommendedPerCallMaxCoins: null,
|
|
26800
|
+
...creditKeys(null),
|
|
26801
|
+
legacyCoinTerms: null,
|
|
25371
26802
|
savedTo: null
|
|
25372
26803
|
});
|
|
25373
26804
|
} else {
|
|
25374
26805
|
log.error("Nothing has been benchmarked in this folder yet.");
|
|
25375
|
-
log.dim(` Run ${c.cyan(
|
|
26806
|
+
log.dim(` Run ${c.cyan(rerun)} first.`);
|
|
25376
26807
|
}
|
|
25377
26808
|
process.exitCode = 1;
|
|
25378
26809
|
return;
|
|
25379
26810
|
}
|
|
26811
|
+
if (saved.kind === "coins") {
|
|
26812
|
+
const old = saved.record;
|
|
26813
|
+
const legacy = {
|
|
26814
|
+
p50: old.p50 ?? null,
|
|
26815
|
+
p95: old.p95 ?? null,
|
|
26816
|
+
max: old.max ?? null,
|
|
26817
|
+
recommendedEstimateCoins: old.recommendedEstimateCoins ?? null,
|
|
26818
|
+
recommendedPerCallMaxCoins: old.recommendedPerCallMaxCoins ?? null,
|
|
26819
|
+
maxCoinsPerSample: old.maxCoinsPerSample ?? null,
|
|
26820
|
+
noRecommendationReason: old.noRecommendationReason ?? null
|
|
26821
|
+
};
|
|
26822
|
+
if (opts.json) {
|
|
26823
|
+
writeJsonLine({
|
|
26824
|
+
command: "llm price",
|
|
26825
|
+
status: "failed",
|
|
26826
|
+
error: "coin_terms",
|
|
26827
|
+
currency: "coins",
|
|
26828
|
+
ranAt: old.ranAt ?? null,
|
|
26829
|
+
modelId: old.modelId ?? null,
|
|
26830
|
+
samples: old.samples ?? null,
|
|
26831
|
+
completed: old.completed ?? null,
|
|
26832
|
+
...creditKeys(null),
|
|
26833
|
+
legacyCoinTerms: legacy,
|
|
26834
|
+
savedTo: LLM_BENCH_FILE
|
|
26835
|
+
});
|
|
26836
|
+
} else {
|
|
26837
|
+
log.plain(c.bold("genex llm price"));
|
|
26838
|
+
log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${old.ranAt ?? "at an unknown time"}${old.modelId ? ` on ${old.modelId}` : ""}`);
|
|
26839
|
+
log.plain("");
|
|
26840
|
+
log.warn(` ${LLM_PRICE_COIN_TERMS_LINE}`);
|
|
26841
|
+
const measured = legacy.p95 === null ? "no settled sample" : `charged coins p50 ${legacy.p50} \xB7 p95 ${legacy.p95} \xB7 max ${legacy.max}`;
|
|
26842
|
+
const recommended = legacy.recommendedEstimateCoins === null ? "no price recommended" : `it recommended estimateCoins ${legacy.recommendedEstimateCoins}${legacy.recommendedPerCallMaxCoins != null ? ` and perCallMaxCoins ${legacy.recommendedPerCallMaxCoins}` : ""}`;
|
|
26843
|
+
log.dim(` What it measured then: ${measured}; ${recommended}.`);
|
|
26844
|
+
log.dim(` Re-run: ${c.cyan(rerun)}`);
|
|
26845
|
+
}
|
|
26846
|
+
process.exitCode = 1;
|
|
26847
|
+
return;
|
|
26848
|
+
}
|
|
26849
|
+
const record = saved.record;
|
|
25380
26850
|
if (opts.json) {
|
|
25381
26851
|
writeJsonLine({
|
|
25382
26852
|
command: "llm price",
|
|
25383
|
-
status: record.
|
|
25384
|
-
error: record.
|
|
26853
|
+
status: record.recommendedMaxCredits === null ? "failed" : "ok",
|
|
26854
|
+
error: record.recommendedMaxCredits === null ? record.noRecommendationReason ?? "no_recommendation" : null,
|
|
26855
|
+
currency: "credits",
|
|
25385
26856
|
ranAt: record.ranAt ?? null,
|
|
25386
26857
|
modelId: record.modelId ?? null,
|
|
25387
26858
|
samples: record.samples ?? null,
|
|
25388
26859
|
completed: record.completed ?? null,
|
|
25389
|
-
|
|
25390
|
-
|
|
25391
|
-
max: record.max ?? null,
|
|
25392
|
-
// Absent from a file an older CLI wrote; null / false / 0 then, never missing.
|
|
25393
|
-
fitP95: record.fitP95 ?? null,
|
|
25394
|
-
chargedBasedEstimateCoins: record.chargedBasedEstimateCoins ?? null,
|
|
25395
|
-
lengthRaisedPrice: record.lengthRaisedPrice ?? false,
|
|
25396
|
-
cutOffSamples: record.cutOffSamples ?? 0,
|
|
25397
|
-
noRecommendationReason: record.noRecommendationReason ?? null,
|
|
25398
|
-
recommendedDeclaredHeadroomBps: record.recommendedDeclaredHeadroomBps ?? null,
|
|
25399
|
-
recommendedEstimateCoins: record.recommendedEstimateCoins ?? null,
|
|
25400
|
-
recommendedPerCallMaxCoins: record.recommendedPerCallMaxCoins ?? null,
|
|
26860
|
+
...creditKeys(record),
|
|
26861
|
+
legacyCoinTerms: null,
|
|
25401
26862
|
savedTo: LLM_BENCH_FILE
|
|
25402
26863
|
});
|
|
25403
|
-
if (record.
|
|
26864
|
+
if (record.recommendedMaxCredits === null) process.exitCode = 1;
|
|
25404
26865
|
return;
|
|
25405
26866
|
}
|
|
25406
26867
|
log.plain(c.bold("genex llm price"));
|
|
25407
26868
|
log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${record.ranAt}`);
|
|
25408
26869
|
log.plain("");
|
|
25409
26870
|
printRecommendation(log, record);
|
|
25410
|
-
if (record.
|
|
26871
|
+
if (record.recommendedMaxCredits === null) process.exitCode = 1;
|
|
25411
26872
|
}
|
|
25412
26873
|
function writeJsonLine(value) {
|
|
25413
26874
|
process.stdout.write(`${JSON.stringify(value)}
|
|
@@ -25667,7 +27128,7 @@ function runtimeRow(state, lane, toolsOnly = false) {
|
|
|
25667
27128
|
return {
|
|
25668
27129
|
label: "Runtime",
|
|
25669
27130
|
value: `in-game LLM lane live \xB7 ${n} model${n === 1 ? "" : "s"}${keyRefused ? ` \xB7 ${c.yellow("provider key refused")}` : ""}`,
|
|
25670
|
-
fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : '
|
|
27131
|
+
fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Measure a call before a game declares its per-call ceiling: `npx genex llm bench "<prompt>" --max-credits <n> --user-approved`.'
|
|
25671
27132
|
};
|
|
25672
27133
|
}
|
|
25673
27134
|
case "off":
|
|
@@ -25727,7 +27188,7 @@ async function fetchLegalStatus(apiUrl, token) {
|
|
|
25727
27188
|
}
|
|
25728
27189
|
async function firstSkillsMarker() {
|
|
25729
27190
|
for (const target of resolveAgentTargets()) {
|
|
25730
|
-
const marker = await readSkillsMarker(
|
|
27191
|
+
const marker = await readSkillsMarker(path39.join(target.baseDir, "skills"));
|
|
25731
27192
|
if (marker) return marker;
|
|
25732
27193
|
}
|
|
25733
27194
|
return null;
|
|
@@ -25779,11 +27240,11 @@ async function runTools(opts) {
|
|
|
25779
27240
|
let totalUpdated = 0;
|
|
25780
27241
|
const refused = [];
|
|
25781
27242
|
for (const t of targets) {
|
|
25782
|
-
const dest =
|
|
27243
|
+
const dest = path40.join(t.baseDir, "skills");
|
|
25783
27244
|
let copied;
|
|
25784
27245
|
let updated;
|
|
25785
27246
|
try {
|
|
25786
|
-
({ copied, updated } = await copyTemplates(
|
|
27247
|
+
({ copied, updated } = await copyTemplates(path40.join(templatesDir, "skills"), dest, {
|
|
25787
27248
|
filter: (rel) => skillFamilyFilter("tools")(`skills/${rel}`)
|
|
25788
27249
|
}));
|
|
25789
27250
|
await pruneRemovedSkills(dest, log);
|
|
@@ -25855,8 +27316,8 @@ async function runTools(opts) {
|
|
|
25855
27316
|
}
|
|
25856
27317
|
|
|
25857
27318
|
// src/commands/budget.ts
|
|
25858
|
-
import
|
|
25859
|
-
import
|
|
27319
|
+
import fs39 from "fs/promises";
|
|
27320
|
+
import path41 from "path";
|
|
25860
27321
|
var BUDGET_SOURCE_LINE = "Live from your account; prices can change without a deploy.";
|
|
25861
27322
|
var BUDGET_APPROVAL_REQUIRED = "STOP: raising the asset allowance requires the player's explicit approval. Re-run with --user-approved only after they agreed to the number.";
|
|
25862
27323
|
function internalKind(kind) {
|
|
@@ -25864,7 +27325,7 @@ function internalKind(kind) {
|
|
|
25864
27325
|
}
|
|
25865
27326
|
async function isGenexProject(cwd) {
|
|
25866
27327
|
try {
|
|
25867
|
-
await
|
|
27328
|
+
await fs39.access(path41.join(cwd, ".genex"));
|
|
25868
27329
|
return true;
|
|
25869
27330
|
} catch {
|
|
25870
27331
|
return false;
|
|
@@ -26013,39 +27474,39 @@ async function runBudget(opts = {}) {
|
|
|
26013
27474
|
}
|
|
26014
27475
|
|
|
26015
27476
|
// src/commands/player.ts
|
|
26016
|
-
import
|
|
27477
|
+
import fs44 from "fs";
|
|
26017
27478
|
import fsp2 from "fs/promises";
|
|
26018
27479
|
import os12 from "os";
|
|
26019
|
-
import
|
|
27480
|
+
import path47 from "path";
|
|
26020
27481
|
import { spawn as spawn8 } from "child_process";
|
|
26021
27482
|
|
|
26022
27483
|
// src/lib/player/paths.ts
|
|
26023
27484
|
import os8 from "os";
|
|
26024
|
-
import
|
|
27485
|
+
import path42 from "path";
|
|
26025
27486
|
var PLAYER_DRY_RUN_ENV = "GENEX_PLAYER_DRY_RUN";
|
|
26026
27487
|
function playerDryRun() {
|
|
26027
27488
|
const raw = process.env[PLAYER_DRY_RUN_ENV];
|
|
26028
27489
|
return raw === "1" || raw === "true";
|
|
26029
27490
|
}
|
|
26030
27491
|
function playerPaths(home = os8.homedir()) {
|
|
26031
|
-
const dir = home === os8.homedir() ? getGenexDir() :
|
|
26032
|
-
const logDir =
|
|
27492
|
+
const dir = home === os8.homedir() ? getGenexDir() : path42.join(home, ".genex");
|
|
27493
|
+
const logDir = path42.join(dir, "logs");
|
|
26033
27494
|
return {
|
|
26034
|
-
credential:
|
|
26035
|
-
state:
|
|
26036
|
-
relaunch:
|
|
27495
|
+
credential: path42.join(dir, "player.json"),
|
|
27496
|
+
state: path42.join(dir, "player-state.json"),
|
|
27497
|
+
relaunch: path42.join(dir, "player-relaunch.json"),
|
|
26037
27498
|
logDir,
|
|
26038
|
-
log:
|
|
26039
|
-
errorLog:
|
|
27499
|
+
log: path42.join(logDir, "player.log"),
|
|
27500
|
+
errorLog: path42.join(logDir, "player.error.log")
|
|
26040
27501
|
};
|
|
26041
27502
|
}
|
|
26042
27503
|
|
|
26043
27504
|
// src/lib/player/store.ts
|
|
26044
|
-
import
|
|
26045
|
-
import
|
|
27505
|
+
import fs40 from "fs/promises";
|
|
27506
|
+
import path43 from "path";
|
|
26046
27507
|
async function readPlayerInstall(home) {
|
|
26047
27508
|
try {
|
|
26048
|
-
const raw = await
|
|
27509
|
+
const raw = await fs40.readFile(playerPaths(home).credential, "utf8");
|
|
26049
27510
|
const parsed = JSON.parse(raw);
|
|
26050
27511
|
if (!parsed || typeof parsed.token !== "string" || typeof parsed.apiUrl !== "string") return null;
|
|
26051
27512
|
return parsed;
|
|
@@ -26075,7 +27536,7 @@ function describeInstall(install2) {
|
|
|
26075
27536
|
}
|
|
26076
27537
|
async function readPlayerState(home) {
|
|
26077
27538
|
try {
|
|
26078
|
-
return JSON.parse(await
|
|
27539
|
+
return JSON.parse(await fs40.readFile(playerPaths(home).state, "utf8"));
|
|
26079
27540
|
} catch {
|
|
26080
27541
|
return null;
|
|
26081
27542
|
}
|
|
@@ -26083,8 +27544,8 @@ async function readPlayerState(home) {
|
|
|
26083
27544
|
async function writePlayerState(state, home) {
|
|
26084
27545
|
const file = playerPaths(home).state;
|
|
26085
27546
|
try {
|
|
26086
|
-
await
|
|
26087
|
-
await
|
|
27547
|
+
await fs40.mkdir(path43.dirname(file), { recursive: true, mode: 448 });
|
|
27548
|
+
await fs40.writeFile(file, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
|
|
26088
27549
|
} catch {
|
|
26089
27550
|
}
|
|
26090
27551
|
}
|
|
@@ -26092,8 +27553,8 @@ async function removePlayerFiles(home) {
|
|
|
26092
27553
|
const paths = playerPaths(home);
|
|
26093
27554
|
const removed = [];
|
|
26094
27555
|
for (const file of [paths.credential, paths.state, paths.relaunch]) {
|
|
26095
|
-
const existed = await
|
|
26096
|
-
await
|
|
27556
|
+
const existed = await fs40.stat(file).then(() => true, () => false);
|
|
27557
|
+
await fs40.rm(file, { force: true }).catch(() => {
|
|
26097
27558
|
});
|
|
26098
27559
|
if (existed) removed.push(file);
|
|
26099
27560
|
}
|
|
@@ -26227,14 +27688,14 @@ var PlayerClient = class {
|
|
|
26227
27688
|
this.version = options.version ?? getCliVersion();
|
|
26228
27689
|
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
26229
27690
|
}
|
|
26230
|
-
async call(
|
|
27691
|
+
async call(path49, init = {}, provider) {
|
|
26231
27692
|
const headers = {
|
|
26232
27693
|
Authorization: `Bearer ${this.token}`,
|
|
26233
27694
|
[PLAYER_VERSION_HEADER]: this.version,
|
|
26234
27695
|
...provider ? { [EXTERNAL_PROVIDER_HEADER]: provider } : {},
|
|
26235
27696
|
...init.body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
26236
27697
|
};
|
|
26237
|
-
const res = await apiFetch(`${this.apiUrl}${
|
|
27698
|
+
const res = await apiFetch(`${this.apiUrl}${path49}`, {
|
|
26238
27699
|
...init,
|
|
26239
27700
|
headers,
|
|
26240
27701
|
signal: AbortSignal.timeout(this.timeoutMs)
|
|
@@ -26303,10 +27764,10 @@ var PlayerClient = class {
|
|
|
26303
27764
|
|
|
26304
27765
|
// src/lib/player/runners.ts
|
|
26305
27766
|
import { spawn as spawn6, spawnSync } from "child_process";
|
|
26306
|
-
import
|
|
27767
|
+
import fs41 from "fs/promises";
|
|
26307
27768
|
import fsSync from "fs";
|
|
26308
27769
|
import os9 from "os";
|
|
26309
|
-
import
|
|
27770
|
+
import path44 from "path";
|
|
26310
27771
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
26311
27772
|
var WATCHER_RUNNERS = ["claude", "codex"];
|
|
26312
27773
|
var RUNNER_FOR_FAMILY = { claude: "claude", chatgpt: "codex" };
|
|
@@ -26364,7 +27825,7 @@ function resolveOnPath(name, env = process.env, platform = process.platform) {
|
|
|
26364
27825
|
const candidates = win ? [`${name}.cmd`, `${name}.exe`, `${name}.bat`, name] : [name];
|
|
26365
27826
|
for (const dir of dirs) {
|
|
26366
27827
|
for (const candidate of candidates) {
|
|
26367
|
-
const file =
|
|
27828
|
+
const file = path44.join(dir, candidate);
|
|
26368
27829
|
try {
|
|
26369
27830
|
const stat = fsSync.statSync(file);
|
|
26370
27831
|
if (!stat.isFile()) continue;
|
|
@@ -26528,17 +27989,17 @@ async function runJob(job, options = {}) {
|
|
|
26528
27989
|
if (!resolved) {
|
|
26529
27990
|
return { ok: false, release: "runner_unavailable", detail: `missing:${runner}`, durationMs: Date.now() - started };
|
|
26530
27991
|
}
|
|
26531
|
-
const cwd =
|
|
26532
|
-
await
|
|
26533
|
-
const outFile =
|
|
27992
|
+
const cwd = path44.join(os9.tmpdir(), `genex-player-${randomUUID3()}`);
|
|
27993
|
+
await fs41.mkdir(cwd, { recursive: true, mode: 448 });
|
|
27994
|
+
const outFile = path44.join(cwd, "result.out");
|
|
26534
27995
|
let argv;
|
|
26535
27996
|
if (runner === "claude") {
|
|
26536
27997
|
argv = claudeArgv(job, policy.claude ?? FALLBACK_POLICY.claude ?? {});
|
|
26537
27998
|
} else {
|
|
26538
27999
|
let schemaFile;
|
|
26539
28000
|
if (job.outputFormat === "json" && job.schema !== void 0) {
|
|
26540
|
-
schemaFile =
|
|
26541
|
-
await
|
|
28001
|
+
schemaFile = path44.join(cwd, "schema.json");
|
|
28002
|
+
await fs41.writeFile(schemaFile, JSON.stringify(job.schema), { mode: 384 });
|
|
26542
28003
|
}
|
|
26543
28004
|
argv = codexArgv(job, policy.codex ?? FALLBACK_POLICY.codex ?? {}, { cwd, outFile, ...schemaFile ? { schemaFile } : {} });
|
|
26544
28005
|
}
|
|
@@ -26602,7 +28063,7 @@ async function runJob(job, options = {}) {
|
|
|
26602
28063
|
if (!read2.ok) return { ok: false, release: "failed", detail: read2.detail, exitCode: code, durationMs };
|
|
26603
28064
|
return { ok: true, output: read2.output, exitCode: code, durationMs };
|
|
26604
28065
|
}
|
|
26605
|
-
const text = await
|
|
28066
|
+
const text = await fs41.readFile(outFile, "utf8").catch(() => "");
|
|
26606
28067
|
if (code !== 0 && !text.trim()) {
|
|
26607
28068
|
const classified = classifyFailure(`${stderrText} exit ${code}`);
|
|
26608
28069
|
return { ok: false, ...classified, detail: `exit_${code}`, exitCode: code, durationMs };
|
|
@@ -26611,15 +28072,15 @@ async function runJob(job, options = {}) {
|
|
|
26611
28072
|
if (!read.ok) return { ok: false, release: "failed", detail: read.detail, exitCode: code, durationMs };
|
|
26612
28073
|
return { ok: true, output: read.output, exitCode: code, durationMs };
|
|
26613
28074
|
} finally {
|
|
26614
|
-
await
|
|
28075
|
+
await fs41.rm(cwd, { recursive: true, force: true }).catch(() => {
|
|
26615
28076
|
});
|
|
26616
28077
|
}
|
|
26617
28078
|
}
|
|
26618
28079
|
|
|
26619
28080
|
// src/lib/player/service.ts
|
|
26620
|
-
import
|
|
28081
|
+
import fs42 from "fs/promises";
|
|
26621
28082
|
import os10 from "os";
|
|
26622
|
-
import
|
|
28083
|
+
import path45 from "path";
|
|
26623
28084
|
import { execFile } from "child_process";
|
|
26624
28085
|
import { promisify } from "util";
|
|
26625
28086
|
var run2 = promisify(execFile);
|
|
@@ -26636,9 +28097,9 @@ function servicePlatform(platform = process.platform) {
|
|
|
26636
28097
|
}
|
|
26637
28098
|
function servicePaths(platform, home = os10.homedir()) {
|
|
26638
28099
|
const { log, errorLog } = playerPaths(home);
|
|
26639
|
-
if (platform === "darwin") return { log, errorLog, unit:
|
|
26640
|
-
if (platform === "linux") return { log, errorLog, unit:
|
|
26641
|
-
return { log, errorLog, unit:
|
|
28100
|
+
if (platform === "darwin") return { log, errorLog, unit: path45.join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
|
|
28101
|
+
if (platform === "linux") return { log, errorLog, unit: path45.join(home, ".config", "systemd", "user", SYSTEMD_UNIT) };
|
|
28102
|
+
return { log, errorLog, unit: path45.join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "genex-player.cmd") };
|
|
26642
28103
|
}
|
|
26643
28104
|
function xmlEscape(value) {
|
|
26644
28105
|
return value.replace(/[<>&'"]/g, (ch) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[ch]);
|
|
@@ -26717,9 +28178,9 @@ function autostartCommand(opts) {
|
|
|
26717
28178
|
}
|
|
26718
28179
|
async function installService(input, platform = servicePlatform()) {
|
|
26719
28180
|
const paths = servicePaths(platform, input.home);
|
|
26720
|
-
await
|
|
26721
|
-
await
|
|
26722
|
-
await
|
|
28181
|
+
await fs42.mkdir(path45.dirname(paths.unit), { recursive: true });
|
|
28182
|
+
await fs42.mkdir(path45.dirname(paths.log), { recursive: true, mode: 448 });
|
|
28183
|
+
await fs42.writeFile(paths.unit, unitFile(platform, input, paths), { mode: platform === "win32" ? 420 : 384 });
|
|
26723
28184
|
if (playerDryRun()) return { unit: paths.unit, loaded: false, detail: "dry run \u2014 the unit was written, the system was not asked" };
|
|
26724
28185
|
if (platform === "darwin") {
|
|
26725
28186
|
const domain = `gui/${process.getuid?.() ?? 501}`;
|
|
@@ -26748,7 +28209,7 @@ async function installService(input, platform = servicePlatform()) {
|
|
|
26748
28209
|
}
|
|
26749
28210
|
async function serviceInstalled(home = os10.homedir(), platform = servicePlatform()) {
|
|
26750
28211
|
const paths = servicePaths(platform, home);
|
|
26751
|
-
return await
|
|
28212
|
+
return await fs42.stat(paths.unit).then(() => paths.unit, () => null);
|
|
26752
28213
|
}
|
|
26753
28214
|
async function stopService(home = os10.homedir(), platform = servicePlatform()) {
|
|
26754
28215
|
const paths = servicePaths(platform, home);
|
|
@@ -26778,15 +28239,15 @@ async function uninstallService(home = os10.homedir(), platform = servicePlatfor
|
|
|
26778
28239
|
});
|
|
26779
28240
|
}
|
|
26780
28241
|
}
|
|
26781
|
-
await
|
|
28242
|
+
await fs42.rm(paths.unit, { force: true }).catch(() => {
|
|
26782
28243
|
});
|
|
26783
28244
|
return { unit: paths.unit, loaded: false, detail: present ? "removed" : "nothing installed" };
|
|
26784
28245
|
}
|
|
26785
28246
|
|
|
26786
28247
|
// src/lib/player/relaunch.ts
|
|
26787
|
-
import
|
|
28248
|
+
import fs43 from "fs/promises";
|
|
26788
28249
|
import fsSync2 from "fs";
|
|
26789
|
-
import
|
|
28250
|
+
import path46 from "path";
|
|
26790
28251
|
import { spawn as spawn7 } from "child_process";
|
|
26791
28252
|
var RELAUNCH_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
26792
28253
|
function relaunchPlan(input) {
|
|
@@ -26814,7 +28275,7 @@ function relaunchPossible() {
|
|
|
26814
28275
|
}
|
|
26815
28276
|
async function relaunchOnCooldown(home, now = Date.now()) {
|
|
26816
28277
|
try {
|
|
26817
|
-
const raw = await
|
|
28278
|
+
const raw = await fs43.readFile(playerPaths(home).relaunch, "utf8");
|
|
26818
28279
|
const at = Date.parse(JSON.parse(raw).at ?? "");
|
|
26819
28280
|
return Number.isFinite(at) && now - at < RELAUNCH_COOLDOWN_MS;
|
|
26820
28281
|
} catch {
|
|
@@ -26824,8 +28285,8 @@ async function relaunchOnCooldown(home, now = Date.now()) {
|
|
|
26824
28285
|
async function markRelaunch(version, home) {
|
|
26825
28286
|
const file = playerPaths(home).relaunch;
|
|
26826
28287
|
try {
|
|
26827
|
-
await
|
|
26828
|
-
await
|
|
28288
|
+
await fs43.mkdir(path46.dirname(file), { recursive: true, mode: 448 });
|
|
28289
|
+
await fs43.writeFile(file, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), version }) + "\n", { mode: 384 });
|
|
26829
28290
|
} catch {
|
|
26830
28291
|
}
|
|
26831
28292
|
}
|
|
@@ -26849,7 +28310,7 @@ async function relaunchWatcher(input) {
|
|
|
26849
28310
|
let err;
|
|
26850
28311
|
const paths = playerPaths(input.home);
|
|
26851
28312
|
try {
|
|
26852
|
-
await
|
|
28313
|
+
await fs43.mkdir(paths.logDir, { recursive: true, mode: 448 });
|
|
26853
28314
|
out = fsSync2.openSync(paths.log, "a", 384);
|
|
26854
28315
|
err = fsSync2.openSync(paths.errorLog, "a", 384);
|
|
26855
28316
|
} catch {
|
|
@@ -27187,15 +28648,15 @@ function defaultLabel2() {
|
|
|
27187
28648
|
}
|
|
27188
28649
|
|
|
27189
28650
|
// src/commands/player.ts
|
|
27190
|
-
var
|
|
28651
|
+
var SUBS5 = ["install", "run", "status", "stop", "uninstall"];
|
|
27191
28652
|
var PLAYER_NOT_INSTALLED = "This machine is not set up to answer in-game model requests \u2014 run `genex player install` first.";
|
|
27192
28653
|
var PLAYER_REAUTH_REQUIRED = "Genex no longer accepts this machine's approval \u2014 run `genex player install` again.";
|
|
27193
28654
|
var PLAYER_NO_RUNNERS = "Neither `claude` nor `codex` is on this machine's PATH, so nothing can be answered yet. Install the one whose subscription you pay for, sign into it, and this watcher picks it up on its own.";
|
|
27194
28655
|
async function runPlayer(opts = {}) {
|
|
27195
28656
|
const log = createLogger({ quiet: opts.quiet || opts.json });
|
|
27196
28657
|
const sub = opts.name?.trim() || "status";
|
|
27197
|
-
if (!
|
|
27198
|
-
fail4(opts, log, `player ${sub}`, `Unknown subcommand ${sub}. Use: ${
|
|
28658
|
+
if (!SUBS5.includes(sub)) {
|
|
28659
|
+
fail4(opts, log, `player ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS5.join(", ")}.`);
|
|
27199
28660
|
return;
|
|
27200
28661
|
}
|
|
27201
28662
|
if (opts.dryRun) process.env[PLAYER_DRY_RUN_ENV] = "1";
|
|
@@ -27330,8 +28791,8 @@ async function spawnDetachedRun(apiUrl, opts) {
|
|
|
27330
28791
|
try {
|
|
27331
28792
|
const paths = playerPaths();
|
|
27332
28793
|
await fsp2.mkdir(paths.logDir, { recursive: true, mode: 448 });
|
|
27333
|
-
out =
|
|
27334
|
-
err =
|
|
28794
|
+
out = fs44.openSync(paths.log, "a", 384);
|
|
28795
|
+
err = fs44.openSync(paths.errorLog, "a", 384);
|
|
27335
28796
|
const args = [process.argv[1] ?? "", "player", "run", ...opts.apiUrl ? ["--api-url", apiUrl] : []];
|
|
27336
28797
|
const child = spawn8(process.execPath, args, { detached: true, stdio: ["ignore", out, err] });
|
|
27337
28798
|
child.on("error", () => {
|
|
@@ -27342,7 +28803,7 @@ async function spawnDetachedRun(apiUrl, opts) {
|
|
|
27342
28803
|
return false;
|
|
27343
28804
|
} finally {
|
|
27344
28805
|
for (const fd of [out, err]) if (fd !== void 0) try {
|
|
27345
|
-
|
|
28806
|
+
fs44.closeSync(fd);
|
|
27346
28807
|
} catch {
|
|
27347
28808
|
}
|
|
27348
28809
|
}
|
|
@@ -27541,7 +29002,7 @@ async function uninstall(opts, log) {
|
|
|
27541
29002
|
writeJsonLine2({ command: "player uninstall", status: "ok", error: null, unit: service.unit, detail: service.detail, removed });
|
|
27542
29003
|
return;
|
|
27543
29004
|
}
|
|
27544
|
-
log.success(`Removed: ${service.detail} (${
|
|
29005
|
+
log.success(`Removed: ${service.detail} (${path47.basename(service.unit)})`);
|
|
27545
29006
|
for (const file of removed) log.dim(` deleted ${file}`);
|
|
27546
29007
|
if (removed.length === 0) log.dim(" no credential was stored here");
|
|
27547
29008
|
log.plain("");
|
|
@@ -27559,46 +29020,46 @@ function writeJsonLine2(value) {
|
|
|
27559
29020
|
}
|
|
27560
29021
|
|
|
27561
29022
|
// src/lib/native-build.ts
|
|
27562
|
-
import { createHash as
|
|
29023
|
+
import { createHash as createHash4 } from "crypto";
|
|
27563
29024
|
import { createReadStream } from "fs";
|
|
27564
|
-
import
|
|
29025
|
+
import fs46 from "fs/promises";
|
|
27565
29026
|
import os13 from "os";
|
|
27566
|
-
import
|
|
29027
|
+
import path48 from "path";
|
|
27567
29028
|
import { execFile as execFile2 } from "child_process";
|
|
27568
29029
|
import { promisify as promisify2 } from "util";
|
|
27569
29030
|
|
|
27570
29031
|
// src/lib/native-zip.ts
|
|
27571
|
-
import
|
|
27572
|
-
import
|
|
27573
|
-
var
|
|
29032
|
+
import fs45 from "fs/promises";
|
|
29033
|
+
import zlib3 from "zlib";
|
|
29034
|
+
var EOCD_SIGNATURE2 = 101010256;
|
|
27574
29035
|
var EOCD64_LOCATOR_SIGNATURE = 117853008;
|
|
27575
29036
|
var EOCD64_SIGNATURE = 101075792;
|
|
27576
|
-
var
|
|
27577
|
-
var
|
|
29037
|
+
var CENTRAL_SIGNATURE2 = 33639248;
|
|
29038
|
+
var LOCAL_SIGNATURE2 = 67324752;
|
|
27578
29039
|
var EOCD_MIN = 22;
|
|
27579
|
-
var
|
|
29040
|
+
var COMMENT_MAX2 = 65535;
|
|
27580
29041
|
var ZIP64_EXTRA = 1;
|
|
27581
29042
|
var U32_MAX = 4294967295;
|
|
27582
29043
|
var U16_MAX = 65535;
|
|
27583
29044
|
var DIRECTORY_MAX_BYTES = 256 * 1024 * 1024;
|
|
27584
|
-
var
|
|
29045
|
+
var UNIX_HOST2 = 3;
|
|
27585
29046
|
var INPUT_MIN = 64 * 1024;
|
|
27586
29047
|
var INPUT_MAX = 1024 * 1024;
|
|
27587
|
-
var
|
|
29048
|
+
var S_IFMT2 = 61440;
|
|
27588
29049
|
var S_IFLNK = 40960;
|
|
27589
29050
|
var isDirectory = (entry) => entry.name.endsWith("/");
|
|
27590
|
-
var isSymlink = (entry) => entry.mode !== null && (entry.mode &
|
|
29051
|
+
var isSymlink = (entry) => entry.mode !== null && (entry.mode & S_IFMT2) === S_IFLNK;
|
|
27591
29052
|
async function readAt2(file, position, length) {
|
|
27592
29053
|
const buffer = Buffer.alloc(length);
|
|
27593
29054
|
const { bytesRead } = await file.read(buffer, 0, length, position);
|
|
27594
29055
|
return buffer.subarray(0, bytesRead);
|
|
27595
29056
|
}
|
|
27596
|
-
async function
|
|
27597
|
-
const tailLength = Math.min(size, EOCD_MIN +
|
|
29057
|
+
async function findDirectory2(file, size) {
|
|
29058
|
+
const tailLength = Math.min(size, EOCD_MIN + COMMENT_MAX2);
|
|
27598
29059
|
const tail = await readAt2(file, size - tailLength, tailLength);
|
|
27599
29060
|
let at = -1;
|
|
27600
29061
|
for (let i = tail.length - EOCD_MIN; i >= 0; i--) {
|
|
27601
|
-
if (tail.readUInt32LE(i) ===
|
|
29062
|
+
if (tail.readUInt32LE(i) === EOCD_SIGNATURE2) {
|
|
27602
29063
|
at = i;
|
|
27603
29064
|
break;
|
|
27604
29065
|
}
|
|
@@ -27640,15 +29101,15 @@ function applyZip64(extra, entry, overflowed) {
|
|
|
27640
29101
|
}
|
|
27641
29102
|
}
|
|
27642
29103
|
async function readZipEntries(filePath) {
|
|
27643
|
-
const file = await
|
|
29104
|
+
const file = await fs45.open(filePath, "r");
|
|
27644
29105
|
try {
|
|
27645
29106
|
const { size } = await file.stat();
|
|
27646
|
-
const directory = await
|
|
29107
|
+
const directory = await findDirectory2(file, size);
|
|
27647
29108
|
const data = await readAt2(file, directory.offset, directory.bytes);
|
|
27648
29109
|
const entries = [];
|
|
27649
29110
|
let at = 0;
|
|
27650
29111
|
while (entries.length < directory.count && at + 46 <= data.length) {
|
|
27651
|
-
if (data.readUInt32LE(at) !==
|
|
29112
|
+
if (data.readUInt32LE(at) !== CENTRAL_SIGNATURE2) throw new Error("broken zip directory entry");
|
|
27652
29113
|
const madeBy = data.readUInt16LE(at + 4);
|
|
27653
29114
|
const nameLength = data.readUInt16LE(at + 28);
|
|
27654
29115
|
const extraLength = data.readUInt16LE(at + 30);
|
|
@@ -27660,7 +29121,7 @@ async function readZipEntries(filePath) {
|
|
|
27660
29121
|
compressedSize: data.readUInt32LE(at + 20),
|
|
27661
29122
|
uncompressedSize: data.readUInt32LE(at + 24),
|
|
27662
29123
|
localOffset: data.readUInt32LE(at + 42),
|
|
27663
|
-
mode: madeBy >> 8 ===
|
|
29124
|
+
mode: madeBy >> 8 === UNIX_HOST2 ? external >>> 16 : null
|
|
27664
29125
|
};
|
|
27665
29126
|
applyZip64(data.subarray(at + 46 + nameLength, at + 46 + nameLength + extraLength), entry, {
|
|
27666
29127
|
uncompressed: entry.uncompressedSize === U32_MAX,
|
|
@@ -27677,10 +29138,10 @@ async function readZipEntries(filePath) {
|
|
|
27677
29138
|
}
|
|
27678
29139
|
}
|
|
27679
29140
|
async function readZipEntryHead(filePath, entry, maxBytes) {
|
|
27680
|
-
const file = await
|
|
29141
|
+
const file = await fs45.open(filePath, "r");
|
|
27681
29142
|
try {
|
|
27682
29143
|
const local = await readAt2(file, entry.localOffset, 30);
|
|
27683
|
-
if (local.length < 30 || local.readUInt32LE(0) !==
|
|
29144
|
+
if (local.length < 30 || local.readUInt32LE(0) !== LOCAL_SIGNATURE2) throw new Error(`broken zip entry ${entry.name}`);
|
|
27684
29145
|
const start = entry.localOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
27685
29146
|
if (entry.method === 0) return await readAt2(file, start, Math.min(maxBytes, entry.uncompressedSize));
|
|
27686
29147
|
if (entry.method !== 8) throw new Error(`${entry.name} uses a compression this command cannot read`);
|
|
@@ -27692,7 +29153,7 @@ async function readZipEntryHead(filePath, entry, maxBytes) {
|
|
|
27692
29153
|
}
|
|
27693
29154
|
function inflateHead(input, maxBytes) {
|
|
27694
29155
|
return new Promise((resolve) => {
|
|
27695
|
-
const inflater =
|
|
29156
|
+
const inflater = zlib3.createInflateRaw();
|
|
27696
29157
|
const chunks = [];
|
|
27697
29158
|
let total = 0;
|
|
27698
29159
|
const finish2 = () => resolve(Buffer.concat(chunks).subarray(0, maxBytes));
|
|
@@ -27711,7 +29172,7 @@ function inflateHead(input, maxBytes) {
|
|
|
27711
29172
|
var run4 = promisify2(execFile2);
|
|
27712
29173
|
var NATIVE_PLATFORMS = ["mac", "windows", "linux"];
|
|
27713
29174
|
var NATIVE_ARCHES = ["arm64", "x64", "universal"];
|
|
27714
|
-
var
|
|
29175
|
+
var VERSION2 = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
27715
29176
|
var VERSION_MAX = 64;
|
|
27716
29177
|
var OS_VERSION = /^\d{1,4}(\.\d{1,6}){0,3}$/;
|
|
27717
29178
|
var ENGINE = /^[a-z][a-z0-9-]{1,39}$/;
|
|
@@ -27741,7 +29202,7 @@ function rootApps(entries) {
|
|
|
27741
29202
|
}
|
|
27742
29203
|
function launchableExes(entries) {
|
|
27743
29204
|
const exes = entries.filter((e) => !isDirectory(e) && e.name.toLowerCase().endsWith(".exe"));
|
|
27744
|
-
const games = exes.filter((e) => e.name.split("/").length <= ENTRY_DEPTH_MAX && !HELPER_EXE.test(
|
|
29205
|
+
const games = exes.filter((e) => e.name.split("/").length <= ENTRY_DEPTH_MAX && !HELPER_EXE.test(path48.posix.basename(e.name)));
|
|
27745
29206
|
const depth = (name) => name.split("/").length;
|
|
27746
29207
|
return games.map((e) => e.name).sort((a, b) => depth(a) - depth(b) || a.localeCompare(b));
|
|
27747
29208
|
}
|
|
@@ -27823,7 +29284,7 @@ function exeArch(head) {
|
|
|
27823
29284
|
}
|
|
27824
29285
|
function engineOf(entries) {
|
|
27825
29286
|
const names2 = entries.map((e) => e.name.toLowerCase());
|
|
27826
|
-
const base = (name) =>
|
|
29287
|
+
const base = (name) => path48.posix.basename(name.replace(/\/$/, ""));
|
|
27827
29288
|
if (names2.some((n) => /^unityplayer\.(dll|dylib|so)$/.test(base(n)) || base(n) === "globalgamemanagers")) return "unity";
|
|
27828
29289
|
if (names2.some((n) => n.startsWith("engine/binaries/") || /\.app\/contents\/ue[45]?\//.test(n) || n.includes("/engine/binaries/")))
|
|
27829
29290
|
return "unreal";
|
|
@@ -27867,7 +29328,7 @@ async function describeNativeArchive(zipPath, asked = {}) {
|
|
|
27867
29328
|
try {
|
|
27868
29329
|
entries = await readZipEntries(zipPath);
|
|
27869
29330
|
} catch (err) {
|
|
27870
|
-
throw new NativeBuildError(`Couldn't read ${
|
|
29331
|
+
throw new NativeBuildError(`Couldn't read ${path48.basename(zipPath)} as a zip: ${err.message}`);
|
|
27871
29332
|
}
|
|
27872
29333
|
if (entries.length === 0) throw new NativeBuildError("The archive is empty.");
|
|
27873
29334
|
const platform = platformOf(entries, asked.platform);
|
|
@@ -27889,19 +29350,19 @@ async function describeNativeArchive(zipPath, asked = {}) {
|
|
|
27889
29350
|
...mac?.appVersion ? { appVersion: mac.appVersion } : {}
|
|
27890
29351
|
};
|
|
27891
29352
|
}
|
|
27892
|
-
function buildVersion(asked, appVersion,
|
|
29353
|
+
function buildVersion(asked, appVersion, sha2563) {
|
|
27893
29354
|
if (asked !== void 0) {
|
|
27894
|
-
if (asked.length > VERSION_MAX || !
|
|
29355
|
+
if (asked.length > VERSION_MAX || !VERSION2.test(asked)) {
|
|
27895
29356
|
throw new NativeBuildError(`--build-version "${asked}" may hold only letters, digits and . _ + - (at most ${VERSION_MAX}).`);
|
|
27896
29357
|
}
|
|
27897
29358
|
return asked;
|
|
27898
29359
|
}
|
|
27899
|
-
const hash =
|
|
29360
|
+
const hash = sha2563.slice(0, VERSION_SHA_CHARS);
|
|
27900
29361
|
const app = (appVersion ?? "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, VERSION_MAX - hash.length - 1);
|
|
27901
29362
|
return app ? `${app}+${hash}` : hash;
|
|
27902
29363
|
}
|
|
27903
29364
|
async function hashFile(filePath, onProgress) {
|
|
27904
|
-
const hash =
|
|
29365
|
+
const hash = createHash4("sha256");
|
|
27905
29366
|
let sizeBytes = 0;
|
|
27906
29367
|
for await (const chunk of createReadStream(filePath)) {
|
|
27907
29368
|
hash.update(chunk);
|
|
@@ -27912,13 +29373,13 @@ async function hashFile(filePath, onProgress) {
|
|
|
27912
29373
|
}
|
|
27913
29374
|
async function packTarget(dir) {
|
|
27914
29375
|
if (dir.toLowerCase().endsWith(".app")) return { target: dir, keepParent: true };
|
|
27915
|
-
const children = await
|
|
29376
|
+
const children = await fs46.readdir(dir, { withFileTypes: true });
|
|
27916
29377
|
const apps = children.filter((d) => d.isDirectory() && d.name.toLowerCase().endsWith(".app"));
|
|
27917
|
-
if (apps.length === 1) return { target:
|
|
29378
|
+
if (apps.length === 1) return { target: path48.join(dir, apps[0].name), keepParent: true };
|
|
27918
29379
|
return { target: dir, keepParent: false };
|
|
27919
29380
|
}
|
|
27920
29381
|
async function packNativeBuild(input) {
|
|
27921
|
-
const stat = await
|
|
29382
|
+
const stat = await fs46.stat(input).catch(() => null);
|
|
27922
29383
|
if (!stat) throw new NativeBuildError(`${input} does not exist.`);
|
|
27923
29384
|
if (stat.isFile()) {
|
|
27924
29385
|
if (!input.toLowerCase().endsWith(".zip")) throw new NativeBuildError("Pass a .zip, a .app, or the build's folder.");
|
|
@@ -27926,14 +29387,14 @@ async function packNativeBuild(input) {
|
|
|
27926
29387
|
} };
|
|
27927
29388
|
}
|
|
27928
29389
|
const { target, keepParent } = await packTarget(input);
|
|
27929
|
-
const dir = await
|
|
27930
|
-
const zipPath =
|
|
27931
|
-
const cleanup = () =>
|
|
29390
|
+
const dir = await fs46.mkdtemp(path48.join(os13.tmpdir(), "genex-native-"));
|
|
29391
|
+
const zipPath = path48.join(dir, `${path48.basename(target).replace(/[^A-Za-z0-9._-]+/g, "-") || "build"}.zip`);
|
|
29392
|
+
const cleanup = () => fs46.rm(dir, { recursive: true, force: true });
|
|
27932
29393
|
try {
|
|
27933
29394
|
if (process.platform === "darwin") {
|
|
27934
29395
|
await run4("ditto", ["-c", "-k", "--sequesterRsrc", ...keepParent ? ["--keepParent"] : [], target, zipPath]);
|
|
27935
29396
|
} else if (keepParent) {
|
|
27936
|
-
await run4("zip", ["-q", "-r", "-y", zipPath,
|
|
29397
|
+
await run4("zip", ["-q", "-r", "-y", zipPath, path48.basename(target)], { cwd: path48.dirname(target) });
|
|
27937
29398
|
} else {
|
|
27938
29399
|
await run4("zip", ["-q", "-r", "-y", zipPath, "."], { cwd: target });
|
|
27939
29400
|
}
|
|
@@ -27948,7 +29409,7 @@ async function packNativeBuild(input) {
|
|
|
27948
29409
|
}
|
|
27949
29410
|
|
|
27950
29411
|
// src/lib/native-upload.ts
|
|
27951
|
-
import
|
|
29412
|
+
import fs47 from "fs/promises";
|
|
27952
29413
|
var PART_CONCURRENCY = 2;
|
|
27953
29414
|
async function call(ctx, route, init, what, log) {
|
|
27954
29415
|
let res;
|
|
@@ -27994,7 +29455,7 @@ async function uploadNativeArchive(grant, zipPath, sizeBytes, log, onProgress) {
|
|
|
27994
29455
|
const base = grant.uploadUrl.replace(/\/+$/, "");
|
|
27995
29456
|
const encoded = grant.uploadPath.split("/").map(encodeURIComponent).join("/");
|
|
27996
29457
|
const auth = { Authorization: `Bearer ${grant.token}` };
|
|
27997
|
-
const file = await
|
|
29458
|
+
const file = await fs47.open(zipPath, "r");
|
|
27998
29459
|
try {
|
|
27999
29460
|
if (sizeBytes <= grant.limits.singlePutMaxBytes) {
|
|
28000
29461
|
const body = await readPart(file, 0, sizeBytes);
|
|
@@ -28173,10 +29634,10 @@ async function runPublish2(opts, log) {
|
|
|
28173
29634
|
const description = await describeNativeArchive(packed.zipPath, opts);
|
|
28174
29635
|
log.step(describeLine(description));
|
|
28175
29636
|
log.step("Hashing the archive\u2026");
|
|
28176
|
-
const { sha256:
|
|
28177
|
-
const version = buildVersion(opts.buildVersion, description.appVersion,
|
|
29637
|
+
const { sha256: sha2563, sizeBytes } = await hashFile(packed.zipPath);
|
|
29638
|
+
const version = buildVersion(opts.buildVersion, description.appVersion, sha2563);
|
|
28178
29639
|
const { platform, arch, entry, installedBytes, minOs, engine } = description;
|
|
28179
|
-
const grant = await requestNativeUpload(ctx, { platform, arch, sha256:
|
|
29640
|
+
const grant = await requestNativeUpload(ctx, { platform, arch, sha256: sha2563, sizeBytes }, log);
|
|
28180
29641
|
if (!grant) return false;
|
|
28181
29642
|
if (grant.uploaded) {
|
|
28182
29643
|
log.step(`Already uploaded (${sizeLine(sizeBytes)}).`);
|
|
@@ -28187,7 +29648,7 @@ async function runPublish2(opts, log) {
|
|
|
28187
29648
|
}
|
|
28188
29649
|
const build = await publishNativeBuild(
|
|
28189
29650
|
ctx,
|
|
28190
|
-
{ platform, arch, sha256:
|
|
29651
|
+
{ platform, arch, sha256: sha2563, sizeBytes, installedBytes, version, entry, ...minOs ? { minOs } : {}, ...engine ? { engine } : {} },
|
|
28191
29652
|
log
|
|
28192
29653
|
);
|
|
28193
29654
|
if (!build) return false;
|
|
@@ -28344,16 +29805,16 @@ ${c.bold("Usage")}
|
|
|
28344
29805
|
status | cancel. "models" says whether this
|
|
28345
29806
|
stand serves the runtime LLM lane at all, and
|
|
28346
29807
|
at what provider rates. "bench" runs the real
|
|
28347
|
-
model on YOUR OWN
|
|
28348
|
-
attempt
|
|
29808
|
+
model on YOUR OWN credits and reports what each
|
|
29809
|
+
attempt really cost; it needs --max-credits <n>
|
|
28349
29810
|
--user-approved, like any spend the player has
|
|
28350
29811
|
to agree to. "price" reprints the last run's
|
|
28351
|
-
recommended
|
|
29812
|
+
recommended maxCredits. "status" lists this
|
|
28352
29813
|
project's open calls and the slots they hold;
|
|
28353
29814
|
"cancel <id>" stops an active one. Declare a
|
|
28354
|
-
game's
|
|
28355
|
-
a
|
|
28356
|
-
|
|
29815
|
+
game's per-call ceiling from a bench, never
|
|
29816
|
+
from a guess: the player is billed as used, and
|
|
29817
|
+
the ceiling also sizes how long an answer may be.
|
|
28357
29818
|
genex player <sub> [options] Answer the in-game model requests you approve, on your
|
|
28358
29819
|
OWN Claude or ChatGPT subscription: install | run |
|
|
28359
29820
|
status | stop | uninstall. "install" signs this machine
|
|
@@ -28437,6 +29898,25 @@ ${c.bold("Usage")}
|
|
|
28437
29898
|
--resellable lets players resell the item to each
|
|
28438
29899
|
other on the market; durable items only, off by
|
|
28439
29900
|
default, and --no-resellable turns it back off.
|
|
29901
|
+
Store items: --price 0 is Free; --description,
|
|
29902
|
+
--tag <word> (repeat; "set <id> --clear-tags"
|
|
29903
|
+
empties them), --image <file.png|webp>
|
|
29904
|
+
(its transparent picture), --model <file.glb>,
|
|
29905
|
+
--package <dir> (files for other games' agents;
|
|
29906
|
+
.git, node_modules, .genex, secrets and links
|
|
29907
|
+
stay out) and --shared ("other games can use
|
|
29908
|
+
it"; durable only). With the store on, "add"
|
|
29909
|
+
makes a draft and --json prints it as one
|
|
29910
|
+
document (a refusal: one genex.item_error
|
|
29911
|
+
line, exit 1). "publish <id>" puts it on sale and
|
|
29912
|
+
"unpublish <id>" takes it off \u2014 in the Genex
|
|
29913
|
+
app the person presses Put on sale instead.
|
|
29914
|
+
genex items <sub> [words|id] Other creators' shared items in this game:
|
|
29915
|
+
search | add | remove | list. "add <id>" lets
|
|
29916
|
+
players use it here (free, not a purchase) and
|
|
29917
|
+
writes genex-items/<id>/ \u2014 item.json and the
|
|
29918
|
+
author's package \u2014 for your agent to integrate.
|
|
29919
|
+
It reaches the store with the next live build.
|
|
28440
29920
|
genex model "<prompt>" [options] Generate a 3D model (GLB); prints a public asset URL.
|
|
28441
29921
|
--image <path|url> builds it FROM a reference
|
|
28442
29922
|
image instead (prompt optional). Quality knobs:
|
|
@@ -28821,9 +30301,13 @@ ${c.bold("Examples")}
|
|
|
28821
30301
|
genex shop set sku_123 --icon https://assets.genex.technology/generations/abc123/image-main
|
|
28822
30302
|
genex shop test sku_123
|
|
28823
30303
|
genex shop remove sku_123
|
|
30304
|
+
genex shop add "Frost Axe" --price 1200 --type durable --description "Freezes on hit." --tag axe --image .genex/scratch/axe.png --shared --json
|
|
30305
|
+
genex shop publish sku_123
|
|
30306
|
+
genex items search "wings" --tag fire
|
|
30307
|
+
genex items add sku_456
|
|
28824
30308
|
genex llm models
|
|
28825
30309
|
genex llm models --all
|
|
28826
|
-
genex llm bench "Reply with one short taunt." --samples 5 --max-
|
|
30310
|
+
genex llm bench "Reply with one short taunt." --samples 5 --max-credits 20 --user-approved
|
|
28827
30311
|
genex llm price
|
|
28828
30312
|
genex llm status
|
|
28829
30313
|
genex llm cancel <id>
|
|
@@ -29060,22 +30544,24 @@ ${c.bold("How the allowance works")}
|
|
|
29060
30544
|
|
|
29061
30545
|
Every number printed is live from your account; prices can change without a deploy.
|
|
29062
30546
|
`,
|
|
29063
|
-
llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what should the game declare?
|
|
30547
|
+
llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what ceiling should the game declare?
|
|
29064
30548
|
|
|
29065
30549
|
${c.bold("Usage")}
|
|
29066
30550
|
genex llm models [--all] [--json] Which models this stand serves, their provider rates per
|
|
29067
|
-
million tokens,
|
|
29068
|
-
|
|
29069
|
-
|
|
29070
|
-
|
|
29071
|
-
on this stand: it says so
|
|
29072
|
-
|
|
29073
|
-
|
|
29074
|
-
|
|
29075
|
-
|
|
29076
|
-
|
|
29077
|
-
|
|
29078
|
-
|
|
30551
|
+
million tokens, the platform fee a call is billed at, and
|
|
30552
|
+
the headroom it recommends over a benchmark. The catalog
|
|
30553
|
+
is synced from OpenRouter, so the FEATURED short list is
|
|
30554
|
+
printed by default and --all prints every row (--json
|
|
30555
|
+
always carries every row). Off on this stand: it says so
|
|
30556
|
+
and exits 0 \u2014 build the feature without a model rather
|
|
30557
|
+
than promising a 404.
|
|
30558
|
+
genex llm bench "<prompt>" --max-credits <n> --user-approved [options]
|
|
30559
|
+
Run the real model through the development lane and
|
|
30560
|
+
report what each attempt really COST and was charged.
|
|
30561
|
+
Refused without both flags, before any network call:
|
|
30562
|
+
this spends YOUR OWN CREDITS, a spend the build's asset
|
|
30563
|
+
allowance does not count. Needs a folder linked to a
|
|
30564
|
+
game you own, and a balance that covers one attempt.
|
|
29079
30565
|
genex llm price [--json] The recommendation from the last bench in this folder.
|
|
29080
30566
|
Reads a file; spends nothing.
|
|
29081
30567
|
genex llm status [--json] This project's open development calls: active, or
|
|
@@ -29092,29 +30578,32 @@ ${c.bold("Bench options")}
|
|
|
29092
30578
|
--samples <n> How many real attempts (default ${DEFAULT_BENCH_SAMPLES}). More samples, better p95.
|
|
29093
30579
|
--schema <file> A JSON file holding the output schema; implies JSON output.
|
|
29094
30580
|
--json-output Ask for JSON without a schema. --text Ask for text (the default).
|
|
29095
|
-
--max-
|
|
30581
|
+
--max-credits <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own credits,
|
|
29096
30582
|
never a player's. The run's worst case is that number times --samples,
|
|
29097
|
-
and it is printed before anything starts.
|
|
30583
|
+
and it is printed before anything starts. (--max-coins is its deprecated
|
|
30584
|
+
name, read as the same number.)
|
|
29098
30585
|
--timeout <seconds> Bounds the wait for each attempt.
|
|
29099
30586
|
--json One machine-readable object; every key is always present, null when
|
|
29100
30587
|
the CLI could not learn it.
|
|
29101
30588
|
|
|
29102
30589
|
${c.bold("Why a benchmark and not an estimate")}
|
|
29103
|
-
|
|
29104
|
-
|
|
29105
|
-
|
|
29106
|
-
|
|
29107
|
-
|
|
29108
|
-
|
|
29109
|
-
|
|
29110
|
-
|
|
29111
|
-
|
|
30590
|
+
An in-game call is paid from the PLAYER's credits and billed as used: its real provider cost
|
|
30591
|
+
plus the platform fee, never more than the per-call CEILING the game declares (\`maxCredits\`,
|
|
30592
|
+
or \`perCallMaxCredits\` on a standing budget). The ceiling is not a price, but it is still a
|
|
30593
|
+
number with two jobs: it bounds what one call may cost, and it funds how LONG the answer may
|
|
30594
|
+
be. Three layers sit between the provider's rate card and it \u2014 the provider's cost for YOUR
|
|
30595
|
+
prompt, the platform fee, and your headroom \u2014 and only a real call measures the first. The
|
|
30596
|
+
bench is billed to YOU, on the development lane; the player-funded lane is what the
|
|
30597
|
+
published game uses. The recommendation is the p95 real cost with the server's fee and
|
|
30598
|
+
headroom applied, in whole credits; a stand that serves no headroom or no fee gets no
|
|
30599
|
+
recommendation, because those multipliers are not the CLI's to invent.
|
|
29112
30600
|
|
|
29113
|
-
|
|
29114
|
-
|
|
29115
|
-
|
|
29116
|
-
|
|
29117
|
-
|
|
30601
|
+
Because the ceiling sizes each call's output allowance, the recommendation is never below
|
|
30602
|
+
the smallest ceiling that leaves room for the benchmarked answer, as the server computes it.
|
|
30603
|
+
A sample cut off at your --max-credits (provider_token_limit) is not a sample \u2014 re-run with a
|
|
30604
|
+
higher --max-credits; an answer longer than one call on this stand may produce gets no
|
|
30605
|
+
ceiling at all \u2014 ask for a shorter one. The run also prints the measured average per call,
|
|
30606
|
+
fee included, which is the honest estimate a budget's disclosure is built on.
|
|
29118
30607
|
|
|
29119
30608
|
Results land in ${LLM_BENCH_FILE} \u2014 gitignored, machine-local, re-read by \`genex llm price\`.
|
|
29120
30609
|
`,
|
|
@@ -29298,6 +30787,10 @@ function parseArgs(argv) {
|
|
|
29298
30787
|
"--operation-id",
|
|
29299
30788
|
"--price",
|
|
29300
30789
|
"--icon",
|
|
30790
|
+
// `genex shop add|set` and `genex items search`: one search word per --tag.
|
|
30791
|
+
"--tag",
|
|
30792
|
+
// `genex shop add|set --package <dir>`: the item's files for other games' agents.
|
|
30793
|
+
"--package",
|
|
29301
30794
|
"--rename",
|
|
29302
30795
|
"--type",
|
|
29303
30796
|
"--title",
|
|
@@ -29365,11 +30858,13 @@ function parseArgs(argv) {
|
|
|
29365
30858
|
"--limit",
|
|
29366
30859
|
// `genex budget --assets <credits>` — the allowance the player approved.
|
|
29367
30860
|
"--assets",
|
|
29368
|
-
// `genex llm bench` value flags. `--max-
|
|
29369
|
-
//
|
|
30861
|
+
// `genex llm bench` value flags. `--max-credits` is the same approval
|
|
30862
|
+
// shape as `--assets`, but its own gate: the asset allowance does not
|
|
30863
|
+
// count a bench. `--max-coins` is its deprecated name (same number).
|
|
29370
30864
|
"--model",
|
|
29371
30865
|
"--samples",
|
|
29372
30866
|
"--schema",
|
|
30867
|
+
"--max-credits",
|
|
29373
30868
|
"--max-coins",
|
|
29374
30869
|
// `genex player install --label <name>` — what this machine is called in
|
|
29375
30870
|
// the dashboard. Its own key rather than the generic 2nd positional,
|
|
@@ -29563,6 +31058,19 @@ function parseArgs(argv) {
|
|
|
29563
31058
|
case "--no-resellable":
|
|
29564
31059
|
parsed.options.resellable = false;
|
|
29565
31060
|
break;
|
|
31061
|
+
// `genex shop add|set`: "Other games can use it" (P10). A pair for the
|
|
31062
|
+
// same reason as --resellable: `set` must tell OFF from ABSENT.
|
|
31063
|
+
case "--shared":
|
|
31064
|
+
parsed.options.shared = true;
|
|
31065
|
+
break;
|
|
31066
|
+
case "--no-shared":
|
|
31067
|
+
parsed.options.shared = false;
|
|
31068
|
+
break;
|
|
31069
|
+
// `genex shop set <id> --clear-tags`: --tag replaces the list and an
|
|
31070
|
+
// empty --tag is refused, so emptying it needs its own flag.
|
|
31071
|
+
case "--clear-tags":
|
|
31072
|
+
parsed.options.clearTags = true;
|
|
31073
|
+
break;
|
|
29566
31074
|
case "--list":
|
|
29567
31075
|
parsed.options.list = true;
|
|
29568
31076
|
break;
|
|
@@ -29662,6 +31170,8 @@ function parseArgs(argv) {
|
|
|
29662
31170
|
parsed.error = `Unexpected argument: ${arg}`;
|
|
29663
31171
|
return parsed;
|
|
29664
31172
|
}
|
|
31173
|
+
} else if (parsed.command === "items") {
|
|
31174
|
+
parsed.options.query = parsed.options.query ? `${parsed.options.query} ${arg}` : arg;
|
|
29665
31175
|
} else if (parsed.command === "domain") {
|
|
29666
31176
|
if (!parsed.options.hostname) parsed.options.hostname = arg;
|
|
29667
31177
|
else {
|
|
@@ -29701,7 +31211,9 @@ function parseArgs(argv) {
|
|
|
29701
31211
|
}
|
|
29702
31212
|
const imageCount = parsed.options.imageUrls?.length ?? 0;
|
|
29703
31213
|
if (imageCount > 0) {
|
|
29704
|
-
if (parsed.command === "
|
|
31214
|
+
if (parsed.command === "shop" && imageCount > 1) {
|
|
31215
|
+
parsed.error = "genex shop takes one --image: the item's picture.";
|
|
31216
|
+
} else if (parsed.command === "model" && imageCount > 1) {
|
|
29705
31217
|
parsed.error = "genex model accepts one --image reference. Use character --image for multiple character views.";
|
|
29706
31218
|
} else if (parsed.command === "character" || parsed.command === "creature") {
|
|
29707
31219
|
if (["preview", "finalize", "import", "animate", "motions", "adopt"].includes(parsed.options.name ?? "")) {
|
|
@@ -29923,12 +31435,14 @@ function applyValueFlag(options, flag, value) {
|
|
|
29923
31435
|
options.samples = n;
|
|
29924
31436
|
break;
|
|
29925
31437
|
}
|
|
31438
|
+
case "--max-credits":
|
|
29926
31439
|
case "--max-coins": {
|
|
29927
31440
|
const n = Number(value);
|
|
29928
31441
|
if (!Number.isInteger(n) || n < 1) {
|
|
29929
|
-
throw new Error(`Invalid
|
|
31442
|
+
throw new Error(`Invalid ${flag} value: ${value} (whole credits, 1 or more)`);
|
|
29930
31443
|
}
|
|
29931
|
-
options.
|
|
31444
|
+
if (flag === "--max-credits") options.maxCredits = n;
|
|
31445
|
+
else options.maxCoins = n;
|
|
29932
31446
|
break;
|
|
29933
31447
|
}
|
|
29934
31448
|
case "--timeout": {
|
|
@@ -29949,8 +31463,8 @@ function applyValueFlag(options, flag, value) {
|
|
|
29949
31463
|
}
|
|
29950
31464
|
case "--price": {
|
|
29951
31465
|
const n = Number(value);
|
|
29952
|
-
if (!Number.isInteger(n) || n
|
|
29953
|
-
throw new Error(`Invalid --price value: ${value} (whole coin, e.g. 100)`);
|
|
31466
|
+
if (value.trim() === "" || !Number.isInteger(n) || n < 0) {
|
|
31467
|
+
throw new Error(`Invalid --price value: ${value} (whole coin, e.g. 100; 0 is Free)`);
|
|
29954
31468
|
}
|
|
29955
31469
|
options.price = n;
|
|
29956
31470
|
break;
|
|
@@ -29958,6 +31472,16 @@ function applyValueFlag(options, flag, value) {
|
|
|
29958
31472
|
case "--icon":
|
|
29959
31473
|
options.icon = value;
|
|
29960
31474
|
break;
|
|
31475
|
+
// A forgotten value must not swallow the next flag (`--tag --json` would
|
|
31476
|
+
// tag the item "--json" and print no draft) or vanish (`--package ""`).
|
|
31477
|
+
case "--tag":
|
|
31478
|
+
if (!value.trim() || value.startsWith("--")) throw new Error("Missing value for --tag");
|
|
31479
|
+
(options.itemTags ??= []).push(value);
|
|
31480
|
+
break;
|
|
31481
|
+
case "--package":
|
|
31482
|
+
if (!value.trim() || value.startsWith("--")) throw new Error("Missing value for --package");
|
|
31483
|
+
options.packageDir = value;
|
|
31484
|
+
break;
|
|
29961
31485
|
case "--type": {
|
|
29962
31486
|
if (value !== "consumable" && value !== "durable" && !MODEL_RIG_TYPES.includes(value)) {
|
|
29963
31487
|
throw new Error(
|
|
@@ -30229,9 +31753,9 @@ async function main() {
|
|
|
30229
31753
|
await runBudget(parsed.options);
|
|
30230
31754
|
break;
|
|
30231
31755
|
// The in-game model lane: is it live here, what does one call really
|
|
30232
|
-
// cost, what should the game declare. `bench`
|
|
30233
|
-
//
|
|
30234
|
-
//
|
|
31756
|
+
// cost, what ceiling should the game declare. `bench` spends the
|
|
31757
|
+
// builder's own credits OUTSIDE the asset allowance, so it carries its
|
|
31758
|
+
// own --max-credits/--user-approved gate.
|
|
30235
31759
|
case "llm":
|
|
30236
31760
|
await runLlm(parsed.options);
|
|
30237
31761
|
break;
|
|
@@ -30261,7 +31785,10 @@ async function main() {
|
|
|
30261
31785
|
await runMakeRemixable(parsed.options);
|
|
30262
31786
|
break;
|
|
30263
31787
|
case "shop":
|
|
30264
|
-
await runShop(parsed.options);
|
|
31788
|
+
await runShop({ ...parsed.options, picture: parsed.options.imageUrl, modelFile: parsed.options.modelId });
|
|
31789
|
+
break;
|
|
31790
|
+
case "items":
|
|
31791
|
+
await runItems(parsed.options);
|
|
30265
31792
|
break;
|
|
30266
31793
|
case "domain":
|
|
30267
31794
|
await runDomain(parsed.options);
|