@genex-ai/cli-demo 0.16.0 → 0.18.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 +299 -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 +11 -6
- 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.matchmaking ?? null, 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,15 @@ 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, matchmaking, 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
|
+
// matchmaking rides every go-live (preview AND publish): object = upsert,
|
|
1158
|
+
// null = clear — so a draft's declared preset reaches the relay immediately.
|
|
1159
|
+
body: JSON.stringify({ commit, matchmaking: matchmaking ?? null })
|
|
989
1160
|
});
|
|
990
1161
|
} catch (err) {
|
|
991
1162
|
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
@@ -1004,7 +1175,7 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1004
1175
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1005
1176
|
return false;
|
|
1006
1177
|
};
|
|
1007
|
-
const gitDir = await
|
|
1178
|
+
const gitDir = await fs8.mkdtemp(path9.join(os2.tmpdir(), "genex-source-"));
|
|
1008
1179
|
const base = { GIT_DIR: gitDir };
|
|
1009
1180
|
const ident = {
|
|
1010
1181
|
GIT_AUTHOR_NAME: "genex",
|
|
@@ -1014,11 +1185,11 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1014
1185
|
};
|
|
1015
1186
|
try {
|
|
1016
1187
|
if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
|
|
1017
|
-
await
|
|
1018
|
-
|
|
1188
|
+
await fs8.writeFile(
|
|
1189
|
+
path9.join(gitDir, "info", "exclude"),
|
|
1019
1190
|
["node_modules/", "dist/", ".git/", KEY_NAME, `${KEY_NAME}.pub`, ".genex/", ""].join("\n")
|
|
1020
1191
|
);
|
|
1021
|
-
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE:
|
|
1192
|
+
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path9.join(gitDir, "index-source") };
|
|
1022
1193
|
await run("git", ["add", "-A"], env);
|
|
1023
1194
|
const tracked = await run("git", ["ls-files"], env);
|
|
1024
1195
|
if (/(^|\/)genex_key(\.pub)?$/m.test(tracked.out)) {
|
|
@@ -1042,20 +1213,20 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1042
1213
|
} catch {
|
|
1043
1214
|
return failed();
|
|
1044
1215
|
} finally {
|
|
1045
|
-
await
|
|
1216
|
+
await fs8.rm(gitDir, { recursive: true, force: true }).catch(() => {
|
|
1046
1217
|
});
|
|
1047
1218
|
}
|
|
1048
1219
|
}
|
|
1049
1220
|
async function isDir2(p) {
|
|
1050
1221
|
try {
|
|
1051
|
-
return (await
|
|
1222
|
+
return (await fs8.stat(p)).isDirectory();
|
|
1052
1223
|
} catch {
|
|
1053
1224
|
return false;
|
|
1054
1225
|
}
|
|
1055
1226
|
}
|
|
1056
1227
|
async function fileExists(p) {
|
|
1057
1228
|
try {
|
|
1058
|
-
await
|
|
1229
|
+
await fs8.access(p);
|
|
1059
1230
|
return true;
|
|
1060
1231
|
} catch {
|
|
1061
1232
|
return false;
|
|
@@ -1088,10 +1259,12 @@ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
|
|
|
1088
1259
|
log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
|
|
1089
1260
|
}
|
|
1090
1261
|
|
|
1091
|
-
// src/
|
|
1262
|
+
// src/lib/detect-features.ts
|
|
1263
|
+
import fs9 from "fs/promises";
|
|
1264
|
+
import path10 from "path";
|
|
1092
1265
|
async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
1093
1266
|
try {
|
|
1094
|
-
const raw = await
|
|
1267
|
+
const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
|
|
1095
1268
|
const pkg = JSON.parse(raw);
|
|
1096
1269
|
const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
|
|
1097
1270
|
return typeof version === "string" && version ? version : null;
|
|
@@ -1101,7 +1274,7 @@ async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
|
1101
1274
|
}
|
|
1102
1275
|
async function detectMultiplayer(cwd = process.cwd()) {
|
|
1103
1276
|
try {
|
|
1104
|
-
const raw = await
|
|
1277
|
+
const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
|
|
1105
1278
|
const pkg = JSON.parse(raw);
|
|
1106
1279
|
return Boolean(
|
|
1107
1280
|
pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
|
|
@@ -1113,7 +1286,7 @@ async function detectMultiplayer(cwd = process.cwd()) {
|
|
|
1113
1286
|
async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
1114
1287
|
let pkg;
|
|
1115
1288
|
try {
|
|
1116
|
-
pkg = JSON.parse(await
|
|
1289
|
+
pkg = JSON.parse(await fs9.readFile(path10.join(cwd, "package.json"), "utf8"));
|
|
1117
1290
|
} catch (err) {
|
|
1118
1291
|
log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
|
|
1119
1292
|
return null;
|
|
@@ -1128,6 +1301,53 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
|
1128
1301
|
}
|
|
1129
1302
|
return null;
|
|
1130
1303
|
}
|
|
1304
|
+
var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
|
|
1305
|
+
async function detectGameStateUsage(cwd = process.cwd()) {
|
|
1306
|
+
const srcDir = path10.join(cwd, "src");
|
|
1307
|
+
let entries;
|
|
1308
|
+
try {
|
|
1309
|
+
entries = await fs9.readdir(srcDir, { recursive: true });
|
|
1310
|
+
} catch {
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
for (const rel of entries) {
|
|
1314
|
+
if (rel.includes("node_modules")) continue;
|
|
1315
|
+
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
1316
|
+
try {
|
|
1317
|
+
const content = await fs9.readFile(path10.join(srcDir, rel), "utf8");
|
|
1318
|
+
if (GAME_STATE_CALLS.test(content)) return true;
|
|
1319
|
+
} catch {
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
async function detectFeatures(log, cwd = process.cwd()) {
|
|
1325
|
+
return {
|
|
1326
|
+
embedSdkVersion: await detectEmbedSdkVersion(cwd),
|
|
1327
|
+
multiplayer: await detectMultiplayer(cwd),
|
|
1328
|
+
matchmaking: await detectMatchmaking(log, cwd),
|
|
1329
|
+
gameStateUsed: await detectGameStateUsage(cwd)
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
function advisoryNudges(log, d) {
|
|
1333
|
+
if (!d.embedSdkVersion) {
|
|
1334
|
+
log.warn(
|
|
1335
|
+
"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."
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
if (d.multiplayer && !d.embedSdkVersion) {
|
|
1339
|
+
log.warn(
|
|
1340
|
+
"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."
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
if (d.matchmaking && !d.multiplayer) {
|
|
1344
|
+
log.warn(
|
|
1345
|
+
"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)."
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// src/commands/publish.ts
|
|
1131
1351
|
async function runPublish(opts) {
|
|
1132
1352
|
const log = createLogger({ quiet: opts.quiet });
|
|
1133
1353
|
log.plain(c.bold("genex publish"));
|
|
@@ -1145,10 +1365,12 @@ async function runPublish(opts) {
|
|
|
1145
1365
|
return;
|
|
1146
1366
|
}
|
|
1147
1367
|
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
1368
|
+
const detections = await detectFeatures(log);
|
|
1369
|
+
advisoryNudges(log, detections);
|
|
1148
1370
|
if (!opts.noPush) {
|
|
1149
1371
|
const ok = await deployGame(
|
|
1150
1372
|
{ projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
|
|
1151
|
-
{ noBuild: opts.noBuild },
|
|
1373
|
+
{ noBuild: opts.noBuild, matchmaking: detections.matchmaking },
|
|
1152
1374
|
log
|
|
1153
1375
|
);
|
|
1154
1376
|
if (!ok) {
|
|
@@ -1166,11 +1388,10 @@ async function runPublish(opts) {
|
|
|
1166
1388
|
if (opts.description) body.description = opts.description;
|
|
1167
1389
|
if (opts.regenerateCover) body.regenerateCover = true;
|
|
1168
1390
|
if (opts.categories?.length) body.categories = opts.categories;
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
body.
|
|
1172
|
-
|
|
1173
|
-
if (matchmaking) body.matchmaking = matchmaking;
|
|
1391
|
+
if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
|
|
1392
|
+
body.multiplayer = detections.multiplayer;
|
|
1393
|
+
body.matchmaking = detections.matchmaking ?? null;
|
|
1394
|
+
body.gameStateUsed = detections.gameStateUsed;
|
|
1174
1395
|
res = await fetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
1175
1396
|
method: "POST",
|
|
1176
1397
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
@@ -1208,10 +1429,14 @@ async function runPreview(opts) {
|
|
|
1208
1429
|
process.exitCode = 1;
|
|
1209
1430
|
return;
|
|
1210
1431
|
}
|
|
1432
|
+
const detections = await detectFeatures(log);
|
|
1433
|
+
advisoryNudges(log, detections);
|
|
1211
1434
|
const apiUrl = getApiUrl(meta.apiUrl);
|
|
1212
1435
|
const ok = await deployGame(
|
|
1213
1436
|
{ projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
|
|
1214
|
-
|
|
1437
|
+
// matchmaking reaches the server on every preview too — a draft with a declared
|
|
1438
|
+
// preset must not silently run the default one (null clears a removed config).
|
|
1439
|
+
{ noBuild: opts.noBuild, matchmaking: detections.matchmaking },
|
|
1215
1440
|
log
|
|
1216
1441
|
);
|
|
1217
1442
|
if (!ok) {
|
|
@@ -1223,11 +1448,11 @@ async function runPreview(opts) {
|
|
|
1223
1448
|
}
|
|
1224
1449
|
|
|
1225
1450
|
// src/commands/generate.ts
|
|
1226
|
-
import
|
|
1451
|
+
import path12 from "path";
|
|
1227
1452
|
|
|
1228
1453
|
// src/lib/assets.ts
|
|
1229
|
-
import
|
|
1230
|
-
import
|
|
1454
|
+
import fs10 from "fs/promises";
|
|
1455
|
+
import path11 from "path";
|
|
1231
1456
|
function slugify(input) {
|
|
1232
1457
|
const s = input.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/g, "");
|
|
1233
1458
|
return s || "asset";
|
|
@@ -1236,8 +1461,8 @@ async function downloadToFile(url, dest, headers) {
|
|
|
1236
1461
|
const res = await fetch(url, { headers });
|
|
1237
1462
|
if (!res.ok) throw new Error(`download failed (HTTP ${res.status}) for ${url}`);
|
|
1238
1463
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
1239
|
-
await
|
|
1240
|
-
await
|
|
1464
|
+
await fs10.mkdir(path11.dirname(dest), { recursive: true });
|
|
1465
|
+
await fs10.writeFile(dest, buf);
|
|
1241
1466
|
return buf.byteLength;
|
|
1242
1467
|
}
|
|
1243
1468
|
|
|
@@ -1369,7 +1594,7 @@ async function runGenerate(kind, opts) {
|
|
|
1369
1594
|
for (const f of view.files ?? []) {
|
|
1370
1595
|
const rel = fileDest(kind, slug, f);
|
|
1371
1596
|
const url = `${apiUrl}/api/generations/${id}/asset/${f.role}`;
|
|
1372
|
-
const bytes = await downloadToFile(url,
|
|
1597
|
+
const bytes = await downloadToFile(url, path12.join(process.cwd(), rel), {
|
|
1373
1598
|
Authorization: `Bearer ${token}`
|
|
1374
1599
|
});
|
|
1375
1600
|
written.push(rel);
|
|
@@ -1475,8 +1700,8 @@ function printHint(kind, written, log) {
|
|
|
1475
1700
|
}
|
|
1476
1701
|
|
|
1477
1702
|
// src/commands/controller.ts
|
|
1478
|
-
import
|
|
1479
|
-
import
|
|
1703
|
+
import fs11 from "fs/promises";
|
|
1704
|
+
import path13 from "path";
|
|
1480
1705
|
var CONTROLLER_KINDS = ["character", "car", "drone"];
|
|
1481
1706
|
var SHARED = [
|
|
1482
1707
|
"shared/math.ts",
|
|
@@ -1555,8 +1780,8 @@ var CONTROLLER_FILE_SETS = {
|
|
|
1555
1780
|
]
|
|
1556
1781
|
}
|
|
1557
1782
|
};
|
|
1558
|
-
var CODE_DEST =
|
|
1559
|
-
var ASSETS_DEST =
|
|
1783
|
+
var CODE_DEST = path13.join("src", "controllers");
|
|
1784
|
+
var ASSETS_DEST = path13.join("public", "assets");
|
|
1560
1785
|
async function runController(opts) {
|
|
1561
1786
|
const log = createLogger({ quiet: opts.quiet });
|
|
1562
1787
|
const kind = opts.kind?.trim();
|
|
@@ -1569,31 +1794,31 @@ async function runController(opts) {
|
|
|
1569
1794
|
process.exitCode = 1;
|
|
1570
1795
|
return;
|
|
1571
1796
|
}
|
|
1572
|
-
const srcDir =
|
|
1797
|
+
const srcDir = path13.join(getTemplatesDir(), "controllers");
|
|
1573
1798
|
const root = opts.cwd ?? process.cwd();
|
|
1574
1799
|
const set = CONTROLLER_FILE_SETS[kind];
|
|
1575
1800
|
log.plain(c.bold(`genex controller ${kind}`));
|
|
1576
1801
|
log.plain("");
|
|
1577
|
-
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST +
|
|
1802
|
+
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path13.sep)}`);
|
|
1578
1803
|
const plan = [
|
|
1579
|
-
...set.code.map((rel) => ({ from: rel, rel:
|
|
1804
|
+
...set.code.map((rel) => ({ from: rel, rel: path13.join(CODE_DEST, rel) })),
|
|
1580
1805
|
...set.assets.map((rel) => ({
|
|
1581
1806
|
from: rel,
|
|
1582
|
-
rel:
|
|
1807
|
+
rel: path13.join(ASSETS_DEST, path13.basename(rel))
|
|
1583
1808
|
}))
|
|
1584
1809
|
];
|
|
1585
1810
|
let copied = 0;
|
|
1586
1811
|
let skipped = 0;
|
|
1587
1812
|
try {
|
|
1588
1813
|
for (const file of plan) {
|
|
1589
|
-
const dest =
|
|
1814
|
+
const dest = path13.join(root, file.rel);
|
|
1590
1815
|
if (!opts.force && await exists2(dest)) {
|
|
1591
1816
|
skipped++;
|
|
1592
1817
|
log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
|
|
1593
1818
|
continue;
|
|
1594
1819
|
}
|
|
1595
|
-
await
|
|
1596
|
-
await
|
|
1820
|
+
await fs11.mkdir(path13.dirname(dest), { recursive: true });
|
|
1821
|
+
await fs11.copyFile(path13.join(srcDir, file.from), dest);
|
|
1597
1822
|
copied++;
|
|
1598
1823
|
log.dim(` ${file.rel}`);
|
|
1599
1824
|
}
|
|
@@ -1625,8 +1850,8 @@ async function runController(opts) {
|
|
|
1625
1850
|
}
|
|
1626
1851
|
async function installOwnerAvatar(args) {
|
|
1627
1852
|
const { root, srcDir, apiUrl, token, log } = args;
|
|
1628
|
-
const dest =
|
|
1629
|
-
await
|
|
1853
|
+
const dest = path13.join(root, ASSETS_DEST, "avatar.vrm");
|
|
1854
|
+
await fs11.mkdir(path13.dirname(dest), { recursive: true });
|
|
1630
1855
|
if (token) {
|
|
1631
1856
|
try {
|
|
1632
1857
|
const res = await fetch(`${apiUrl}/api/avatars/me`, {
|
|
@@ -1638,7 +1863,7 @@ async function installOwnerAvatar(args) {
|
|
|
1638
1863
|
const vrmRes = await fetch(me.vrmUrl);
|
|
1639
1864
|
if (vrmRes.ok) {
|
|
1640
1865
|
const buf = Buffer.from(await vrmRes.arrayBuffer());
|
|
1641
|
-
await
|
|
1866
|
+
await fs11.writeFile(dest, buf);
|
|
1642
1867
|
log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
|
|
1643
1868
|
return;
|
|
1644
1869
|
}
|
|
@@ -1649,14 +1874,14 @@ async function installOwnerAvatar(args) {
|
|
|
1649
1874
|
log.dim(" avatar fetch failed (offline?); using the bundled default.");
|
|
1650
1875
|
}
|
|
1651
1876
|
}
|
|
1652
|
-
await
|
|
1877
|
+
await fs11.copyFile(path13.join(srcDir, "assets", "default-avatar.vrm"), dest);
|
|
1653
1878
|
log.dim(
|
|
1654
1879
|
token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
|
|
1655
1880
|
);
|
|
1656
1881
|
}
|
|
1657
1882
|
async function exists2(p) {
|
|
1658
1883
|
try {
|
|
1659
|
-
await
|
|
1884
|
+
await fs11.access(p);
|
|
1660
1885
|
return true;
|
|
1661
1886
|
} catch {
|
|
1662
1887
|
return false;
|
|
@@ -1667,9 +1892,9 @@ async function exists2(p) {
|
|
|
1667
1892
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture"]);
|
|
1668
1893
|
function getVersion() {
|
|
1669
1894
|
try {
|
|
1670
|
-
const here =
|
|
1895
|
+
const here = path14.dirname(fileURLToPath2(import.meta.url));
|
|
1671
1896
|
const pkg = JSON.parse(
|
|
1672
|
-
readFileSync(
|
|
1897
|
+
readFileSync(path14.resolve(here, "..", "package.json"), "utf8")
|
|
1673
1898
|
);
|
|
1674
1899
|
return pkg.version ?? "0.0.0";
|
|
1675
1900
|
} catch {
|
|
@@ -1680,6 +1905,9 @@ var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize
|
|
|
1680
1905
|
|
|
1681
1906
|
${c.bold("Usage")}
|
|
1682
1907
|
genex init [<name>] [options] Scaffold + authorize + create the draft project.
|
|
1908
|
+
genex link <slug> [options] Re-link THIS folder to an existing game of yours
|
|
1909
|
+
(lost folder / new machine); preview/publish then
|
|
1910
|
+
update the same live game. Never creates a project.
|
|
1683
1911
|
genex preview [options] Build + push to the hosted draft URL (unlisted).
|
|
1684
1912
|
genex publish [options] Build + push, then list the game in the gallery.
|
|
1685
1913
|
genex model "<prompt>" [options] Generate a 3D model (GLB) into public/assets/models.
|
|
@@ -1709,6 +1937,15 @@ ${c.bold("Options for `init`")}
|
|
|
1709
1937
|
--force Overwrite existing files (default: never overwrite).
|
|
1710
1938
|
--timeout <seconds> How long to wait for the auth redirect (default: 300).
|
|
1711
1939
|
|
|
1940
|
+
${c.bold("Options for `link`")}
|
|
1941
|
+
<slug> The game to reconnect to (the name in your play URL). Required.
|
|
1942
|
+
--force Re-link a folder already linked to a DIFFERENT game.
|
|
1943
|
+
--env <path> Token env file (default: ~/.genex/env).
|
|
1944
|
+
--auth-url <url> Override the auth site (used only if sign-in is needed).
|
|
1945
|
+
--api-url <url> Override the API base URL.
|
|
1946
|
+
--colyseus-url <url> Override the multiplayer URL (stored in project metadata).
|
|
1947
|
+
--timeout <seconds> How long to wait for the auth redirect (default: 300).
|
|
1948
|
+
|
|
1712
1949
|
${c.bold("Options for `preview` / `publish`")}
|
|
1713
1950
|
--no-build Skip the build step; deploy whatever is already built.
|
|
1714
1951
|
--no-push (publish) Skip build + push; only flip the gallery flag.
|
|
@@ -1734,6 +1971,7 @@ ${c.bold("Environment")}
|
|
|
1734
1971
|
${c.bold("Examples")}
|
|
1735
1972
|
genex init my-game
|
|
1736
1973
|
genex init my-game --api-url http://localhost:3000 --auth-url http://localhost:5173
|
|
1974
|
+
genex link my-game
|
|
1737
1975
|
genex preview
|
|
1738
1976
|
genex publish
|
|
1739
1977
|
genex publish --categories games,vfx
|
|
@@ -1912,6 +2150,9 @@ async function main() {
|
|
|
1912
2150
|
case "init":
|
|
1913
2151
|
await runInit(parsed.options);
|
|
1914
2152
|
break;
|
|
2153
|
+
case "link":
|
|
2154
|
+
await runLink(parsed.options);
|
|
2155
|
+
break;
|
|
1915
2156
|
case "controller":
|
|
1916
2157
|
await runController({ ...parsed.options, kind: parsed.options.name });
|
|
1917
2158
|
break;
|
package/package.json
CHANGED