@genex-ai/cli-demo 0.25.0 → 0.26.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 CHANGED
@@ -36,20 +36,20 @@ genex controller <type> # install a tuned character|car|drone controller →
36
36
  browser can't open, it prints the URL to open manually.
37
37
  3. **Saves your token** — writes `GENEX_TOKEN` to `~/.genex/env` (per-user;
38
38
  reused across projects).
39
- 4. **Creates the draft project** — generates a per-project SSH deploy key
40
- (`genex_key`, gitignored automatically), registers its public half with a new
41
- repo via `POST /api/projects`, and stores the project metadata (id, slug,
42
- `sshUrl`, urls) in `./.genex/project.json`. The game shows up in your
43
- dashboard's **My games** immediately.
39
+ 4. **Creates the draft project** — `POST /api/projects` provisions a managed
40
+ Forgejo repo (one per user; no SSH key) and stores the project metadata (id,
41
+ slug, `cloneUrl`, urls) in `./.genex/project.json`. Source pushes to that repo
42
+ over HTTPS with a per-push token minted by the API (see below), so it works
43
+ from any device with a Genex login. The game shows up in your dashboard's
44
+ **My games** immediately.
44
45
 
45
46
  `genex link <slug>` is the recovery counterpart of step 4 for a game that
46
47
  already exists: run it inside a fresh clone of the game's source repo (or any
47
- folder that lost its link) and it re-authorizes if needed, reuses or generates
48
- `genex_key`, registers the public half via `POST /api/projects/:id/deploy-key`
49
- (keys are added, never replaced), and rewrites `./.genex/project.json` — after
50
- which `preview`/`publish` update the **same** live game. It never creates a
51
- project; the dashboard's "Continue building" flow uses it when the original
52
- folder can't be found.
48
+ folder that lost its link) and it re-authorizes if needed and rewrites
49
+ `./.genex/project.json` nothing to register, since source pushes authorize per
50
+ push over HTTPS. After it, `preview`/`publish` update the **same** live game. It
51
+ never creates a project; the dashboard's "Continue building" flow uses it when
52
+ the original folder can't be found.
53
53
 
54
54
  `genex preview` and `genex publish` share a build-aware deploy core: each runs
55
55
  `npm run build` (when the project has a build script), then uploads the built
package/dist/index.js CHANGED
@@ -585,7 +585,7 @@ async function apiFetch(url, init = {}) {
585
585
 
586
586
  // src/lib/project.ts
587
587
  async function createDraftProject(opts) {
588
- const { apiUrl, token, deployKey, colyseusUrl, dashboardUrl, log } = opts;
588
+ const { apiUrl, token, colyseusUrl, dashboardUrl, log } = opts;
589
589
  log.step("Creating your project\u2026");
590
590
  const names = [opts.name, `${opts.name}-${randomSuffix()}`];
591
591
  for (let i = 0; i < names.length; i++) {
@@ -598,7 +598,7 @@ async function createDraftProject(opts) {
598
598
  "Content-Type": "application/json",
599
599
  Authorization: `Bearer ${token}`
600
600
  },
601
- body: JSON.stringify({ name, deployKey })
601
+ body: JSON.stringify({ name })
602
602
  });
603
603
  } catch (err) {
604
604
  log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
@@ -610,17 +610,13 @@ async function createDraftProject(opts) {
610
610
  log.warn("Not authorized to create the project (token rejected).");
611
611
  return null;
612
612
  }
613
- if (res.status === 400) {
614
- log.warn("The API rejected the deploy key (must be an OpenSSH public key).");
615
- return null;
616
- }
617
613
  if (!res.ok) {
618
614
  log.warn(`Couldn't create the project (HTTP ${res.status}).`);
619
615
  return null;
620
616
  }
621
617
  const data = await res.json().catch(() => null);
622
618
  const project = data?.project;
623
- if (!project || !data?.sshUrl) {
619
+ if (!project || !project.cloneUrl) {
624
620
  log.warn("Project created, but the API response was unexpected.");
625
621
  return null;
626
622
  }
@@ -630,7 +626,7 @@ async function createDraftProject(opts) {
630
626
  return {
631
627
  id: project.id,
632
628
  slug: project.slug,
633
- sshUrl: data.sshUrl,
629
+ cloneUrl: project.cloneUrl,
634
630
  apiUrl,
635
631
  colyseusUrl,
636
632
  playUrl: project.playUrl ?? void 0,
@@ -650,57 +646,6 @@ function randomSuffix() {
650
646
  // src/lib/ssh.ts
651
647
  import fs4 from "fs/promises";
652
648
  import path4 from "path";
653
- import { spawn as spawn2 } from "child_process";
654
- var KEY_NAME = "genex_key";
655
- async function generateSshKeypair(dir, log) {
656
- const keyPath = path4.join(dir, KEY_NAME);
657
- const pubPath = `${keyPath}.pub`;
658
- try {
659
- const existing = (await fs4.readFile(pubPath, "utf8")).trim();
660
- if (existing) {
661
- log.dim(`Reusing existing deploy key (${KEY_NAME}).`);
662
- return { publicKey: existing };
663
- }
664
- } catch {
665
- }
666
- log.step("Generating a deploy key\u2026");
667
- const ok = await runSshKeygen(keyPath, log);
668
- if (!ok) return null;
669
- try {
670
- const pub = (await fs4.readFile(pubPath, "utf8")).trim();
671
- if (!pub) {
672
- log.warn("ssh-keygen produced no public key.");
673
- return null;
674
- }
675
- await fs4.chmod(keyPath, 384).catch(() => {
676
- });
677
- return { publicKey: pub };
678
- } catch (err) {
679
- log.warn(`Couldn't read the generated public key: ${String(err)}`);
680
- return null;
681
- }
682
- }
683
- function runSshKeygen(keyPath, log) {
684
- return new Promise((resolve) => {
685
- let child;
686
- try {
687
- child = spawn2(
688
- "ssh-keygen",
689
- ["-t", "ed25519", "-f", keyPath, "-N", "", "-C", "genex-agent"],
690
- { stdio: "ignore" }
691
- );
692
- } catch {
693
- log.warn("ssh-keygen not found \u2014 install OpenSSH (ssh-keygen) and re-run.");
694
- resolve(false);
695
- return;
696
- }
697
- child.on("error", () => {
698
- log.warn("ssh-keygen not found \u2014 install OpenSSH (ssh-keygen) and re-run.");
699
- resolve(false);
700
- });
701
- child.on("close", (code2) => resolve(code2 === 0));
702
- });
703
- }
704
649
  async function writeGitignore(dir, log) {
705
650
  const file = path4.join(dir, ".gitignore");
706
651
  let content = "";
@@ -709,11 +654,11 @@ async function writeGitignore(dir, log) {
709
654
  } catch {
710
655
  }
711
656
  const present = new Set(content.split("\n").map((l) => l.trim()));
712
- const toAdd = [KEY_NAME, `${KEY_NAME}.pub`, ".genex/"].filter((e) => !present.has(e));
657
+ const toAdd = [".genex/", ".env", ".env.*", "!.env.example"].filter((e) => !present.has(e));
713
658
  if (toAdd.length === 0) return;
714
659
  let next = content;
715
660
  if (next.length > 0 && !next.endsWith("\n")) next += "\n";
716
- if (!content.trim()) next += "# genex (deploy key + local metadata \u2014 never publish)\n";
661
+ if (!content.trim()) next += "# genex local metadata + secrets \u2014 never publish\n";
717
662
  next += toAdd.join("\n") + "\n";
718
663
  await fs4.writeFile(file, next);
719
664
  log.dim(`Updated .gitignore (${toAdd.join(", ")}).`);
@@ -726,7 +671,7 @@ import path6 from "path";
726
671
  // src/lib/env.ts
727
672
  import fs5 from "fs/promises";
728
673
  import path5 from "path";
729
- import { spawn as spawn3 } from "child_process";
674
+ import { spawn as spawn2 } from "child_process";
730
675
  async function writeEnvVar(envPath, key, value) {
731
676
  let content = "";
732
677
  let existed = false;
@@ -766,7 +711,7 @@ async function restrictFilePermissions(filePath) {
766
711
  if (!user) return;
767
712
  await new Promise((resolve) => {
768
713
  try {
769
- const child = spawn3(
714
+ const child = spawn2(
770
715
  "icacls",
771
716
  [filePath, "/inheritance:r", "/grant:r", `${user}:F`],
772
717
  { stdio: "ignore" }
@@ -971,16 +916,7 @@ async function runInit(opts) {
971
916
  const { path: tokenPath } = await writeUserToken(token, opts.envPath);
972
917
  log.success(`Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
973
918
  log.plain("");
974
- const key = await generateSshKeypair(process.cwd(), log);
975
919
  await writeGitignore(process.cwd(), log);
976
- if (!key) {
977
- log.warn(
978
- "Skipping project creation \u2014 no deploy key. Install ssh-keygen (OpenSSH) and re-run `genex init`."
979
- );
980
- log.plain("");
981
- log.success("Workspace ready (no project created yet).");
982
- return;
983
- }
984
920
  const apiUrl = getApiUrl(opts.apiUrl);
985
921
  const colyseusUrl = getColyseusUrl(opts.colyseusUrl);
986
922
  const projectName = opts.name?.trim() || path8.basename(process.cwd());
@@ -988,7 +924,6 @@ async function runInit(opts) {
988
924
  apiUrl,
989
925
  token,
990
926
  name: projectName,
991
- deployKey: key.publicKey,
992
927
  colyseusUrl,
993
928
  dashboardUrl: authBaseUrl,
994
929
  log
@@ -1041,23 +976,11 @@ async function runLink(opts) {
1041
976
  process.exitCode = 1;
1042
977
  return;
1043
978
  }
1044
- const key = await generateSshKeypair(process.cwd(), log);
1045
- if (!key) {
1046
- log.error("No deploy key \u2014 install ssh-keygen (OpenSSH) and re-run `genex link`.");
1047
- process.exitCode = 1;
1048
- return;
1049
- }
1050
979
  await writeGitignore(process.cwd(), log);
1051
- log.step("Registering this folder's deploy key\u2026");
1052
- const linked = await registerDeployKey(apiUrl, token, project.id, key.publicKey, log);
1053
- if (!linked) {
1054
- process.exitCode = 1;
1055
- return;
1056
- }
1057
980
  const meta = {
1058
981
  id: project.id,
1059
982
  slug: project.slug,
1060
- sshUrl: linked.sshUrl,
983
+ cloneUrl: project.cloneUrl ?? "",
1061
984
  apiUrl,
1062
985
  colyseusUrl: getColyseusUrl(opts.colyseusUrl),
1063
986
  playUrl: project.playUrl ?? void 0,
@@ -1143,40 +1066,9 @@ async function listOwnSlugs(apiUrl, token, log) {
1143
1066
  } catch {
1144
1067
  }
1145
1068
  }
1146
- async function registerDeployKey(apiUrl, token, projectId, deployKey, log) {
1147
- let res;
1148
- try {
1149
- res = await apiFetch(`${apiUrl}/api/projects/${projectId}/deploy-key`, {
1150
- method: "POST",
1151
- headers: {
1152
- "Content-Type": "application/json",
1153
- Authorization: `Bearer ${token}`
1154
- },
1155
- body: JSON.stringify({ deployKey })
1156
- });
1157
- } catch (err) {
1158
- log.error(`Couldn't reach the API at ${apiUrl}.`);
1159
- log.dim(` ${String(err)}`);
1160
- return null;
1161
- }
1162
- if (res.status === 429) {
1163
- log.error("Rate limited \u2014 too many link attempts. Try again in a bit.");
1164
- return null;
1165
- }
1166
- if (!res.ok) {
1167
- log.error(`Couldn't register the deploy key (HTTP ${res.status}).`);
1168
- return null;
1169
- }
1170
- const data = await res.json().catch(() => null);
1171
- if (!data?.sshUrl) {
1172
- log.error("Unexpected API response while registering the deploy key.");
1173
- return null;
1174
- }
1175
- return { sshUrl: data.sshUrl };
1176
- }
1177
1069
 
1178
1070
  // src/lib/deploy.ts
1179
- import { spawn as spawn4 } from "child_process";
1071
+ import { spawn as spawn3 } from "child_process";
1180
1072
  import crypto3 from "crypto";
1181
1073
  import fs9 from "fs/promises";
1182
1074
  import os2 from "os";
@@ -1185,7 +1077,7 @@ function run(cmd, args, env) {
1185
1077
  return new Promise((resolve) => {
1186
1078
  let child;
1187
1079
  try {
1188
- child = spawn4(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
1080
+ child = spawn3(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
1189
1081
  } catch {
1190
1082
  resolve({ code: -1, out: "", err: `${cmd} not found` });
1191
1083
  return;
@@ -1199,6 +1091,9 @@ function run(cmd, args, env) {
1199
1091
  });
1200
1092
  }
1201
1093
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
1094
+ function isSecretEnvFile(name) {
1095
+ return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
1096
+ }
1202
1097
  async function deployGame(ctx, opts, log) {
1203
1098
  const cwd = process.cwd();
1204
1099
  if (!opts.noBuild && await hasBuildScript(cwd)) {
@@ -1217,10 +1112,6 @@ async function deployGame(ctx, opts, log) {
1217
1112
  const rel = path10.relative(cwd, siteDir) || ".";
1218
1113
  if (siteDir === cwd) await writeGitignore(cwd, log);
1219
1114
  const files = await collectFiles(siteDir);
1220
- if (files.some((f) => /(^|\/)genex_key(\.pub)?$/.test(f.relPath))) {
1221
- log.error(`Refusing to deploy: ${KEY_NAME} is in ${rel}. Add it to .gitignore and retry.`);
1222
- return false;
1223
- }
1224
1115
  if (files.length === 0) {
1225
1116
  log.warn("Nothing to deploy \u2014 the build produced no files.");
1226
1117
  return false;
@@ -1253,12 +1144,7 @@ async function deployGame(ctx, opts, log) {
1253
1144
  log.error("Couldn't upload your game \u2014 please try again.");
1254
1145
  return false;
1255
1146
  }
1256
- const keyPath = path10.resolve(cwd, KEY_NAME);
1257
- if (!await fileExists(keyPath)) {
1258
- log.error(`No deploy key (${KEY_NAME}) here \u2014 run \`genex init\` in this folder first.`);
1259
- return false;
1260
- }
1261
- if (!await pushSource(cwd, ctx.sshUrl, keyPath, log)) return false;
1147
+ if (!await pushSource(cwd, ctx, log)) return false;
1262
1148
  log.step("Publishing\u2026");
1263
1149
  if (!await callPublish(ctx, commit, opts, log)) return false;
1264
1150
  const index = files.find((f) => f.relPath === "index.html");
@@ -1280,7 +1166,7 @@ async function collectFiles(root) {
1280
1166
  const relPath = prefix ? `${prefix}/${e.name}` : e.name;
1281
1167
  if (e.isDirectory()) {
1282
1168
  if (!EXCLUDE_DIRS.has(e.name)) await walk2(path10.join(dir, e.name), relPath);
1283
- } else if (e.isFile() && e.name !== KEY_NAME && e.name !== `${KEY_NAME}.pub`) {
1169
+ } else if (e.isFile() && !isSecretEnvFile(e.name)) {
1284
1170
  out.push({ relPath, bytes: await fs9.readFile(path10.join(dir, e.name)) });
1285
1171
  }
1286
1172
  }
@@ -1371,12 +1257,14 @@ async function callPublish(ctx, commit, opts, log) {
1371
1257
  }
1372
1258
  return true;
1373
1259
  }
1374
- async function pushSource(cwd, sshUrl, keyPath, log) {
1260
+ async function pushSource(cwd, ctx, log) {
1375
1261
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1376
1262
  const failed = () => {
1377
1263
  log.error("Couldn't save your game's source \u2014 please try again.");
1378
1264
  return false;
1379
1265
  };
1266
+ const pushUrl = await fetchPushUrl(ctx, log);
1267
+ if (!pushUrl) return false;
1380
1268
  const gitDir = await fs9.mkdtemp(path10.join(os2.tmpdir(), "genex-source-"));
1381
1269
  const base = { GIT_DIR: gitDir };
1382
1270
  const ident = {
@@ -1389,28 +1277,19 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1389
1277
  if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
1390
1278
  await fs9.writeFile(
1391
1279
  path10.join(gitDir, "info", "exclude"),
1392
- ["node_modules/", "dist/", ".git/", KEY_NAME, `${KEY_NAME}.pub`, ".genex/", ""].join("\n")
1280
+ // .env* are secrets — the managed repo is public. `!` keeps the non-secret template.
1281
+ ["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
1393
1282
  );
1394
1283
  const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
1395
1284
  await run("git", ["add", "-A"], env);
1396
- const tracked = await run("git", ["ls-files"], env);
1397
- if (/(^|\/)genex_key(\.pub)?$/m.test(tracked.out)) {
1398
- log.error(`Refusing to publish: ${KEY_NAME} is in your project. Add it to .gitignore and retry.`);
1399
- return false;
1400
- }
1401
- const tree = tracked.out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
1285
+ const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
1402
1286
  if (!tree || tree === EMPTY_TREE) {
1403
1287
  log.error("Nothing to publish \u2014 the project has no files.");
1404
1288
  return false;
1405
1289
  }
1406
1290
  const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
1407
1291
  await run("git", ["update-ref", "refs/heads/main", commit], base);
1408
- const push = await run("git", ["push", "-q", sshUrl, "+refs/heads/main:main"], {
1409
- ...base,
1410
- // Quote the key path: git splits GIT_SSH_COMMAND shell-like, so a folder with a
1411
- // space would otherwise break the `-i` argument.
1412
- GIT_SSH_COMMAND: `ssh -i "${keyPath}" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`
1413
- });
1292
+ const push = await run("git", ["push", "-q", pushUrl, "+refs/heads/main:main"], base);
1414
1293
  return push.code === 0 ? true : failed();
1415
1294
  } catch {
1416
1295
  return failed();
@@ -1419,17 +1298,35 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1419
1298
  });
1420
1299
  }
1421
1300
  }
1422
- async function isDir2(p) {
1301
+ async function fetchPushUrl(ctx, log) {
1302
+ let res;
1423
1303
  try {
1424
- return (await fs9.stat(p)).isDirectory();
1425
- } catch {
1426
- return false;
1304
+ res = await apiFetch(`${ctx.apiUrl}/api/projects/${ctx.projectId}/push-token`, {
1305
+ method: "POST",
1306
+ headers: { Authorization: `Bearer ${ctx.token}` }
1307
+ });
1308
+ } catch (err) {
1309
+ log.error(`Couldn't reach the API to authorize the source push: ${String(err)}`);
1310
+ return null;
1311
+ }
1312
+ if (res.status === 401) {
1313
+ log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
1314
+ return null;
1315
+ }
1316
+ if (!res.ok) {
1317
+ log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
1318
+ return null;
1427
1319
  }
1320
+ const data = await res.json().catch(() => null);
1321
+ if (!data?.pushUrl) {
1322
+ log.error("The API didn't return a push URL.");
1323
+ return null;
1324
+ }
1325
+ return data.pushUrl;
1428
1326
  }
1429
- async function fileExists(p) {
1327
+ async function isDir2(p) {
1430
1328
  try {
1431
- await fs9.access(p);
1432
- return true;
1329
+ return (await fs9.stat(p)).isDirectory();
1433
1330
  } catch {
1434
1331
  return false;
1435
1332
  }
@@ -1571,7 +1468,7 @@ async function runPublish(opts) {
1571
1468
  advisoryNudges(log, detections);
1572
1469
  if (!opts.noPush) {
1573
1470
  const ok = await deployGame(
1574
- { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1471
+ { projectId: meta.id, apiUrl, token },
1575
1472
  {
1576
1473
  noBuild: opts.noBuild,
1577
1474
  matchmaking: detections.matchmaking,
@@ -1643,7 +1540,7 @@ async function runPreview(opts) {
1643
1540
  advisoryNudges(log, detections);
1644
1541
  const apiUrl = getApiUrl(meta.apiUrl);
1645
1542
  const ok = await deployGame(
1646
- { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1543
+ { projectId: meta.id, apiUrl, token },
1647
1544
  // Detections reach the server on every preview too: matchmaking so a draft's
1648
1545
  // declared preset doesn't silently run the default (null clears a removed
1649
1546
  // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.25.0",
3
+ "version": "0.26.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": {
@@ -92,8 +92,8 @@ refreshes the genex-owned ones.
92
92
 
93
93
  ## Reconnecting an existing game (`genex link`)
94
94
 
95
- Each game's connection to its live page is folder-local (`.genex/project.json`
96
- + the `genex_key` deploy key). If that folder is gone — deleted, or the game
95
+ Each game's connection to its live page is folder-local (`.genex/project.json`).
96
+ If that folder is gone — deleted, or the game
97
97
  was built on another machine — **don't run `init` to "recover" it**: that
98
98
  creates a brand-new game at a new URL. Instead, clone the game's source repo
99
99
  and re-link the clone to the same live game:
@@ -104,8 +104,9 @@ npm install
104
104
  npx @genex-ai/cli-demo@latest link <slug> # slug = the name in the play URL
105
105
  ```
106
106
 
107
- `link` never creates a project: it signs in if needed (the browser opens once),
108
- registers a fresh deploy key for this folder, and rewrites the local link.
107
+ `link` never creates a project: it signs in if needed (the browser opens once)
108
+ and rewrites the local link (source pushes authorize over HTTPS, so there's no
109
+ key to set up — it works from any machine).
109
110
  After it, `npx genex preview` / `npx genex publish` update the **same live
110
111
  game** — plays, likes, and comments stay. It also fixes a folder whose
111
112
  authorization went stale ("Not authorized" from `preview`/`publish`).