@genex-ai/cli-demo 0.17.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.
Files changed (38) hide show
  1. package/README.md +10 -0
  2. package/dist/index.js +299 -58
  3. package/package.json +1 -1
  4. package/templates/skills/genex-ai-model/SKILL.md +6 -1
  5. package/templates/skills/genex-ai-sfx/SKILL.md +9 -0
  6. package/templates/skills/genex-ai-skybox/SKILL.md +9 -1
  7. package/templates/skills/genex-ai-texture/SKILL.md +11 -1
  8. package/templates/skills/genex-getting-started/SKILL.md +44 -0
  9. package/templates/skills/genex-threejs-atmosphere-aerial-perspective/SKILL.md +4 -0
  10. package/templates/skills/genex-threejs-bloom/references/bloom.md +3 -1
  11. package/templates/skills/genex-threejs-camera-direction/SKILL.md +6 -0
  12. package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +2 -2
  13. package/templates/skills/genex-threejs-character-controller/SKILL.md +6 -1
  14. package/templates/skills/genex-threejs-embed-auth/SKILL.md +9 -1
  15. package/templates/skills/genex-threejs-game-feel/SKILL.md +97 -0
  16. package/templates/skills/genex-threejs-game-ui/SKILL.md +119 -0
  17. package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +2 -0
  18. package/templates/skills/genex-threejs-multiplayer/SKILL.md +13 -72
  19. package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +16 -5
  20. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +2 -1
  21. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +1 -1
  22. package/templates/skills/genex-threejs-procedural-animation/references/procedural-motion.md +8 -8
  23. package/templates/skills/genex-threejs-screen-space-ambient-occlusion/SKILL.md +1 -1
  24. package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +2 -0
  25. package/templates/skills/genex-threejs-shadow-systems/SKILL.md +6 -1
  26. package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +2 -0
  27. package/templates/skills/genex-threejs-skill-router/SKILL.md +23 -3
  28. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +31 -18
  29. package/templates/skills/genex-threejs-spectral-ocean/SKILL.md +2 -0
  30. package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +2 -0
  31. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +24 -0
  32. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +30 -2
  33. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +21 -2
  34. package/templates/skills/genex-threejs-visual-validation/SKILL.md +15 -0
  35. package/templates/skills/genex-threejs-volumetric-clouds/SKILL.md +4 -0
  36. package/templates/skills/genex-threejs-water-optics/references/water-optics.md +3 -1
  37. package/templates/controllers/shared/NETWORKING.md +0 -11
  38. package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +0 -111
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 path13 from "path";
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/publish.ts
810
- import fs8 from "fs/promises";
811
- import path9 from "path";
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 fs7 from "fs/promises";
985
+ import fs8 from "fs/promises";
817
986
  import os2 from "os";
818
- import path8 from "path";
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 = path8.join(cwd, "dist");
1019
+ const distDir = path9.join(cwd, "dist");
851
1020
  const siteDir = await isDir2(distDir) ? distDir : cwd;
852
- const rel = path8.relative(cwd, siteDir) || ".";
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 = path8.resolve(cwd, KEY_NAME);
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 fs7.readFile(path8.join(cwd, "package.json"), "utf8"));
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 fs7.readdir(dir, { withFileTypes: true })) {
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(path8.join(dir, e.name), relPath);
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 fs7.readFile(path8.join(dir, e.name)) });
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
- body: JSON.stringify({ commit })
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 fs7.mkdtemp(path8.join(os2.tmpdir(), "genex-source-"));
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 fs7.writeFile(
1018
- path8.join(gitDir, "info", "exclude"),
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: path8.join(gitDir, "index-source") };
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 fs7.rm(gitDir, { recursive: true, force: true }).catch(() => {
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 fs7.stat(p)).isDirectory();
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 fs7.access(p);
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/commands/publish.ts
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 fs8.readFile(path9.join(cwd, "package.json"), "utf8");
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 fs8.readFile(path9.join(cwd, "package.json"), "utf8");
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 fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
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
- const embedSdkVersion = await detectEmbedSdkVersion();
1170
- if (embedSdkVersion) body.embedSdkVersion = embedSdkVersion;
1171
- body.multiplayer = await detectMultiplayer();
1172
- const matchmaking = await detectMatchmaking(log);
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
- { noBuild: opts.noBuild },
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 path11 from "path";
1451
+ import path12 from "path";
1227
1452
 
1228
1453
  // src/lib/assets.ts
1229
- import fs9 from "fs/promises";
1230
- import path10 from "path";
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 fs9.mkdir(path10.dirname(dest), { recursive: true });
1240
- await fs9.writeFile(dest, buf);
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, path11.join(process.cwd(), rel), {
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 fs10 from "fs/promises";
1479
- import path12 from "path";
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 = path12.join("src", "controllers");
1559
- var ASSETS_DEST = path12.join("public", "assets");
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 = path12.join(getTemplatesDir(), "controllers");
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 + path12.sep)}`);
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: path12.join(CODE_DEST, 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: path12.join(ASSETS_DEST, path12.basename(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 = path12.join(root, file.rel);
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 fs10.mkdir(path12.dirname(dest), { recursive: true });
1596
- await fs10.copyFile(path12.join(srcDir, file.from), dest);
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 = path12.join(root, ASSETS_DEST, "avatar.vrm");
1629
- await fs10.mkdir(path12.dirname(dest), { recursive: true });
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 fs10.writeFile(dest, buf);
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 fs10.copyFile(path12.join(srcDir, "assets", "default-avatar.vrm"), dest);
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 fs10.access(p);
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 = path13.dirname(fileURLToPath2(import.meta.url));
1895
+ const here = path14.dirname(fileURLToPath2(import.meta.url));
1671
1896
  const pkg = JSON.parse(
1672
- readFileSync(path13.resolve(here, "..", "package.json"), "utf8")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {