@genex-ai/cli-demo 0.17.0 → 0.20.0
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/README.md +10 -0
- package/dist/index.js +317 -58
- package/package.json +1 -1
- package/templates/skills/genex-ai-model/SKILL.md +6 -1
- package/templates/skills/genex-ai-sfx/SKILL.md +9 -0
- package/templates/skills/genex-ai-skybox/SKILL.md +9 -1
- package/templates/skills/genex-ai-texture/SKILL.md +11 -1
- package/templates/skills/genex-getting-started/SKILL.md +44 -0
- package/templates/skills/genex-threejs-atmosphere-aerial-perspective/SKILL.md +4 -0
- package/templates/skills/genex-threejs-bloom/references/bloom.md +3 -1
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +6 -0
- package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +2 -2
- package/templates/skills/genex-threejs-character-controller/SKILL.md +6 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +9 -1
- package/templates/skills/genex-threejs-game-feel/SKILL.md +97 -0
- package/templates/skills/genex-threejs-game-ui/SKILL.md +119 -0
- package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +2 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +10 -5
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +16 -5
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +2 -1
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +1 -1
- package/templates/skills/genex-threejs-procedural-animation/references/procedural-motion.md +8 -8
- package/templates/skills/genex-threejs-screen-space-ambient-occlusion/SKILL.md +1 -1
- package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +2 -0
- package/templates/skills/genex-threejs-shadow-systems/SKILL.md +6 -1
- package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +2 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +23 -3
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +30 -13
- package/templates/skills/genex-threejs-spectral-ocean/SKILL.md +2 -0
- package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +2 -0
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +24 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +30 -2
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +21 -2
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +15 -0
- package/templates/skills/genex-threejs-volumetric-clouds/SKILL.md +4 -0
- package/templates/skills/genex-threejs-water-optics/references/water-optics.md +3 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ This is the `cli` app of the [genex monorepo](../../README.md).
|
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
genex init <name> # authorize + create the draft project
|
|
8
|
+
genex link <slug> # re-link this folder to an existing game of yours (recovery)
|
|
8
9
|
genex preview # build + push to the hosted draft URL (unlisted)
|
|
9
10
|
genex publish # build + push, then list the game in the gallery
|
|
10
11
|
genex model "<prompt>" # generate a 3D model → assets/models/
|
|
@@ -39,6 +40,15 @@ genex controller <type> # install a tuned character|car|drone controller →
|
|
|
39
40
|
`sshUrl`, urls) in `./.genex/project.json`. The game shows up in your
|
|
40
41
|
dashboard's **My games** immediately.
|
|
41
42
|
|
|
43
|
+
`genex link <slug>` is the recovery counterpart of step 4 for a game that
|
|
44
|
+
already exists: run it inside a fresh clone of the game's source repo (or any
|
|
45
|
+
folder that lost its link) and it re-authorizes if needed, reuses or generates
|
|
46
|
+
`genex_key`, registers the public half via `POST /api/projects/:id/deploy-key`
|
|
47
|
+
(keys are added, never replaced), and rewrites `./.genex/project.json` — after
|
|
48
|
+
which `preview`/`publish` update the **same** live game. It never creates a
|
|
49
|
+
project; the dashboard's "Continue building" flow uses it when the original
|
|
50
|
+
folder can't be found.
|
|
51
|
+
|
|
42
52
|
`genex preview` and `genex publish` share a build-aware deploy core: each runs
|
|
43
53
|
`npm run build` (when the project has a build script), then uploads the built
|
|
44
54
|
`dist/` to R2 through a short-lived scoped upload token and flips a live pointer;
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { readFileSync } from "fs";
|
|
5
|
-
import
|
|
5
|
+
import path14 from "path";
|
|
6
6
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/commands/init.ts
|
|
@@ -806,16 +806,185 @@ async function runInit(opts) {
|
|
|
806
806
|
log.success("All set. \u{1F680}");
|
|
807
807
|
}
|
|
808
808
|
|
|
809
|
-
// src/commands/
|
|
810
|
-
import
|
|
811
|
-
import
|
|
809
|
+
// src/commands/link.ts
|
|
810
|
+
import fs7 from "fs/promises";
|
|
811
|
+
import path8 from "path";
|
|
812
|
+
async function runLink(opts) {
|
|
813
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
814
|
+
log.plain(c.bold("genex link"));
|
|
815
|
+
log.plain("");
|
|
816
|
+
const slug = opts.name?.trim();
|
|
817
|
+
if (!slug) {
|
|
818
|
+
log.error("Which game? Usage: genex link <slug> (the name in your play URL).");
|
|
819
|
+
process.exitCode = 1;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const existing = await readProject();
|
|
823
|
+
if (existing && existing.slug !== slug && !opts.force) {
|
|
824
|
+
log.error(
|
|
825
|
+
`This folder is already linked to ${c.cyan(existing.slug)}. Run link inside the right folder, or pass --force to re-link it to ${c.cyan(slug)}.`
|
|
826
|
+
);
|
|
827
|
+
process.exitCode = 1;
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const authBaseUrl = getAuthUrl(opts.authUrl);
|
|
831
|
+
let token = await readUserToken(opts.envPath);
|
|
832
|
+
if (!token) {
|
|
833
|
+
token = await authorize(authBaseUrl, {
|
|
834
|
+
log,
|
|
835
|
+
timeoutMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
|
|
836
|
+
});
|
|
837
|
+
const { path: tokenPath } = await writeUserToken(token, opts.envPath);
|
|
838
|
+
log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)}.`);
|
|
839
|
+
log.plain("");
|
|
840
|
+
}
|
|
841
|
+
const apiUrl = getApiUrl(opts.apiUrl);
|
|
842
|
+
log.step(`Looking up ${c.cyan(slug)}\u2026`);
|
|
843
|
+
const project = await fetchOwnProject(apiUrl, token, slug, log);
|
|
844
|
+
if (!project) {
|
|
845
|
+
process.exitCode = 1;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const key = await generateSshKeypair(process.cwd(), log);
|
|
849
|
+
if (!key) {
|
|
850
|
+
log.error("No deploy key \u2014 install ssh-keygen (OpenSSH) and re-run `genex link`.");
|
|
851
|
+
process.exitCode = 1;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
await writeGitignore(process.cwd(), log);
|
|
855
|
+
log.step("Registering this folder's deploy key\u2026");
|
|
856
|
+
const linked = await registerDeployKey(apiUrl, token, project.id, key.publicKey, log);
|
|
857
|
+
if (!linked) {
|
|
858
|
+
process.exitCode = 1;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const meta = {
|
|
862
|
+
id: project.id,
|
|
863
|
+
slug: project.slug,
|
|
864
|
+
sshUrl: linked.sshUrl,
|
|
865
|
+
apiUrl,
|
|
866
|
+
colyseusUrl: getColyseusUrl(opts.colyseusUrl),
|
|
867
|
+
playUrl: project.playUrl ?? void 0,
|
|
868
|
+
status: project.status ?? "draft",
|
|
869
|
+
dashboardOrigins: [new URL(authBaseUrl).origin]
|
|
870
|
+
};
|
|
871
|
+
const { path: metaPath } = await writeProject(meta);
|
|
872
|
+
log.dim(` saved ${c.cyan(metaPath)}`);
|
|
873
|
+
await writeGameConfigFiles(meta, log);
|
|
874
|
+
await ensureSlugEnv(project.slug, log);
|
|
875
|
+
log.plain("");
|
|
876
|
+
log.success(
|
|
877
|
+
`Linked. This folder now updates ${c.cyan(project.slug)} \u2014 \`npx genex preview\` / \`publish\` ship to the same live game.`
|
|
878
|
+
);
|
|
879
|
+
if (project.playUrl) log.dim(` play URL: ${project.playUrl}`);
|
|
880
|
+
}
|
|
881
|
+
async function ensureSlugEnv(slug, log, cwd = process.cwd()) {
|
|
882
|
+
const file = path8.join(cwd, ".env");
|
|
883
|
+
let content;
|
|
884
|
+
try {
|
|
885
|
+
content = await fs7.readFile(file, "utf8");
|
|
886
|
+
} catch {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const re = /^(\s*VITE_GENEX_SLUG=)(.*)$/m;
|
|
890
|
+
const m = content.match(re);
|
|
891
|
+
if (!m) {
|
|
892
|
+
await fs7.appendFile(file, `${content.endsWith("\n") ? "" : "\n"}VITE_GENEX_SLUG=${slug}
|
|
893
|
+
`);
|
|
894
|
+
log.dim(` added VITE_GENEX_SLUG=${slug} to .env`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (m[2].trim() === slug) return;
|
|
898
|
+
await fs7.writeFile(file, content.replace(re, `$1${slug}`));
|
|
899
|
+
log.dim(` updated .env: VITE_GENEX_SLUG=${slug} (was ${m[2].trim()})`);
|
|
900
|
+
}
|
|
901
|
+
async function fetchOwnProject(apiUrl, token, slug, log) {
|
|
902
|
+
let res;
|
|
903
|
+
try {
|
|
904
|
+
res = await fetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
|
|
905
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
906
|
+
});
|
|
907
|
+
} catch (err) {
|
|
908
|
+
log.error(`Couldn't reach the API at ${apiUrl}.`);
|
|
909
|
+
log.dim(` ${String(err)}`);
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
if (res.status === 401) {
|
|
913
|
+
log.error(
|
|
914
|
+
"Not authorized (token rejected). Delete ~/.genex/env and re-run `genex link` to sign in again."
|
|
915
|
+
);
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
if (res.status === 404) {
|
|
919
|
+
log.error(`No game of yours is named ${c.cyan(slug)}.`);
|
|
920
|
+
await listOwnSlugs(apiUrl, token, log);
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
if (!res.ok) {
|
|
924
|
+
log.error(`Couldn't look up the project (HTTP ${res.status}).`);
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
const data = await res.json().catch(() => null);
|
|
928
|
+
if (!data?.project) {
|
|
929
|
+
log.error("Unexpected API response while looking up the project.");
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
return data.project;
|
|
933
|
+
}
|
|
934
|
+
async function listOwnSlugs(apiUrl, token, log) {
|
|
935
|
+
try {
|
|
936
|
+
const res = await fetch(`${apiUrl}/api/projects`, {
|
|
937
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
938
|
+
});
|
|
939
|
+
if (!res.ok) return;
|
|
940
|
+
const data = await res.json().catch(() => null);
|
|
941
|
+
const slugs = (data?.projects ?? []).map((p) => p.slug);
|
|
942
|
+
if (slugs.length === 0) {
|
|
943
|
+
log.dim(" You have no projects yet \u2014 `genex init <name>` creates one.");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
log.dim(` Your games: ${slugs.map((s) => c.cyan(s)).join(", ")}`);
|
|
947
|
+
} catch {
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async function registerDeployKey(apiUrl, token, projectId, deployKey, log) {
|
|
951
|
+
let res;
|
|
952
|
+
try {
|
|
953
|
+
res = await fetch(`${apiUrl}/api/projects/${projectId}/deploy-key`, {
|
|
954
|
+
method: "POST",
|
|
955
|
+
headers: {
|
|
956
|
+
"Content-Type": "application/json",
|
|
957
|
+
Authorization: `Bearer ${token}`
|
|
958
|
+
},
|
|
959
|
+
body: JSON.stringify({ deployKey })
|
|
960
|
+
});
|
|
961
|
+
} catch (err) {
|
|
962
|
+
log.error(`Couldn't reach the API at ${apiUrl}.`);
|
|
963
|
+
log.dim(` ${String(err)}`);
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
if (res.status === 429) {
|
|
967
|
+
log.error("Rate limited \u2014 too many link attempts. Try again in a bit.");
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
if (!res.ok) {
|
|
971
|
+
log.error(`Couldn't register the deploy key (HTTP ${res.status}).`);
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
const data = await res.json().catch(() => null);
|
|
975
|
+
if (!data?.sshUrl) {
|
|
976
|
+
log.error("Unexpected API response while registering the deploy key.");
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
return { sshUrl: data.sshUrl };
|
|
980
|
+
}
|
|
812
981
|
|
|
813
982
|
// src/lib/deploy.ts
|
|
814
983
|
import { spawn as spawn4 } from "child_process";
|
|
815
984
|
import crypto3 from "crypto";
|
|
816
|
-
import
|
|
985
|
+
import fs8 from "fs/promises";
|
|
817
986
|
import os2 from "os";
|
|
818
|
-
import
|
|
987
|
+
import path9 from "path";
|
|
819
988
|
function run(cmd, args, env) {
|
|
820
989
|
return new Promise((resolve) => {
|
|
821
990
|
let child;
|
|
@@ -847,9 +1016,9 @@ async function deployGame(ctx, opts, log) {
|
|
|
847
1016
|
}
|
|
848
1017
|
log.success("Built.");
|
|
849
1018
|
}
|
|
850
|
-
const distDir =
|
|
1019
|
+
const distDir = path9.join(cwd, "dist");
|
|
851
1020
|
const siteDir = await isDir2(distDir) ? distDir : cwd;
|
|
852
|
-
const rel =
|
|
1021
|
+
const rel = path9.relative(cwd, siteDir) || ".";
|
|
853
1022
|
if (siteDir === cwd) await writeGitignore(cwd, log);
|
|
854
1023
|
const files = await collectFiles(siteDir);
|
|
855
1024
|
if (files.some((f) => /(^|\/)genex_key(\.pub)?$/.test(f.relPath))) {
|
|
@@ -888,21 +1057,21 @@ async function deployGame(ctx, opts, log) {
|
|
|
888
1057
|
log.error("Couldn't upload your game \u2014 please try again.");
|
|
889
1058
|
return false;
|
|
890
1059
|
}
|
|
891
|
-
const keyPath =
|
|
1060
|
+
const keyPath = path9.resolve(cwd, KEY_NAME);
|
|
892
1061
|
if (!await fileExists(keyPath)) {
|
|
893
1062
|
log.error(`No deploy key (${KEY_NAME}) here \u2014 run \`genex init\` in this folder first.`);
|
|
894
1063
|
return false;
|
|
895
1064
|
}
|
|
896
1065
|
if (!await pushSource(cwd, ctx.sshUrl, keyPath, log)) return false;
|
|
897
1066
|
log.step("Publishing\u2026");
|
|
898
|
-
if (!await callPublish(ctx, commit, log)) return false;
|
|
1067
|
+
if (!await callPublish(ctx, commit, opts, log)) return false;
|
|
899
1068
|
const index = files.find((f) => f.relPath === "index.html");
|
|
900
1069
|
await waitUntilLive(grant.playUrl, fingerprintOf(index.bytes.toString("utf8")), opts.liveTimeoutMs ?? 2e4, log);
|
|
901
1070
|
return true;
|
|
902
1071
|
}
|
|
903
1072
|
async function hasBuildScript(cwd) {
|
|
904
1073
|
try {
|
|
905
|
-
const pkg = JSON.parse(await
|
|
1074
|
+
const pkg = JSON.parse(await fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
|
|
906
1075
|
return Boolean(pkg.scripts?.build);
|
|
907
1076
|
} catch {
|
|
908
1077
|
return false;
|
|
@@ -911,12 +1080,12 @@ async function hasBuildScript(cwd) {
|
|
|
911
1080
|
async function collectFiles(root) {
|
|
912
1081
|
const out = [];
|
|
913
1082
|
const walk2 = async (dir, prefix) => {
|
|
914
|
-
for (const e of await
|
|
1083
|
+
for (const e of await fs8.readdir(dir, { withFileTypes: true })) {
|
|
915
1084
|
const relPath = prefix ? `${prefix}/${e.name}` : e.name;
|
|
916
1085
|
if (e.isDirectory()) {
|
|
917
|
-
if (!EXCLUDE_DIRS.has(e.name)) await walk2(
|
|
1086
|
+
if (!EXCLUDE_DIRS.has(e.name)) await walk2(path9.join(dir, e.name), relPath);
|
|
918
1087
|
} else if (e.isFile() && e.name !== KEY_NAME && e.name !== `${KEY_NAME}.pub`) {
|
|
919
|
-
out.push({ relPath, bytes: await
|
|
1088
|
+
out.push({ relPath, bytes: await fs8.readFile(path9.join(dir, e.name)) });
|
|
920
1089
|
}
|
|
921
1090
|
}
|
|
922
1091
|
};
|
|
@@ -979,13 +1148,21 @@ async function uploadAll(uploadUrl, uploadToken, files, log) {
|
|
|
979
1148
|
await Promise.all(Array.from({ length: Math.min(8, files.length) }, () => worker()));
|
|
980
1149
|
return !failed;
|
|
981
1150
|
}
|
|
982
|
-
async function callPublish(ctx, commit, log) {
|
|
1151
|
+
async function callPublish(ctx, commit, opts, log) {
|
|
983
1152
|
let res;
|
|
984
1153
|
try {
|
|
985
1154
|
res = await fetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
|
|
986
1155
|
method: "POST",
|
|
987
1156
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
|
|
988
|
-
|
|
1157
|
+
// Detections ride every go-live (preview AND publish): matchmaking is
|
|
1158
|
+
// tri-state (object = upsert, null = clear); embedSdkVersion/multiplayer
|
|
1159
|
+
// are sent only when detected (absent = server keeps the stored value).
|
|
1160
|
+
body: JSON.stringify({
|
|
1161
|
+
commit,
|
|
1162
|
+
matchmaking: opts.matchmaking ?? null,
|
|
1163
|
+
...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
|
|
1164
|
+
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {}
|
|
1165
|
+
})
|
|
989
1166
|
});
|
|
990
1167
|
} catch (err) {
|
|
991
1168
|
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
@@ -1004,7 +1181,7 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1004
1181
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1005
1182
|
return false;
|
|
1006
1183
|
};
|
|
1007
|
-
const gitDir = await
|
|
1184
|
+
const gitDir = await fs8.mkdtemp(path9.join(os2.tmpdir(), "genex-source-"));
|
|
1008
1185
|
const base = { GIT_DIR: gitDir };
|
|
1009
1186
|
const ident = {
|
|
1010
1187
|
GIT_AUTHOR_NAME: "genex",
|
|
@@ -1014,11 +1191,11 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1014
1191
|
};
|
|
1015
1192
|
try {
|
|
1016
1193
|
if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
|
|
1017
|
-
await
|
|
1018
|
-
|
|
1194
|
+
await fs8.writeFile(
|
|
1195
|
+
path9.join(gitDir, "info", "exclude"),
|
|
1019
1196
|
["node_modules/", "dist/", ".git/", KEY_NAME, `${KEY_NAME}.pub`, ".genex/", ""].join("\n")
|
|
1020
1197
|
);
|
|
1021
|
-
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE:
|
|
1198
|
+
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path9.join(gitDir, "index-source") };
|
|
1022
1199
|
await run("git", ["add", "-A"], env);
|
|
1023
1200
|
const tracked = await run("git", ["ls-files"], env);
|
|
1024
1201
|
if (/(^|\/)genex_key(\.pub)?$/m.test(tracked.out)) {
|
|
@@ -1042,20 +1219,20 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1042
1219
|
} catch {
|
|
1043
1220
|
return failed();
|
|
1044
1221
|
} finally {
|
|
1045
|
-
await
|
|
1222
|
+
await fs8.rm(gitDir, { recursive: true, force: true }).catch(() => {
|
|
1046
1223
|
});
|
|
1047
1224
|
}
|
|
1048
1225
|
}
|
|
1049
1226
|
async function isDir2(p) {
|
|
1050
1227
|
try {
|
|
1051
|
-
return (await
|
|
1228
|
+
return (await fs8.stat(p)).isDirectory();
|
|
1052
1229
|
} catch {
|
|
1053
1230
|
return false;
|
|
1054
1231
|
}
|
|
1055
1232
|
}
|
|
1056
1233
|
async function fileExists(p) {
|
|
1057
1234
|
try {
|
|
1058
|
-
await
|
|
1235
|
+
await fs8.access(p);
|
|
1059
1236
|
return true;
|
|
1060
1237
|
} catch {
|
|
1061
1238
|
return false;
|
|
@@ -1088,10 +1265,12 @@ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
|
|
|
1088
1265
|
log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
|
|
1089
1266
|
}
|
|
1090
1267
|
|
|
1091
|
-
// src/
|
|
1268
|
+
// src/lib/detect-features.ts
|
|
1269
|
+
import fs9 from "fs/promises";
|
|
1270
|
+
import path10 from "path";
|
|
1092
1271
|
async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
1093
1272
|
try {
|
|
1094
|
-
const raw = await
|
|
1273
|
+
const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
|
|
1095
1274
|
const pkg = JSON.parse(raw);
|
|
1096
1275
|
const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
|
|
1097
1276
|
return typeof version === "string" && version ? version : null;
|
|
@@ -1101,7 +1280,7 @@ async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
|
1101
1280
|
}
|
|
1102
1281
|
async function detectMultiplayer(cwd = process.cwd()) {
|
|
1103
1282
|
try {
|
|
1104
|
-
const raw = await
|
|
1283
|
+
const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
|
|
1105
1284
|
const pkg = JSON.parse(raw);
|
|
1106
1285
|
return Boolean(
|
|
1107
1286
|
pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
|
|
@@ -1113,7 +1292,7 @@ async function detectMultiplayer(cwd = process.cwd()) {
|
|
|
1113
1292
|
async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
1114
1293
|
let pkg;
|
|
1115
1294
|
try {
|
|
1116
|
-
pkg = JSON.parse(await
|
|
1295
|
+
pkg = JSON.parse(await fs9.readFile(path10.join(cwd, "package.json"), "utf8"));
|
|
1117
1296
|
} catch (err) {
|
|
1118
1297
|
log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
|
|
1119
1298
|
return null;
|
|
@@ -1128,6 +1307,53 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
|
1128
1307
|
}
|
|
1129
1308
|
return null;
|
|
1130
1309
|
}
|
|
1310
|
+
var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
|
|
1311
|
+
async function detectGameStateUsage(cwd = process.cwd()) {
|
|
1312
|
+
const srcDir = path10.join(cwd, "src");
|
|
1313
|
+
let entries;
|
|
1314
|
+
try {
|
|
1315
|
+
entries = await fs9.readdir(srcDir, { recursive: true });
|
|
1316
|
+
} catch {
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
for (const rel of entries) {
|
|
1320
|
+
if (rel.includes("node_modules")) continue;
|
|
1321
|
+
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
1322
|
+
try {
|
|
1323
|
+
const content = await fs9.readFile(path10.join(srcDir, rel), "utf8");
|
|
1324
|
+
if (GAME_STATE_CALLS.test(content)) return true;
|
|
1325
|
+
} catch {
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
async function detectFeatures(log, cwd = process.cwd()) {
|
|
1331
|
+
return {
|
|
1332
|
+
embedSdkVersion: await detectEmbedSdkVersion(cwd),
|
|
1333
|
+
multiplayer: await detectMultiplayer(cwd),
|
|
1334
|
+
matchmaking: await detectMatchmaking(log, cwd),
|
|
1335
|
+
gameStateUsed: await detectGameStateUsage(cwd)
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function advisoryNudges(log, d) {
|
|
1339
|
+
if (!d.embedSdkVersion) {
|
|
1340
|
+
log.warn(
|
|
1341
|
+
"This game has no player identity or saves \u2014 players lose everything on refresh. The genex-threejs-embed-auth skill wires it with one call (initEmbed) \u2014 load it before publishing."
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
if (d.multiplayer && !d.embedSdkVersion) {
|
|
1345
|
+
log.warn(
|
|
1346
|
+
"Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
if (d.matchmaking && !d.multiplayer) {
|
|
1350
|
+
log.warn(
|
|
1351
|
+
"package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// src/commands/publish.ts
|
|
1131
1357
|
async function runPublish(opts) {
|
|
1132
1358
|
const log = createLogger({ quiet: opts.quiet });
|
|
1133
1359
|
log.plain(c.bold("genex publish"));
|
|
@@ -1145,10 +1371,17 @@ async function runPublish(opts) {
|
|
|
1145
1371
|
return;
|
|
1146
1372
|
}
|
|
1147
1373
|
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
1374
|
+
const detections = await detectFeatures(log);
|
|
1375
|
+
advisoryNudges(log, detections);
|
|
1148
1376
|
if (!opts.noPush) {
|
|
1149
1377
|
const ok = await deployGame(
|
|
1150
1378
|
{ projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
|
|
1151
|
-
{
|
|
1379
|
+
{
|
|
1380
|
+
noBuild: opts.noBuild,
|
|
1381
|
+
matchmaking: detections.matchmaking,
|
|
1382
|
+
embedSdkVersion: detections.embedSdkVersion,
|
|
1383
|
+
multiplayer: detections.multiplayer
|
|
1384
|
+
},
|
|
1152
1385
|
log
|
|
1153
1386
|
);
|
|
1154
1387
|
if (!ok) {
|
|
@@ -1166,11 +1399,10 @@ async function runPublish(opts) {
|
|
|
1166
1399
|
if (opts.description) body.description = opts.description;
|
|
1167
1400
|
if (opts.regenerateCover) body.regenerateCover = true;
|
|
1168
1401
|
if (opts.categories?.length) body.categories = opts.categories;
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
body.
|
|
1172
|
-
|
|
1173
|
-
if (matchmaking) body.matchmaking = matchmaking;
|
|
1402
|
+
if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
|
|
1403
|
+
body.multiplayer = detections.multiplayer;
|
|
1404
|
+
body.matchmaking = detections.matchmaking ?? null;
|
|
1405
|
+
body.gameStateUsed = detections.gameStateUsed;
|
|
1174
1406
|
res = await fetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
1175
1407
|
method: "POST",
|
|
1176
1408
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
@@ -1208,10 +1440,21 @@ async function runPreview(opts) {
|
|
|
1208
1440
|
process.exitCode = 1;
|
|
1209
1441
|
return;
|
|
1210
1442
|
}
|
|
1443
|
+
const detections = await detectFeatures(log);
|
|
1444
|
+
advisoryNudges(log, detections);
|
|
1211
1445
|
const apiUrl = getApiUrl(meta.apiUrl);
|
|
1212
1446
|
const ok = await deployGame(
|
|
1213
1447
|
{ projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
|
|
1214
|
-
|
|
1448
|
+
// Detections reach the server on every preview too: matchmaking so a draft's
|
|
1449
|
+
// declared preset doesn't silently run the default (null clears a removed
|
|
1450
|
+
// config), embedSdkVersion/multiplayer so the dashboard's Publish button can
|
|
1451
|
+
// list the draft without wrongly treating it as a pre-embed-auth bundle.
|
|
1452
|
+
{
|
|
1453
|
+
noBuild: opts.noBuild,
|
|
1454
|
+
matchmaking: detections.matchmaking,
|
|
1455
|
+
embedSdkVersion: detections.embedSdkVersion,
|
|
1456
|
+
multiplayer: detections.multiplayer
|
|
1457
|
+
},
|
|
1215
1458
|
log
|
|
1216
1459
|
);
|
|
1217
1460
|
if (!ok) {
|
|
@@ -1223,11 +1466,11 @@ async function runPreview(opts) {
|
|
|
1223
1466
|
}
|
|
1224
1467
|
|
|
1225
1468
|
// src/commands/generate.ts
|
|
1226
|
-
import
|
|
1469
|
+
import path12 from "path";
|
|
1227
1470
|
|
|
1228
1471
|
// src/lib/assets.ts
|
|
1229
|
-
import
|
|
1230
|
-
import
|
|
1472
|
+
import fs10 from "fs/promises";
|
|
1473
|
+
import path11 from "path";
|
|
1231
1474
|
function slugify(input) {
|
|
1232
1475
|
const s = input.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/g, "");
|
|
1233
1476
|
return s || "asset";
|
|
@@ -1236,8 +1479,8 @@ async function downloadToFile(url, dest, headers) {
|
|
|
1236
1479
|
const res = await fetch(url, { headers });
|
|
1237
1480
|
if (!res.ok) throw new Error(`download failed (HTTP ${res.status}) for ${url}`);
|
|
1238
1481
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
1239
|
-
await
|
|
1240
|
-
await
|
|
1482
|
+
await fs10.mkdir(path11.dirname(dest), { recursive: true });
|
|
1483
|
+
await fs10.writeFile(dest, buf);
|
|
1241
1484
|
return buf.byteLength;
|
|
1242
1485
|
}
|
|
1243
1486
|
|
|
@@ -1369,7 +1612,7 @@ async function runGenerate(kind, opts) {
|
|
|
1369
1612
|
for (const f of view.files ?? []) {
|
|
1370
1613
|
const rel = fileDest(kind, slug, f);
|
|
1371
1614
|
const url = `${apiUrl}/api/generations/${id}/asset/${f.role}`;
|
|
1372
|
-
const bytes = await downloadToFile(url,
|
|
1615
|
+
const bytes = await downloadToFile(url, path12.join(process.cwd(), rel), {
|
|
1373
1616
|
Authorization: `Bearer ${token}`
|
|
1374
1617
|
});
|
|
1375
1618
|
written.push(rel);
|
|
@@ -1475,8 +1718,8 @@ function printHint(kind, written, log) {
|
|
|
1475
1718
|
}
|
|
1476
1719
|
|
|
1477
1720
|
// src/commands/controller.ts
|
|
1478
|
-
import
|
|
1479
|
-
import
|
|
1721
|
+
import fs11 from "fs/promises";
|
|
1722
|
+
import path13 from "path";
|
|
1480
1723
|
var CONTROLLER_KINDS = ["character", "car", "drone"];
|
|
1481
1724
|
var SHARED = [
|
|
1482
1725
|
"shared/math.ts",
|
|
@@ -1555,8 +1798,8 @@ var CONTROLLER_FILE_SETS = {
|
|
|
1555
1798
|
]
|
|
1556
1799
|
}
|
|
1557
1800
|
};
|
|
1558
|
-
var CODE_DEST =
|
|
1559
|
-
var ASSETS_DEST =
|
|
1801
|
+
var CODE_DEST = path13.join("src", "controllers");
|
|
1802
|
+
var ASSETS_DEST = path13.join("public", "assets");
|
|
1560
1803
|
async function runController(opts) {
|
|
1561
1804
|
const log = createLogger({ quiet: opts.quiet });
|
|
1562
1805
|
const kind = opts.kind?.trim();
|
|
@@ -1569,31 +1812,31 @@ async function runController(opts) {
|
|
|
1569
1812
|
process.exitCode = 1;
|
|
1570
1813
|
return;
|
|
1571
1814
|
}
|
|
1572
|
-
const srcDir =
|
|
1815
|
+
const srcDir = path13.join(getTemplatesDir(), "controllers");
|
|
1573
1816
|
const root = opts.cwd ?? process.cwd();
|
|
1574
1817
|
const set = CONTROLLER_FILE_SETS[kind];
|
|
1575
1818
|
log.plain(c.bold(`genex controller ${kind}`));
|
|
1576
1819
|
log.plain("");
|
|
1577
|
-
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST +
|
|
1820
|
+
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path13.sep)}`);
|
|
1578
1821
|
const plan = [
|
|
1579
|
-
...set.code.map((rel) => ({ from: rel, rel:
|
|
1822
|
+
...set.code.map((rel) => ({ from: rel, rel: path13.join(CODE_DEST, rel) })),
|
|
1580
1823
|
...set.assets.map((rel) => ({
|
|
1581
1824
|
from: rel,
|
|
1582
|
-
rel:
|
|
1825
|
+
rel: path13.join(ASSETS_DEST, path13.basename(rel))
|
|
1583
1826
|
}))
|
|
1584
1827
|
];
|
|
1585
1828
|
let copied = 0;
|
|
1586
1829
|
let skipped = 0;
|
|
1587
1830
|
try {
|
|
1588
1831
|
for (const file of plan) {
|
|
1589
|
-
const dest =
|
|
1832
|
+
const dest = path13.join(root, file.rel);
|
|
1590
1833
|
if (!opts.force && await exists2(dest)) {
|
|
1591
1834
|
skipped++;
|
|
1592
1835
|
log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
|
|
1593
1836
|
continue;
|
|
1594
1837
|
}
|
|
1595
|
-
await
|
|
1596
|
-
await
|
|
1838
|
+
await fs11.mkdir(path13.dirname(dest), { recursive: true });
|
|
1839
|
+
await fs11.copyFile(path13.join(srcDir, file.from), dest);
|
|
1597
1840
|
copied++;
|
|
1598
1841
|
log.dim(` ${file.rel}`);
|
|
1599
1842
|
}
|
|
@@ -1625,8 +1868,8 @@ async function runController(opts) {
|
|
|
1625
1868
|
}
|
|
1626
1869
|
async function installOwnerAvatar(args) {
|
|
1627
1870
|
const { root, srcDir, apiUrl, token, log } = args;
|
|
1628
|
-
const dest =
|
|
1629
|
-
await
|
|
1871
|
+
const dest = path13.join(root, ASSETS_DEST, "avatar.vrm");
|
|
1872
|
+
await fs11.mkdir(path13.dirname(dest), { recursive: true });
|
|
1630
1873
|
if (token) {
|
|
1631
1874
|
try {
|
|
1632
1875
|
const res = await fetch(`${apiUrl}/api/avatars/me`, {
|
|
@@ -1638,7 +1881,7 @@ async function installOwnerAvatar(args) {
|
|
|
1638
1881
|
const vrmRes = await fetch(me.vrmUrl);
|
|
1639
1882
|
if (vrmRes.ok) {
|
|
1640
1883
|
const buf = Buffer.from(await vrmRes.arrayBuffer());
|
|
1641
|
-
await
|
|
1884
|
+
await fs11.writeFile(dest, buf);
|
|
1642
1885
|
log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
|
|
1643
1886
|
return;
|
|
1644
1887
|
}
|
|
@@ -1649,14 +1892,14 @@ async function installOwnerAvatar(args) {
|
|
|
1649
1892
|
log.dim(" avatar fetch failed (offline?); using the bundled default.");
|
|
1650
1893
|
}
|
|
1651
1894
|
}
|
|
1652
|
-
await
|
|
1895
|
+
await fs11.copyFile(path13.join(srcDir, "assets", "default-avatar.vrm"), dest);
|
|
1653
1896
|
log.dim(
|
|
1654
1897
|
token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
|
|
1655
1898
|
);
|
|
1656
1899
|
}
|
|
1657
1900
|
async function exists2(p) {
|
|
1658
1901
|
try {
|
|
1659
|
-
await
|
|
1902
|
+
await fs11.access(p);
|
|
1660
1903
|
return true;
|
|
1661
1904
|
} catch {
|
|
1662
1905
|
return false;
|
|
@@ -1667,9 +1910,9 @@ async function exists2(p) {
|
|
|
1667
1910
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture"]);
|
|
1668
1911
|
function getVersion() {
|
|
1669
1912
|
try {
|
|
1670
|
-
const here =
|
|
1913
|
+
const here = path14.dirname(fileURLToPath2(import.meta.url));
|
|
1671
1914
|
const pkg = JSON.parse(
|
|
1672
|
-
readFileSync(
|
|
1915
|
+
readFileSync(path14.resolve(here, "..", "package.json"), "utf8")
|
|
1673
1916
|
);
|
|
1674
1917
|
return pkg.version ?? "0.0.0";
|
|
1675
1918
|
} catch {
|
|
@@ -1680,6 +1923,9 @@ var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize
|
|
|
1680
1923
|
|
|
1681
1924
|
${c.bold("Usage")}
|
|
1682
1925
|
genex init [<name>] [options] Scaffold + authorize + create the draft project.
|
|
1926
|
+
genex link <slug> [options] Re-link THIS folder to an existing game of yours
|
|
1927
|
+
(lost folder / new machine); preview/publish then
|
|
1928
|
+
update the same live game. Never creates a project.
|
|
1683
1929
|
genex preview [options] Build + push to the hosted draft URL (unlisted).
|
|
1684
1930
|
genex publish [options] Build + push, then list the game in the gallery.
|
|
1685
1931
|
genex model "<prompt>" [options] Generate a 3D model (GLB) into public/assets/models.
|
|
@@ -1709,6 +1955,15 @@ ${c.bold("Options for `init`")}
|
|
|
1709
1955
|
--force Overwrite existing files (default: never overwrite).
|
|
1710
1956
|
--timeout <seconds> How long to wait for the auth redirect (default: 300).
|
|
1711
1957
|
|
|
1958
|
+
${c.bold("Options for `link`")}
|
|
1959
|
+
<slug> The game to reconnect to (the name in your play URL). Required.
|
|
1960
|
+
--force Re-link a folder already linked to a DIFFERENT game.
|
|
1961
|
+
--env <path> Token env file (default: ~/.genex/env).
|
|
1962
|
+
--auth-url <url> Override the auth site (used only if sign-in is needed).
|
|
1963
|
+
--api-url <url> Override the API base URL.
|
|
1964
|
+
--colyseus-url <url> Override the multiplayer URL (stored in project metadata).
|
|
1965
|
+
--timeout <seconds> How long to wait for the auth redirect (default: 300).
|
|
1966
|
+
|
|
1712
1967
|
${c.bold("Options for `preview` / `publish`")}
|
|
1713
1968
|
--no-build Skip the build step; deploy whatever is already built.
|
|
1714
1969
|
--no-push (publish) Skip build + push; only flip the gallery flag.
|
|
@@ -1734,6 +1989,7 @@ ${c.bold("Environment")}
|
|
|
1734
1989
|
${c.bold("Examples")}
|
|
1735
1990
|
genex init my-game
|
|
1736
1991
|
genex init my-game --api-url http://localhost:3000 --auth-url http://localhost:5173
|
|
1992
|
+
genex link my-game
|
|
1737
1993
|
genex preview
|
|
1738
1994
|
genex publish
|
|
1739
1995
|
genex publish --categories games,vfx
|
|
@@ -1912,6 +2168,9 @@ async function main() {
|
|
|
1912
2168
|
case "init":
|
|
1913
2169
|
await runInit(parsed.options);
|
|
1914
2170
|
break;
|
|
2171
|
+
case "link":
|
|
2172
|
+
await runLink(parsed.options);
|
|
2173
|
+
break;
|
|
1915
2174
|
case "controller":
|
|
1916
2175
|
await runController({ ...parsed.options, kind: parsed.options.name });
|
|
1917
2176
|
break;
|
package/package.json
CHANGED