@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.
Files changed (36) hide show
  1. package/README.md +10 -0
  2. package/dist/index.js +317 -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 +10 -5
  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 +30 -13
  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
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, 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,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
- body: JSON.stringify({ commit })
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 fs7.mkdtemp(path8.join(os2.tmpdir(), "genex-source-"));
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 fs7.writeFile(
1018
- path8.join(gitDir, "info", "exclude"),
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: path8.join(gitDir, "index-source") };
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 fs7.rm(gitDir, { recursive: true, force: true }).catch(() => {
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 fs7.stat(p)).isDirectory();
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 fs7.access(p);
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/commands/publish.ts
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 fs8.readFile(path9.join(cwd, "package.json"), "utf8");
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 fs8.readFile(path9.join(cwd, "package.json"), "utf8");
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 fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
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
- { noBuild: opts.noBuild },
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
- 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;
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
- { noBuild: opts.noBuild },
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 path11 from "path";
1469
+ import path12 from "path";
1227
1470
 
1228
1471
  // src/lib/assets.ts
1229
- import fs9 from "fs/promises";
1230
- import path10 from "path";
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 fs9.mkdir(path10.dirname(dest), { recursive: true });
1240
- await fs9.writeFile(dest, buf);
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, path11.join(process.cwd(), rel), {
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 fs10 from "fs/promises";
1479
- import path12 from "path";
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 = path12.join("src", "controllers");
1559
- var ASSETS_DEST = path12.join("public", "assets");
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 = path12.join(getTemplatesDir(), "controllers");
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 + path12.sep)}`);
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: path12.join(CODE_DEST, 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: path12.join(ASSETS_DEST, path12.basename(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 = path12.join(root, file.rel);
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 fs10.mkdir(path12.dirname(dest), { recursive: true });
1596
- await fs10.copyFile(path12.join(srcDir, file.from), dest);
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 = path12.join(root, ASSETS_DEST, "avatar.vrm");
1629
- await fs10.mkdir(path12.dirname(dest), { recursive: true });
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 fs10.writeFile(dest, buf);
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 fs10.copyFile(path12.join(srcDir, "assets", "default-avatar.vrm"), dest);
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 fs10.access(p);
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 = path13.dirname(fileURLToPath2(import.meta.url));
1913
+ const here = path14.dirname(fileURLToPath2(import.meta.url));
1671
1914
  const pkg = JSON.parse(
1672
- readFileSync(path13.resolve(here, "..", "package.json"), "utf8")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.17.0",
3
+ "version": "0.20.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": {