@genex-ai/cli-demo 0.24.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
@@ -28,26 +28,28 @@ genex controller <type> # install a tuned character|car|drone controller →
28
28
  agents/ + commands/), Codex (`~/.codex/skills`), and Cursor
29
29
  (`~/.cursor/skills`). genex-owned skills (`genex-*`) are **always refreshed**
30
30
  to the package version so a stale copy can't linger; your own files are never
31
- overwritten. Pick agents explicitly with `--agents claude,codex,cursor`, or a
32
- single custom dir with `--dir`.
31
+ overwritten. After init, **every** `genex` command re-syncs them to the
32
+ installed CLI version automatically (printing `🔄 Genex skills updated to
33
+ X.Y.Z` when it does). Pick agents explicitly with `--agents
34
+ claude,codex,cursor`, or a single custom dir with `--dir`.
33
35
  2. **Authorizes you** — opens the Genex auth site (web) in your browser. If the
34
36
  browser can't open, it prints the URL to open manually.
35
37
  3. **Saves your token** — writes `GENEX_TOKEN` to `~/.genex/env` (per-user;
36
38
  reused across projects).
37
- 4. **Creates the draft project** — generates a per-project SSH deploy key
38
- (`genex_key`, gitignored automatically), registers its public half with a new
39
- repo via `POST /api/projects`, and stores the project metadata (id, slug,
40
- `sshUrl`, urls) in `./.genex/project.json`. The game shows up in your
41
- 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.
42
45
 
43
46
  `genex link <slug>` is the recovery counterpart of step 4 for a game that
44
47
  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.
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.
51
53
 
52
54
  `genex preview` and `genex publish` share a build-aware deploy core: each runs
53
55
  `npm run build` (when the project has a build script), then uploads the built
package/dist/index.js CHANGED
@@ -1,12 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { readFileSync } from "fs";
5
- import path12 from "path";
6
- import { fileURLToPath as fileURLToPath2 } from "url";
7
-
8
3
  // src/commands/init.ts
9
- import path7 from "path";
4
+ import path8 from "path";
10
5
 
11
6
  // src/config.ts
12
7
  import fs from "fs";
@@ -43,6 +38,17 @@ function getTemplatesDir() {
43
38
  const here = path.dirname(fileURLToPath(import.meta.url));
44
39
  return path.resolve(here, "..", "templates");
45
40
  }
41
+ function getCliVersion() {
42
+ try {
43
+ const here = path.dirname(fileURLToPath(import.meta.url));
44
+ const pkg = JSON.parse(
45
+ fs.readFileSync(path.resolve(here, "..", "package.json"), "utf8")
46
+ );
47
+ return pkg.version ?? "0.0.0";
48
+ } catch {
49
+ return "0.0.0";
50
+ }
51
+ }
46
52
  var KNOWN_AGENTS = {
47
53
  claude: { label: "Claude Code", dirName: ".claude", full: true },
48
54
  codex: { label: "Codex", dirName: ".codex", full: false },
@@ -121,6 +127,172 @@ async function exists(p) {
121
127
  }
122
128
  }
123
129
 
130
+ // src/lib/updates.ts
131
+ import fs3 from "fs/promises";
132
+ import path3 from "path";
133
+ function parseSemver(v) {
134
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
135
+ if (!m) return null;
136
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
137
+ }
138
+ function isNewerVersion(a, b) {
139
+ const pa = parseSemver(a);
140
+ const pb = parseSemver(b);
141
+ if (!pa || !pb) return false;
142
+ for (let i = 0; i < 3; i++) {
143
+ if (pa[i] !== pb[i]) return pa[i] > pb[i];
144
+ }
145
+ return false;
146
+ }
147
+ var SKILLS_VERSION_MARKER = "genex-skills-version.json";
148
+ async function readSkillsMarker(skillsDir) {
149
+ try {
150
+ const raw = await fs3.readFile(path3.join(skillsDir, SKILLS_VERSION_MARKER), "utf8");
151
+ const parsed = JSON.parse(raw);
152
+ return typeof parsed.version === "string" ? parsed.version : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ async function writeSkillsMarker(skillsDir, version = getCliVersion()) {
158
+ await fs3.mkdir(skillsDir, { recursive: true });
159
+ await fs3.writeFile(
160
+ path3.join(skillsDir, SKILLS_VERSION_MARKER),
161
+ JSON.stringify({ version, syncedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
162
+ );
163
+ }
164
+ async function hasGenexSkills(skillsDir) {
165
+ try {
166
+ const entries = await fs3.readdir(skillsDir);
167
+ return entries.some((name) => name.startsWith("genex-"));
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+ async function syncSkillsForTarget(target, templatesDir = getTemplatesDir(), version = getCliVersion()) {
173
+ const skillsDir = path3.join(target.baseDir, "skills");
174
+ if (!await hasGenexSkills(skillsDir)) return false;
175
+ if (await readSkillsMarker(skillsDir) === version) return false;
176
+ const src = target.full ? templatesDir : path3.join(templatesDir, "skills");
177
+ const dest = target.full ? target.baseDir : skillsDir;
178
+ await copyTemplates(src, dest, { exclude: ["controllers"] });
179
+ await writeSkillsMarker(skillsDir, version);
180
+ return true;
181
+ }
182
+ async function syncSkills(log) {
183
+ try {
184
+ const version = getCliVersion();
185
+ const templatesDir = getTemplatesDir();
186
+ let refreshed = false;
187
+ for (const target of resolveAgentTargets()) {
188
+ try {
189
+ refreshed = await syncSkillsForTarget(target, templatesDir, version) || refreshed;
190
+ } catch {
191
+ }
192
+ }
193
+ if (refreshed) log.plain(`\u{1F504} Genex skills updated to ${version}`);
194
+ } catch {
195
+ }
196
+ }
197
+ var PUBLISHED_PACKAGES = [
198
+ { name: "@genex-ai/cli-demo", label: "CLI", install: "npm i -D @genex-ai/cli-demo@latest" },
199
+ { name: "@genex-ai/embed-sdk", label: "embed SDK", install: "npm i @genex-ai/embed-sdk@latest" },
200
+ {
201
+ name: "@genex-ai/multiplayer",
202
+ label: "multiplayer SDK",
203
+ install: "npm i @genex-ai/multiplayer@latest"
204
+ }
205
+ ];
206
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
207
+ var REGISTRY_TIMEOUT_MS = 1500;
208
+ function getUpdateCachePath() {
209
+ return path3.join(getGenexDir(), "update-check.json");
210
+ }
211
+ function isCacheFresh(cache, nowMs) {
212
+ const at = Date.parse(cache.checkedAt);
213
+ return Number.isFinite(at) && nowMs - at < CHECK_INTERVAL_MS;
214
+ }
215
+ async function readUpdateCache() {
216
+ try {
217
+ const raw = await fs3.readFile(getUpdateCachePath(), "utf8");
218
+ const parsed = JSON.parse(raw);
219
+ if (typeof parsed.checkedAt !== "string" || typeof parsed.latest !== "object") return null;
220
+ return parsed;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+ async function fetchLatestFromRegistry(name) {
226
+ try {
227
+ const res = await fetch(
228
+ `https://registry.npmjs.org/-/package/${encodeURIComponent(name)}/dist-tags`,
229
+ { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) }
230
+ );
231
+ if (!res.ok) return null;
232
+ const tags = await res.json();
233
+ return typeof tags.latest === "string" && parseSemver(tags.latest) ? tags.latest : null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+ function startUpdateCheck() {
239
+ return (async () => {
240
+ try {
241
+ const cached = await readUpdateCache();
242
+ if (cached && isCacheFresh(cached, Date.now())) return cached;
243
+ const latest = { ...cached?.latest ?? {} };
244
+ const results = await Promise.allSettled(
245
+ PUBLISHED_PACKAGES.map((p) => fetchLatestFromRegistry(p.name))
246
+ );
247
+ results.forEach((r, i) => {
248
+ if (r.status === "fulfilled" && r.value) latest[PUBLISHED_PACKAGES[i].name] = r.value;
249
+ });
250
+ const next = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), latest };
251
+ await fs3.mkdir(getGenexDir(), { recursive: true });
252
+ await fs3.writeFile(getUpdateCachePath(), JSON.stringify(next, null, 2) + "\n");
253
+ return next;
254
+ } catch {
255
+ return null;
256
+ }
257
+ })();
258
+ }
259
+ async function installedPackageVersion(cwd, name) {
260
+ try {
261
+ const raw = await fs3.readFile(path3.join(cwd, "node_modules", name, "package.json"), "utf8");
262
+ const pkg = JSON.parse(raw);
263
+ return typeof pkg.version === "string" ? pkg.version : null;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+ function formatNudge(label, latest, installed, install) {
269
+ return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install}`;
270
+ }
271
+ async function reportUpdateNudges(check, log, cwd = process.cwd()) {
272
+ try {
273
+ const cache = await check;
274
+ if (!cache) return;
275
+ const lines = [];
276
+ let whatsNew = null;
277
+ for (const pkg of PUBLISHED_PACKAGES) {
278
+ const latest = cache.latest[pkg.name];
279
+ if (!latest) continue;
280
+ const installed = pkg.name === "@genex-ai/cli-demo" ? getCliVersion() : await installedPackageVersion(cwd, pkg.name);
281
+ if (!installed) continue;
282
+ if (isNewerVersion(latest, installed)) {
283
+ lines.push(formatNudge(pkg.label, latest, installed, pkg.install));
284
+ whatsNew ??= `https://www.npmjs.com/package/${pkg.name}`;
285
+ }
286
+ }
287
+ if (lines.length === 0) return;
288
+ for (const line of lines) log.plain(line);
289
+ log.dim(
290
+ ` Apply at a safe moment (never mid-task) \u2014 see the genex-updates skill. What's new: ${whatsNew}`
291
+ );
292
+ } catch {
293
+ }
294
+ }
295
+
124
296
  // src/lib/auth.ts
125
297
  import http from "http";
126
298
  import crypto from "crypto";
@@ -388,22 +560,45 @@ var c = {
388
560
  gray: code(90, 39)
389
561
  };
390
562
 
563
+ // src/lib/api.ts
564
+ var CLI_VERSION_HEADER = "x-genex-cli-version";
565
+ function formatUpdateRequired(body) {
566
+ const action = body.action ?? "npm i -D @genex-ai/cli-demo@latest";
567
+ const message = body.message ?? `Genex CLI ${body.clientVersion ?? getCliVersion()} is below the minimum supported version${body.minVersion ? ` ${body.minVersion}` : ""}.`;
568
+ return [`${c.red("\u2717")} ${message}`, ` Update now \u2014 run: ${action} (then re-run this command)`];
569
+ }
570
+ async function apiFetch(url, init = {}) {
571
+ const headers = new Headers(init.headers);
572
+ if (!headers.has(CLI_VERSION_HEADER)) headers.set(CLI_VERSION_HEADER, getCliVersion());
573
+ const res = await fetch(url, { ...init, headers });
574
+ if (res.status === 426) {
575
+ try {
576
+ const body = await res.clone().json();
577
+ if (body?.error === "cli_update_required") {
578
+ for (const line of formatUpdateRequired(body)) process.stderr.write(line + "\n");
579
+ }
580
+ } catch {
581
+ }
582
+ }
583
+ return res;
584
+ }
585
+
391
586
  // src/lib/project.ts
392
587
  async function createDraftProject(opts) {
393
- const { apiUrl, token, deployKey, colyseusUrl, dashboardUrl, log } = opts;
588
+ const { apiUrl, token, colyseusUrl, dashboardUrl, log } = opts;
394
589
  log.step("Creating your project\u2026");
395
590
  const names = [opts.name, `${opts.name}-${randomSuffix()}`];
396
591
  for (let i = 0; i < names.length; i++) {
397
592
  const name = names[i];
398
593
  let res;
399
594
  try {
400
- res = await fetch(`${apiUrl}/api/projects`, {
595
+ res = await apiFetch(`${apiUrl}/api/projects`, {
401
596
  method: "POST",
402
597
  headers: {
403
598
  "Content-Type": "application/json",
404
599
  Authorization: `Bearer ${token}`
405
600
  },
406
- body: JSON.stringify({ name, deployKey })
601
+ body: JSON.stringify({ name })
407
602
  });
408
603
  } catch (err) {
409
604
  log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
@@ -415,17 +610,13 @@ async function createDraftProject(opts) {
415
610
  log.warn("Not authorized to create the project (token rejected).");
416
611
  return null;
417
612
  }
418
- if (res.status === 400) {
419
- log.warn("The API rejected the deploy key (must be an OpenSSH public key).");
420
- return null;
421
- }
422
613
  if (!res.ok) {
423
614
  log.warn(`Couldn't create the project (HTTP ${res.status}).`);
424
615
  return null;
425
616
  }
426
617
  const data = await res.json().catch(() => null);
427
618
  const project = data?.project;
428
- if (!project || !data?.sshUrl) {
619
+ if (!project || !project.cloneUrl) {
429
620
  log.warn("Project created, but the API response was unexpected.");
430
621
  return null;
431
622
  }
@@ -435,7 +626,7 @@ async function createDraftProject(opts) {
435
626
  return {
436
627
  id: project.id,
437
628
  slug: project.slug,
438
- sshUrl: data.sshUrl,
629
+ cloneUrl: project.cloneUrl,
439
630
  apiUrl,
440
631
  colyseusUrl,
441
632
  playUrl: project.playUrl ?? void 0,
@@ -453,90 +644,39 @@ function randomSuffix() {
453
644
  }
454
645
 
455
646
  // src/lib/ssh.ts
456
- import fs3 from "fs/promises";
457
- import path3 from "path";
458
- import { spawn as spawn2 } from "child_process";
459
- var KEY_NAME = "genex_key";
460
- async function generateSshKeypair(dir, log) {
461
- const keyPath = path3.join(dir, KEY_NAME);
462
- const pubPath = `${keyPath}.pub`;
463
- try {
464
- const existing = (await fs3.readFile(pubPath, "utf8")).trim();
465
- if (existing) {
466
- log.dim(`Reusing existing deploy key (${KEY_NAME}).`);
467
- return { publicKey: existing };
468
- }
469
- } catch {
470
- }
471
- log.step("Generating a deploy key\u2026");
472
- const ok = await runSshKeygen(keyPath, log);
473
- if (!ok) return null;
474
- try {
475
- const pub = (await fs3.readFile(pubPath, "utf8")).trim();
476
- if (!pub) {
477
- log.warn("ssh-keygen produced no public key.");
478
- return null;
479
- }
480
- await fs3.chmod(keyPath, 384).catch(() => {
481
- });
482
- return { publicKey: pub };
483
- } catch (err) {
484
- log.warn(`Couldn't read the generated public key: ${String(err)}`);
485
- return null;
486
- }
487
- }
488
- function runSshKeygen(keyPath, log) {
489
- return new Promise((resolve) => {
490
- let child;
491
- try {
492
- child = spawn2(
493
- "ssh-keygen",
494
- ["-t", "ed25519", "-f", keyPath, "-N", "", "-C", "genex-agent"],
495
- { stdio: "ignore" }
496
- );
497
- } catch {
498
- log.warn("ssh-keygen not found \u2014 install OpenSSH (ssh-keygen) and re-run.");
499
- resolve(false);
500
- return;
501
- }
502
- child.on("error", () => {
503
- log.warn("ssh-keygen not found \u2014 install OpenSSH (ssh-keygen) and re-run.");
504
- resolve(false);
505
- });
506
- child.on("close", (code2) => resolve(code2 === 0));
507
- });
508
- }
647
+ import fs4 from "fs/promises";
648
+ import path4 from "path";
509
649
  async function writeGitignore(dir, log) {
510
- const file = path3.join(dir, ".gitignore");
650
+ const file = path4.join(dir, ".gitignore");
511
651
  let content = "";
512
652
  try {
513
- content = await fs3.readFile(file, "utf8");
653
+ content = await fs4.readFile(file, "utf8");
514
654
  } catch {
515
655
  }
516
656
  const present = new Set(content.split("\n").map((l) => l.trim()));
517
- 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));
518
658
  if (toAdd.length === 0) return;
519
659
  let next = content;
520
660
  if (next.length > 0 && !next.endsWith("\n")) next += "\n";
521
- 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";
522
662
  next += toAdd.join("\n") + "\n";
523
- await fs3.writeFile(file, next);
663
+ await fs4.writeFile(file, next);
524
664
  log.dim(`Updated .gitignore (${toAdd.join(", ")}).`);
525
665
  }
526
666
 
527
667
  // src/lib/store.ts
528
- import fs5 from "fs/promises";
529
- import path5 from "path";
668
+ import fs6 from "fs/promises";
669
+ import path6 from "path";
530
670
 
531
671
  // src/lib/env.ts
532
- import fs4 from "fs/promises";
533
- import path4 from "path";
534
- import { spawn as spawn3 } from "child_process";
672
+ import fs5 from "fs/promises";
673
+ import path5 from "path";
674
+ import { spawn as spawn2 } from "child_process";
535
675
  async function writeEnvVar(envPath, key, value) {
536
676
  let content = "";
537
677
  let existed = false;
538
678
  try {
539
- content = await fs4.readFile(envPath, "utf8");
679
+ content = await fs5.readFile(envPath, "utf8");
540
680
  existed = true;
541
681
  } catch {
542
682
  }
@@ -556,14 +696,14 @@ async function writeEnvVar(envPath, key, value) {
556
696
  next = prefix + assignment + "\n";
557
697
  mode = existed ? "appended" : "created";
558
698
  }
559
- await fs4.mkdir(path4.dirname(envPath), { recursive: true });
560
- await fs4.writeFile(envPath, next, { mode: 384 });
699
+ await fs5.mkdir(path5.dirname(envPath), { recursive: true });
700
+ await fs5.writeFile(envPath, next, { mode: 384 });
561
701
  await restrictFilePermissions(envPath);
562
702
  return { mode, path: envPath };
563
703
  }
564
704
  async function restrictFilePermissions(filePath) {
565
705
  if (process.platform !== "win32") {
566
- await fs4.chmod(filePath, 384).catch(() => {
706
+ await fs5.chmod(filePath, 384).catch(() => {
567
707
  });
568
708
  return;
569
709
  }
@@ -571,7 +711,7 @@ async function restrictFilePermissions(filePath) {
571
711
  if (!user) return;
572
712
  await new Promise((resolve) => {
573
713
  try {
574
- const child = spawn3(
714
+ const child = spawn2(
575
715
  "icacls",
576
716
  [filePath, "/inheritance:r", "/grant:r", `${user}:F`],
577
717
  { stdio: "ignore" }
@@ -595,7 +735,7 @@ function escapeRegExp(s) {
595
735
 
596
736
  // src/lib/store.ts
597
737
  function getProjectMetadataPath(cwd = process.cwd()) {
598
- return path5.join(cwd, ".genex", "project.json");
738
+ return path6.join(cwd, ".genex", "project.json");
599
739
  }
600
740
  async function writeUserToken(token, envPath) {
601
741
  const { path: written } = await writeEnvVar(getGenexEnvPath(envPath), ENV_TOKEN_KEY, token);
@@ -605,14 +745,14 @@ async function readUserToken(envPath) {
605
745
  const fromGenex = await readTokenFromFile(getGenexEnvPath(envPath));
606
746
  if (fromGenex) return fromGenex;
607
747
  if (!envPath) {
608
- return readTokenFromFile(path5.join(process.cwd(), ".env"));
748
+ return readTokenFromFile(path6.join(process.cwd(), ".env"));
609
749
  }
610
750
  return null;
611
751
  }
612
752
  async function readTokenFromFile(file) {
613
753
  let content;
614
754
  try {
615
- content = await fs5.readFile(file, "utf8");
755
+ content = await fs6.readFile(file, "utf8");
616
756
  } catch {
617
757
  return null;
618
758
  }
@@ -628,7 +768,7 @@ function stripQuotes(v) {
628
768
  }
629
769
  async function readProject(cwd = process.cwd()) {
630
770
  try {
631
- const raw = await fs5.readFile(getProjectMetadataPath(cwd), "utf8");
771
+ const raw = await fs6.readFile(getProjectMetadataPath(cwd), "utf8");
632
772
  return JSON.parse(raw);
633
773
  } catch {
634
774
  return null;
@@ -636,16 +776,16 @@ async function readProject(cwd = process.cwd()) {
636
776
  }
637
777
  async function writeProject(meta, cwd = process.cwd()) {
638
778
  const file = getProjectMetadataPath(cwd);
639
- await fs5.mkdir(path5.dirname(file), { recursive: true });
640
- await fs5.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
641
- await fs5.chmod(file, 384).catch(() => {
779
+ await fs6.mkdir(path6.dirname(file), { recursive: true });
780
+ await fs6.writeFile(file, JSON.stringify(meta, null, 2) + "\n", { mode: 384 });
781
+ await fs6.chmod(file, 384).catch(() => {
642
782
  });
643
783
  return { path: file };
644
784
  }
645
785
 
646
786
  // src/lib/game-config.ts
647
- import fs6 from "fs/promises";
648
- import path6 from "path";
787
+ import fs7 from "fs/promises";
788
+ import path7 from "path";
649
789
  var DEFAULT_DASHBOARD_ORIGIN = new URL(DEFAULT_AUTH_URL).origin;
650
790
  function renderGenexConfig() {
651
791
  return `// src/genex.config.ts \u2014 written by \`genex init\`. DO NOT hardcode URLs here.
@@ -686,7 +826,7 @@ ${overrides.join("\n")}
686
826
  }
687
827
  async function writeIfAbsent(file, content, log) {
688
828
  try {
689
- await fs6.writeFile(file, content, { flag: "wx" });
829
+ await fs7.writeFile(file, content, { flag: "wx" });
690
830
  log.dim(` wrote ${c.cyan(file)}`);
691
831
  return true;
692
832
  } catch (err) {
@@ -698,12 +838,12 @@ async function writeIfAbsent(file, content, log) {
698
838
  }
699
839
  }
700
840
  async function writeGameConfigFiles(meta, log, cwd = process.cwd()) {
701
- await fs6.mkdir(path6.join(cwd, "src"), { recursive: true });
702
- await writeIfAbsent(path6.join(cwd, "src", "genex.config.ts"), renderGenexConfig(), log);
703
- await writeIfAbsent(path6.join(cwd, ".env"), renderSlugEnv(meta.slug), log);
841
+ await fs7.mkdir(path7.join(cwd, "src"), { recursive: true });
842
+ await writeIfAbsent(path7.join(cwd, "src", "genex.config.ts"), renderGenexConfig(), log);
843
+ await writeIfAbsent(path7.join(cwd, ".env"), renderSlugEnv(meta.slug), log);
704
844
  const overrides = renderDevOverrides(meta);
705
845
  if (overrides) {
706
- await writeIfAbsent(path6.join(cwd, ".env.development.local"), overrides, log);
846
+ await writeIfAbsent(path7.join(cwd, ".env.development.local"), overrides, log);
707
847
  }
708
848
  }
709
849
 
@@ -739,12 +879,13 @@ async function runInit(opts) {
739
879
  let totalNew = 0;
740
880
  let totalUpdated = 0;
741
881
  for (const t of targets) {
742
- const src = t.full ? templatesDir : path7.join(templatesDir, "skills");
743
- const dest = t.full ? t.baseDir : path7.join(t.baseDir, "skills");
882
+ const src = t.full ? templatesDir : path8.join(templatesDir, "skills");
883
+ const dest = t.full ? t.baseDir : path8.join(t.baseDir, "skills");
744
884
  const { copied, updated } = await copyTemplates(src, dest, {
745
885
  force: opts.force,
746
886
  exclude: ["controllers"]
747
887
  });
888
+ await writeSkillsMarker(path8.join(t.baseDir, "skills"));
748
889
  const added = copied.length - updated.length;
749
890
  totalNew += added;
750
891
  totalUpdated += updated.length;
@@ -775,24 +916,14 @@ async function runInit(opts) {
775
916
  const { path: tokenPath } = await writeUserToken(token, opts.envPath);
776
917
  log.success(`Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
777
918
  log.plain("");
778
- const key = await generateSshKeypair(process.cwd(), log);
779
919
  await writeGitignore(process.cwd(), log);
780
- if (!key) {
781
- log.warn(
782
- "Skipping project creation \u2014 no deploy key. Install ssh-keygen (OpenSSH) and re-run `genex init`."
783
- );
784
- log.plain("");
785
- log.success("Workspace ready (no project created yet).");
786
- return;
787
- }
788
920
  const apiUrl = getApiUrl(opts.apiUrl);
789
921
  const colyseusUrl = getColyseusUrl(opts.colyseusUrl);
790
- const projectName = opts.name?.trim() || path7.basename(process.cwd());
922
+ const projectName = opts.name?.trim() || path8.basename(process.cwd());
791
923
  const meta = await createDraftProject({
792
924
  apiUrl,
793
925
  token,
794
926
  name: projectName,
795
- deployKey: key.publicKey,
796
927
  colyseusUrl,
797
928
  dashboardUrl: authBaseUrl,
798
929
  log
@@ -807,8 +938,8 @@ async function runInit(opts) {
807
938
  }
808
939
 
809
940
  // src/commands/link.ts
810
- import fs7 from "fs/promises";
811
- import path8 from "path";
941
+ import fs8 from "fs/promises";
942
+ import path9 from "path";
812
943
  async function runLink(opts) {
813
944
  const log = createLogger({ quiet: opts.quiet });
814
945
  log.plain(c.bold("genex link"));
@@ -845,23 +976,11 @@ async function runLink(opts) {
845
976
  process.exitCode = 1;
846
977
  return;
847
978
  }
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
979
  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
980
  const meta = {
862
981
  id: project.id,
863
982
  slug: project.slug,
864
- sshUrl: linked.sshUrl,
983
+ cloneUrl: project.cloneUrl ?? "",
865
984
  apiUrl,
866
985
  colyseusUrl: getColyseusUrl(opts.colyseusUrl),
867
986
  playUrl: project.playUrl ?? void 0,
@@ -879,29 +998,29 @@ async function runLink(opts) {
879
998
  if (project.playUrl) log.dim(` play URL: ${project.playUrl}`);
880
999
  }
881
1000
  async function ensureSlugEnv(slug, log, cwd = process.cwd()) {
882
- const file = path8.join(cwd, ".env");
1001
+ const file = path9.join(cwd, ".env");
883
1002
  let content;
884
1003
  try {
885
- content = await fs7.readFile(file, "utf8");
1004
+ content = await fs8.readFile(file, "utf8");
886
1005
  } catch {
887
1006
  return;
888
1007
  }
889
1008
  const re = /^(\s*VITE_GENEX_SLUG=)(.*)$/m;
890
1009
  const m = content.match(re);
891
1010
  if (!m) {
892
- await fs7.appendFile(file, `${content.endsWith("\n") ? "" : "\n"}VITE_GENEX_SLUG=${slug}
1011
+ await fs8.appendFile(file, `${content.endsWith("\n") ? "" : "\n"}VITE_GENEX_SLUG=${slug}
893
1012
  `);
894
1013
  log.dim(` added VITE_GENEX_SLUG=${slug} to .env`);
895
1014
  return;
896
1015
  }
897
1016
  if (m[2].trim() === slug) return;
898
- await fs7.writeFile(file, content.replace(re, `$1${slug}`));
1017
+ await fs8.writeFile(file, content.replace(re, `$1${slug}`));
899
1018
  log.dim(` updated .env: VITE_GENEX_SLUG=${slug} (was ${m[2].trim()})`);
900
1019
  }
901
1020
  async function fetchOwnProject(apiUrl, token, slug, log) {
902
1021
  let res;
903
1022
  try {
904
- res = await fetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
1023
+ res = await apiFetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
905
1024
  headers: { Authorization: `Bearer ${token}` }
906
1025
  });
907
1026
  } catch (err) {
@@ -933,7 +1052,7 @@ async function fetchOwnProject(apiUrl, token, slug, log) {
933
1052
  }
934
1053
  async function listOwnSlugs(apiUrl, token, log) {
935
1054
  try {
936
- const res = await fetch(`${apiUrl}/api/projects`, {
1055
+ const res = await apiFetch(`${apiUrl}/api/projects`, {
937
1056
  headers: { Authorization: `Bearer ${token}` }
938
1057
  });
939
1058
  if (!res.ok) return;
@@ -947,49 +1066,18 @@ async function listOwnSlugs(apiUrl, token, log) {
947
1066
  } catch {
948
1067
  }
949
1068
  }
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
- }
981
1069
 
982
1070
  // src/lib/deploy.ts
983
- import { spawn as spawn4 } from "child_process";
1071
+ import { spawn as spawn3 } from "child_process";
984
1072
  import crypto3 from "crypto";
985
- import fs8 from "fs/promises";
1073
+ import fs9 from "fs/promises";
986
1074
  import os2 from "os";
987
- import path9 from "path";
1075
+ import path10 from "path";
988
1076
  function run(cmd, args, env) {
989
1077
  return new Promise((resolve) => {
990
1078
  let child;
991
1079
  try {
992
- child = spawn4(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
1080
+ child = spawn3(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
993
1081
  } catch {
994
1082
  resolve({ code: -1, out: "", err: `${cmd} not found` });
995
1083
  return;
@@ -1003,6 +1091,9 @@ function run(cmd, args, env) {
1003
1091
  });
1004
1092
  }
1005
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
+ }
1006
1097
  async function deployGame(ctx, opts, log) {
1007
1098
  const cwd = process.cwd();
1008
1099
  if (!opts.noBuild && await hasBuildScript(cwd)) {
@@ -1016,15 +1107,11 @@ async function deployGame(ctx, opts, log) {
1016
1107
  }
1017
1108
  log.success("Built.");
1018
1109
  }
1019
- const distDir = path9.join(cwd, "dist");
1110
+ const distDir = path10.join(cwd, "dist");
1020
1111
  const siteDir = await isDir2(distDir) ? distDir : cwd;
1021
- const rel = path9.relative(cwd, siteDir) || ".";
1112
+ const rel = path10.relative(cwd, siteDir) || ".";
1022
1113
  if (siteDir === cwd) await writeGitignore(cwd, log);
1023
1114
  const files = await collectFiles(siteDir);
1024
- if (files.some((f) => /(^|\/)genex_key(\.pub)?$/.test(f.relPath))) {
1025
- log.error(`Refusing to deploy: ${KEY_NAME} is in ${rel}. Add it to .gitignore and retry.`);
1026
- return false;
1027
- }
1028
1115
  if (files.length === 0) {
1029
1116
  log.warn("Nothing to deploy \u2014 the build produced no files.");
1030
1117
  return false;
@@ -1057,12 +1144,7 @@ async function deployGame(ctx, opts, log) {
1057
1144
  log.error("Couldn't upload your game \u2014 please try again.");
1058
1145
  return false;
1059
1146
  }
1060
- const keyPath = path9.resolve(cwd, KEY_NAME);
1061
- if (!await fileExists(keyPath)) {
1062
- log.error(`No deploy key (${KEY_NAME}) here \u2014 run \`genex init\` in this folder first.`);
1063
- return false;
1064
- }
1065
- if (!await pushSource(cwd, ctx.sshUrl, keyPath, log)) return false;
1147
+ if (!await pushSource(cwd, ctx, log)) return false;
1066
1148
  log.step("Publishing\u2026");
1067
1149
  if (!await callPublish(ctx, commit, opts, log)) return false;
1068
1150
  const index = files.find((f) => f.relPath === "index.html");
@@ -1071,7 +1153,7 @@ async function deployGame(ctx, opts, log) {
1071
1153
  }
1072
1154
  async function hasBuildScript(cwd) {
1073
1155
  try {
1074
- const pkg = JSON.parse(await fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
1156
+ const pkg = JSON.parse(await fs9.readFile(path10.join(cwd, "package.json"), "utf8"));
1075
1157
  return Boolean(pkg.scripts?.build);
1076
1158
  } catch {
1077
1159
  return false;
@@ -1080,12 +1162,12 @@ async function hasBuildScript(cwd) {
1080
1162
  async function collectFiles(root) {
1081
1163
  const out = [];
1082
1164
  const walk2 = async (dir, prefix) => {
1083
- for (const e of await fs8.readdir(dir, { withFileTypes: true })) {
1165
+ for (const e of await fs9.readdir(dir, { withFileTypes: true })) {
1084
1166
  const relPath = prefix ? `${prefix}/${e.name}` : e.name;
1085
1167
  if (e.isDirectory()) {
1086
- if (!EXCLUDE_DIRS.has(e.name)) await walk2(path9.join(dir, e.name), relPath);
1087
- } else if (e.isFile() && e.name !== KEY_NAME && e.name !== `${KEY_NAME}.pub`) {
1088
- out.push({ relPath, bytes: await fs8.readFile(path9.join(dir, e.name)) });
1168
+ if (!EXCLUDE_DIRS.has(e.name)) await walk2(path10.join(dir, e.name), relPath);
1169
+ } else if (e.isFile() && !isSecretEnvFile(e.name)) {
1170
+ out.push({ relPath, bytes: await fs9.readFile(path10.join(dir, e.name)) });
1089
1171
  }
1090
1172
  }
1091
1173
  };
@@ -1104,7 +1186,7 @@ function contentCommit(files) {
1104
1186
  async function getUploadToken(ctx, commit, log) {
1105
1187
  let res;
1106
1188
  try {
1107
- res = await fetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
1189
+ res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
1108
1190
  method: "POST",
1109
1191
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
1110
1192
  body: JSON.stringify({ commit })
@@ -1151,7 +1233,7 @@ async function uploadAll(uploadUrl, uploadToken, files, log) {
1151
1233
  async function callPublish(ctx, commit, opts, log) {
1152
1234
  let res;
1153
1235
  try {
1154
- res = await fetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
1236
+ res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
1155
1237
  method: "POST",
1156
1238
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
1157
1239
  // Detections ride every go-live (preview AND publish): matchmaking is
@@ -1175,13 +1257,15 @@ async function callPublish(ctx, commit, opts, log) {
1175
1257
  }
1176
1258
  return true;
1177
1259
  }
1178
- async function pushSource(cwd, sshUrl, keyPath, log) {
1260
+ async function pushSource(cwd, ctx, log) {
1179
1261
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1180
1262
  const failed = () => {
1181
1263
  log.error("Couldn't save your game's source \u2014 please try again.");
1182
1264
  return false;
1183
1265
  };
1184
- const gitDir = await fs8.mkdtemp(path9.join(os2.tmpdir(), "genex-source-"));
1266
+ const pushUrl = await fetchPushUrl(ctx, log);
1267
+ if (!pushUrl) return false;
1268
+ const gitDir = await fs9.mkdtemp(path10.join(os2.tmpdir(), "genex-source-"));
1185
1269
  const base = { GIT_DIR: gitDir };
1186
1270
  const ident = {
1187
1271
  GIT_AUTHOR_NAME: "genex",
@@ -1191,49 +1275,58 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
1191
1275
  };
1192
1276
  try {
1193
1277
  if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
1194
- await fs8.writeFile(
1195
- path9.join(gitDir, "info", "exclude"),
1196
- ["node_modules/", "dist/", ".git/", KEY_NAME, `${KEY_NAME}.pub`, ".genex/", ""].join("\n")
1278
+ await fs9.writeFile(
1279
+ path10.join(gitDir, "info", "exclude"),
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")
1197
1282
  );
1198
- const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path9.join(gitDir, "index-source") };
1283
+ const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
1199
1284
  await run("git", ["add", "-A"], env);
1200
- const tracked = await run("git", ["ls-files"], env);
1201
- if (/(^|\/)genex_key(\.pub)?$/m.test(tracked.out)) {
1202
- log.error(`Refusing to publish: ${KEY_NAME} is in your project. Add it to .gitignore and retry.`);
1203
- return false;
1204
- }
1205
- 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() : "";
1206
1286
  if (!tree || tree === EMPTY_TREE) {
1207
1287
  log.error("Nothing to publish \u2014 the project has no files.");
1208
1288
  return false;
1209
1289
  }
1210
1290
  const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
1211
1291
  await run("git", ["update-ref", "refs/heads/main", commit], base);
1212
- const push = await run("git", ["push", "-q", sshUrl, "+refs/heads/main:main"], {
1213
- ...base,
1214
- // Quote the key path: git splits GIT_SSH_COMMAND shell-like, so a folder with a
1215
- // space would otherwise break the `-i` argument.
1216
- GIT_SSH_COMMAND: `ssh -i "${keyPath}" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`
1217
- });
1292
+ const push = await run("git", ["push", "-q", pushUrl, "+refs/heads/main:main"], base);
1218
1293
  return push.code === 0 ? true : failed();
1219
1294
  } catch {
1220
1295
  return failed();
1221
1296
  } finally {
1222
- await fs8.rm(gitDir, { recursive: true, force: true }).catch(() => {
1297
+ await fs9.rm(gitDir, { recursive: true, force: true }).catch(() => {
1223
1298
  });
1224
1299
  }
1225
1300
  }
1226
- async function isDir2(p) {
1301
+ async function fetchPushUrl(ctx, log) {
1302
+ let res;
1227
1303
  try {
1228
- return (await fs8.stat(p)).isDirectory();
1229
- } catch {
1230
- 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;
1231
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;
1232
1326
  }
1233
- async function fileExists(p) {
1327
+ async function isDir2(p) {
1234
1328
  try {
1235
- await fs8.access(p);
1236
- return true;
1329
+ return (await fs9.stat(p)).isDirectory();
1237
1330
  } catch {
1238
1331
  return false;
1239
1332
  }
@@ -1266,11 +1359,11 @@ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
1266
1359
  }
1267
1360
 
1268
1361
  // src/lib/detect-features.ts
1269
- import fs9 from "fs/promises";
1270
- import path10 from "path";
1362
+ import fs10 from "fs/promises";
1363
+ import path11 from "path";
1271
1364
  async function detectEmbedSdkVersion(cwd = process.cwd()) {
1272
1365
  try {
1273
- const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
1366
+ const raw = await fs10.readFile(path11.join(cwd, "package.json"), "utf8");
1274
1367
  const pkg = JSON.parse(raw);
1275
1368
  const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
1276
1369
  return typeof version === "string" && version ? version : null;
@@ -1280,7 +1373,7 @@ async function detectEmbedSdkVersion(cwd = process.cwd()) {
1280
1373
  }
1281
1374
  async function detectMultiplayer(cwd = process.cwd()) {
1282
1375
  try {
1283
- const raw = await fs9.readFile(path10.join(cwd, "package.json"), "utf8");
1376
+ const raw = await fs10.readFile(path11.join(cwd, "package.json"), "utf8");
1284
1377
  const pkg = JSON.parse(raw);
1285
1378
  return Boolean(
1286
1379
  pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
@@ -1292,7 +1385,7 @@ async function detectMultiplayer(cwd = process.cwd()) {
1292
1385
  async function detectMatchmaking(log, cwd = process.cwd()) {
1293
1386
  let pkg;
1294
1387
  try {
1295
- pkg = JSON.parse(await fs9.readFile(path10.join(cwd, "package.json"), "utf8"));
1388
+ pkg = JSON.parse(await fs10.readFile(path11.join(cwd, "package.json"), "utf8"));
1296
1389
  } catch (err) {
1297
1390
  log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
1298
1391
  return null;
@@ -1309,10 +1402,10 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
1309
1402
  }
1310
1403
  var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
1311
1404
  async function detectGameStateUsage(cwd = process.cwd()) {
1312
- const srcDir = path10.join(cwd, "src");
1405
+ const srcDir = path11.join(cwd, "src");
1313
1406
  let entries;
1314
1407
  try {
1315
- entries = await fs9.readdir(srcDir, { recursive: true });
1408
+ entries = await fs10.readdir(srcDir, { recursive: true });
1316
1409
  } catch {
1317
1410
  return false;
1318
1411
  }
@@ -1320,7 +1413,7 @@ async function detectGameStateUsage(cwd = process.cwd()) {
1320
1413
  if (rel.includes("node_modules")) continue;
1321
1414
  if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
1322
1415
  try {
1323
- const content = await fs9.readFile(path10.join(srcDir, rel), "utf8");
1416
+ const content = await fs10.readFile(path11.join(srcDir, rel), "utf8");
1324
1417
  if (GAME_STATE_CALLS.test(content)) return true;
1325
1418
  } catch {
1326
1419
  }
@@ -1375,7 +1468,7 @@ async function runPublish(opts) {
1375
1468
  advisoryNudges(log, detections);
1376
1469
  if (!opts.noPush) {
1377
1470
  const ok = await deployGame(
1378
- { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1471
+ { projectId: meta.id, apiUrl, token },
1379
1472
  {
1380
1473
  noBuild: opts.noBuild,
1381
1474
  matchmaking: detections.matchmaking,
@@ -1406,7 +1499,7 @@ async function runPublish(opts) {
1406
1499
  body.multiplayer = detections.multiplayer;
1407
1500
  body.matchmaking = detections.matchmaking ?? null;
1408
1501
  body.gameStateUsed = detections.gameStateUsed;
1409
- res = await fetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
1502
+ res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
1410
1503
  method: "POST",
1411
1504
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
1412
1505
  body: JSON.stringify(body)
@@ -1447,7 +1540,7 @@ async function runPreview(opts) {
1447
1540
  advisoryNudges(log, detections);
1448
1541
  const apiUrl = getApiUrl(meta.apiUrl);
1449
1542
  const ok = await deployGame(
1450
- { projectId: meta.id, sshUrl: meta.sshUrl, apiUrl, token },
1543
+ { projectId: meta.id, apiUrl, token },
1451
1544
  // Detections reach the server on every preview too: matchmaking so a draft's
1452
1545
  // declared preset doesn't silently run the default (null clears a removed
1453
1546
  // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
@@ -1533,7 +1626,7 @@ async function runGenerate(kind, opts) {
1533
1626
  log.plain("");
1534
1627
  let id;
1535
1628
  try {
1536
- const res = await fetch(`${apiUrl}/api/generations`, {
1629
+ const res = await apiFetch(`${apiUrl}/api/generations`, {
1537
1630
  method: "POST",
1538
1631
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
1539
1632
  body: JSON.stringify({ kind, prompt, options })
@@ -1599,7 +1692,7 @@ async function waitViaSSE(apiUrl, token, id, onProgress, deadline) {
1599
1692
  try {
1600
1693
  let res;
1601
1694
  try {
1602
- res = await fetch(`${apiUrl}/api/generations/${id}/events`, {
1695
+ res = await apiFetch(`${apiUrl}/api/generations/${id}/events`, {
1603
1696
  headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
1604
1697
  signal: abort.signal
1605
1698
  });
@@ -1646,7 +1739,7 @@ async function poll(apiUrl, token, id, onProgress) {
1646
1739
  let last = -1;
1647
1740
  while (Date.now() < deadline) {
1648
1741
  try {
1649
- const res = await fetch(`${apiUrl}/api/generations/${id}`, {
1742
+ const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
1650
1743
  headers: { Authorization: `Bearer ${token}` }
1651
1744
  });
1652
1745
  if (res.ok) {
@@ -1678,8 +1771,8 @@ function printHint(kind, files, log) {
1678
1771
  }
1679
1772
 
1680
1773
  // src/commands/controller.ts
1681
- import fs10 from "fs/promises";
1682
- import path11 from "path";
1774
+ import fs11 from "fs/promises";
1775
+ import path12 from "path";
1683
1776
  var CONTROLLER_KINDS = ["character", "car", "drone"];
1684
1777
  var SHARED = [
1685
1778
  "shared/math.ts",
@@ -1758,8 +1851,8 @@ var CONTROLLER_FILE_SETS = {
1758
1851
  ]
1759
1852
  }
1760
1853
  };
1761
- var CODE_DEST = path11.join("src", "controllers");
1762
- var ASSETS_DEST = path11.join("public", "assets");
1854
+ var CODE_DEST = path12.join("src", "controllers");
1855
+ var ASSETS_DEST = path12.join("public", "assets");
1763
1856
  async function runController(opts) {
1764
1857
  const log = createLogger({ quiet: opts.quiet });
1765
1858
  const kind = opts.kind?.trim();
@@ -1772,31 +1865,31 @@ async function runController(opts) {
1772
1865
  process.exitCode = 1;
1773
1866
  return;
1774
1867
  }
1775
- const srcDir = path11.join(getTemplatesDir(), "controllers");
1868
+ const srcDir = path12.join(getTemplatesDir(), "controllers");
1776
1869
  const root = opts.cwd ?? process.cwd();
1777
1870
  const set = CONTROLLER_FILE_SETS[kind];
1778
1871
  log.plain(c.bold(`genex controller ${kind}`));
1779
1872
  log.plain("");
1780
- log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path11.sep)}`);
1873
+ log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path12.sep)}`);
1781
1874
  const plan = [
1782
- ...set.code.map((rel) => ({ from: rel, rel: path11.join(CODE_DEST, rel) })),
1875
+ ...set.code.map((rel) => ({ from: rel, rel: path12.join(CODE_DEST, rel) })),
1783
1876
  ...set.assets.map((rel) => ({
1784
1877
  from: rel,
1785
- rel: path11.join(ASSETS_DEST, path11.basename(rel))
1878
+ rel: path12.join(ASSETS_DEST, path12.basename(rel))
1786
1879
  }))
1787
1880
  ];
1788
1881
  let copied = 0;
1789
1882
  let skipped = 0;
1790
1883
  try {
1791
1884
  for (const file of plan) {
1792
- const dest = path11.join(root, file.rel);
1885
+ const dest = path12.join(root, file.rel);
1793
1886
  if (!opts.force && await exists2(dest)) {
1794
1887
  skipped++;
1795
1888
  log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
1796
1889
  continue;
1797
1890
  }
1798
- await fs10.mkdir(path11.dirname(dest), { recursive: true });
1799
- await fs10.copyFile(path11.join(srcDir, file.from), dest);
1891
+ await fs11.mkdir(path12.dirname(dest), { recursive: true });
1892
+ await fs11.copyFile(path12.join(srcDir, file.from), dest);
1800
1893
  copied++;
1801
1894
  log.dim(` ${file.rel}`);
1802
1895
  }
@@ -1828,11 +1921,11 @@ async function runController(opts) {
1828
1921
  }
1829
1922
  async function installOwnerAvatar(args) {
1830
1923
  const { root, srcDir, apiUrl, token, log } = args;
1831
- const dest = path11.join(root, ASSETS_DEST, "avatar.vrm");
1832
- await fs10.mkdir(path11.dirname(dest), { recursive: true });
1924
+ const dest = path12.join(root, ASSETS_DEST, "avatar.vrm");
1925
+ await fs11.mkdir(path12.dirname(dest), { recursive: true });
1833
1926
  if (token) {
1834
1927
  try {
1835
- const res = await fetch(`${apiUrl}/api/avatars/me`, {
1928
+ const res = await apiFetch(`${apiUrl}/api/avatars/me`, {
1836
1929
  headers: { Authorization: `Bearer ${token}` }
1837
1930
  });
1838
1931
  if (res.ok) {
@@ -1841,7 +1934,7 @@ async function installOwnerAvatar(args) {
1841
1934
  const vrmRes = await fetch(me.vrmUrl);
1842
1935
  if (vrmRes.ok) {
1843
1936
  const buf = Buffer.from(await vrmRes.arrayBuffer());
1844
- await fs10.writeFile(dest, buf);
1937
+ await fs11.writeFile(dest, buf);
1845
1938
  log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
1846
1939
  return;
1847
1940
  }
@@ -1852,14 +1945,14 @@ async function installOwnerAvatar(args) {
1852
1945
  log.dim(" avatar fetch failed (offline?); using the bundled default.");
1853
1946
  }
1854
1947
  }
1855
- await fs10.copyFile(path11.join(srcDir, "assets", "default-avatar.vrm"), dest);
1948
+ await fs11.copyFile(path12.join(srcDir, "assets", "default-avatar.vrm"), dest);
1856
1949
  log.dim(
1857
1950
  token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
1858
1951
  );
1859
1952
  }
1860
1953
  async function exists2(p) {
1861
1954
  try {
1862
- await fs10.access(p);
1955
+ await fs11.access(p);
1863
1956
  return true;
1864
1957
  } catch {
1865
1958
  return false;
@@ -1873,7 +1966,7 @@ async function runExplore(opts) {
1873
1966
  const apiUrl = getApiUrl(opts.apiUrl);
1874
1967
  let res;
1875
1968
  try {
1876
- res = await fetch(`${apiUrl}/api/gallery/search-corpus?curated=1`);
1969
+ res = await apiFetch(`${apiUrl}/api/gallery/search-corpus?curated=1`);
1877
1970
  } catch {
1878
1971
  log.error("Couldn't reach Genex \u2014 please try again.");
1879
1972
  process.exitCode = 1;
@@ -1933,17 +2026,6 @@ function rank(items, query) {
1933
2026
 
1934
2027
  // src/index.ts
1935
2028
  var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture"]);
1936
- function getVersion() {
1937
- try {
1938
- const here = path12.dirname(fileURLToPath2(import.meta.url));
1939
- const pkg = JSON.parse(
1940
- readFileSync(path12.resolve(here, "..", "package.json"), "utf8")
1941
- );
1942
- return pkg.version ?? "0.0.0";
1943
- } catch {
1944
- return "0.0.0";
1945
- }
1946
- }
1947
2029
  var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize, and publish 3D games.
1948
2030
 
1949
2031
  ${c.bold("Usage")}
@@ -2206,43 +2288,50 @@ async function main() {
2206
2288
  return;
2207
2289
  }
2208
2290
  if (parsed.version) {
2209
- log.plain(getVersion());
2291
+ log.plain(getCliVersion());
2210
2292
  return;
2211
2293
  }
2212
2294
  if (parsed.help || parsed.command === void 0) {
2213
2295
  log.plain(HELP);
2214
2296
  return;
2215
2297
  }
2216
- if (GEN_KINDS.has(parsed.command)) {
2217
- await runGenerate(parsed.command, {
2218
- ...parsed.options,
2219
- prompt: parsed.options.name
2220
- });
2221
- return;
2222
- }
2223
- switch (parsed.command) {
2224
- case "init":
2225
- await runInit(parsed.options);
2226
- break;
2227
- case "link":
2228
- await runLink(parsed.options);
2229
- break;
2230
- case "controller":
2231
- await runController({ ...parsed.options, kind: parsed.options.name });
2232
- break;
2233
- case "preview":
2234
- await runPreview(parsed.options);
2235
- break;
2236
- case "publish":
2237
- await runPublish(parsed.options);
2238
- break;
2239
- case "explore":
2240
- await runExplore({ ...parsed.options, query: parsed.options.name });
2241
- break;
2242
- default:
2243
- log.error(`Unknown command: ${parsed.command}`);
2244
- log.plain(`Run ${c.cyan("genex --help")} for usage.`);
2245
- process.exitCode = 1;
2298
+ const updateLog = createLogger({ quiet: parsed.options.quiet });
2299
+ if (parsed.command !== "init") await syncSkills(updateLog);
2300
+ const updateCheck = startUpdateCheck();
2301
+ try {
2302
+ if (GEN_KINDS.has(parsed.command)) {
2303
+ await runGenerate(parsed.command, {
2304
+ ...parsed.options,
2305
+ prompt: parsed.options.name
2306
+ });
2307
+ return;
2308
+ }
2309
+ switch (parsed.command) {
2310
+ case "init":
2311
+ await runInit(parsed.options);
2312
+ break;
2313
+ case "link":
2314
+ await runLink(parsed.options);
2315
+ break;
2316
+ case "controller":
2317
+ await runController({ ...parsed.options, kind: parsed.options.name });
2318
+ break;
2319
+ case "preview":
2320
+ await runPreview(parsed.options);
2321
+ break;
2322
+ case "publish":
2323
+ await runPublish(parsed.options);
2324
+ break;
2325
+ case "explore":
2326
+ await runExplore({ ...parsed.options, query: parsed.options.name });
2327
+ break;
2328
+ default:
2329
+ log.error(`Unknown command: ${parsed.command}`);
2330
+ log.plain(`Run ${c.cyan("genex --help")} for usage.`);
2331
+ process.exitCode = 1;
2332
+ }
2333
+ } finally {
2334
+ await reportUpdateNudges(updateCheck, updateLog);
2246
2335
  }
2247
2336
  }
2248
2337
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.24.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": {
@@ -8,9 +8,9 @@ for making 3D games in the browser.
8
8
  Genex is built around agent superpowers for browser games: Three.js skills,
9
9
  one-click publishing, multiplayer-ready architecture, and team workflows.
10
10
 
11
- Files whose names start with `genex-` are managed by Genex: re-running
12
- `genex init` refreshes them to the latest version. Anything you add yourself is
13
- never touched.
11
+ Files whose names start with `genex-` are managed by Genex: every `genex`
12
+ command keeps them in sync with the installed CLI version automatically
13
+ (re-running `genex init` does too). Anything you add yourself is never touched.
14
14
 
15
15
  - `skills/` - reusable Genex skills for 3D game creation.
16
16
  - `agents/` - example subagent definitions.
@@ -13,9 +13,9 @@ architecture, and team-ready workflows.
13
13
 
14
14
  `genex init` installs the Genex skills into **every coding agent it detects** —
15
15
  Claude Code (`~/.claude`), Codex (`~/.codex`), and Cursor (`~/.cursor`) — so the
16
- same skills are available whichever agent you build with. Re-running `init`
17
- always refreshes the genex-owned skills to the latest version (your own files
18
- are never touched).
16
+ same skills are available whichever agent you build with. After that, every
17
+ `genex` command keeps the genex-owned skills in sync with the installed CLI
18
+ automatically (re-running `init` does too); your own files are never touched.
19
19
 
20
20
  - **skills/** - reusable Genex skills for 3D browser-game work (all agents).
21
21
  - **agents/** - example subagent definitions (Claude Code).
@@ -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,12 +104,20 @@ 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`).
112
113
 
114
+ ## Staying up to date
115
+
116
+ Genex commands print an update nudge when a newer package exists
117
+ (`⬆ Genex … available — run: …`). Follow `$genex-updates`: apply the printed
118
+ command at a safe moment (never mid-task), then tell the user what changed.
119
+ Skills need no action — they sync automatically on every `genex` command.
120
+
113
121
  ## Re-running setup
114
122
 
115
123
  Safe to run any time — genex-owned skills are refreshed to the latest version,
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: genex-updates
3
+ description: Apply Genex platform updates safely. Use when a genex command prints an update nudge ("⬆ Genex … available — run: …"), a skills-refresh line ("🔄 Genex skills updated"), or an update-required refusal (HTTP 426, cli_update_required), or when the user asks about updating Genex packages.
4
+ ---
5
+
6
+ # Genex Updates — apply platform updates at safe moments
7
+
8
+ Genex ships improvements continuously. The `genex` CLI tells you when something
9
+ is stale; you (the agent) apply the update at the right moment and tell the
10
+ user what happened. The user never manages versions themselves.
11
+
12
+ ## The three signals in `genex` output
13
+
14
+ | Line | Meaning | What you do |
15
+ | -- | -- | -- |
16
+ | `🔄 Genex skills updated to X.Y.Z` | Skills were auto-refreshed to match the installed CLI. | Nothing — informational. Skills stay in sync on their own. |
17
+ | `⬆ Genex <package> X.Y.Z available (installed A.B.C) — run: <command>` | A newer npm package exists. | Run the printed command at a **safe moment** (below), then tell the user. |
18
+ | `✗ … below the minimum supported version … Update now — run: <command>` | The API refuses this CLI version (HTTP 426, `cli_update_required`). | Run the printed command **now**, then re-run the refused genex command. |
19
+
20
+ ## Safe moments — when to apply a nudge
21
+
22
+ Update when nothing is in flight:
23
+
24
+ - at the start of a session, before you begin the user's task;
25
+ - between tasks, after the current change is finished and working;
26
+ - right before a `genex preview`/`publish` you have **not** started yet.
27
+
28
+ Never mid-task: not during a build or deploy, not halfway through a code
29
+ change, not while the user is waiting for something else. A nudge is a
30
+ suggestion — if now is not a safe moment, finish first; the nudge reappears on
31
+ the next command. (The 426 refusal is the exception: nothing works until you
32
+ update, so update immediately.)
33
+
34
+ ## How to apply
35
+
36
+ Run exactly the command the nudge printed, from the game project root:
37
+
38
+ ```bash
39
+ npm i -D @genex-ai/cli-demo@latest # the genex CLI (a dev dependency)
40
+ npm i @genex-ai/embed-sdk@latest # identity/saves SDK (ships inside the game)
41
+ npm i @genex-ai/multiplayer@latest # multiplayer SDK (only if the game uses it)
42
+ ```
43
+
44
+ Skills refresh automatically on the next `genex` command after a CLI update —
45
+ no extra step. Then tell the user in one short line what changed and why it
46
+ matters, e.g. "I also updated Genex to 0.22 — skills and SDK refreshed." For
47
+ what's new, check the package page: https://www.npmjs.com/package/@genex-ai/cli-demo
48
+
49
+ ## Hard rules
50
+
51
+ - **Never touch game code as part of an update.** Everything in the game's
52
+ `src/` — including vendored controller code in `src/controllers/` — is
53
+ user-owned and may carry customizations. Updates never overwrite it. If a
54
+ newer controller exists you may *mention* that `genex controller <type>
55
+ --force` re-vendors it, but run that only when the user explicitly opts in
56
+ (it overwrites their changes).
57
+ - **Never update silently.** `npm i …@latest` changes `package.json` — that is
58
+ the sanctioned path, but always say in chat that you did it.
59
+ - **Never downgrade**, and never pin to an old version to "fix" an error —
60
+ report the error instead.
61
+ - **Verify after updating**: the game should still build/run. If it does not,
62
+ say exactly what broke rather than quietly reverting.
63
+ - If `npm i` fails (offline, registry down), say so and continue on the
64
+ current version — everything keeps working, and the nudge will reappear.
65
+ - A published game is never affected by any of this: its live bundle is frozen
66
+ and keeps working regardless of local package versions.