@genex-ai/cli-demo 0.25.0 → 0.27.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** —
|
|
40
|
-
(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
`
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
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,
|
|
601
|
+
body: JSON.stringify(opts.repoUrl ? { name, repoUrl: opts.repoUrl } : { 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 || !
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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
|
|
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 =
|
|
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,16 +924,20 @@ async function runInit(opts) {
|
|
|
988
924
|
apiUrl,
|
|
989
925
|
token,
|
|
990
926
|
name: projectName,
|
|
991
|
-
|
|
927
|
+
repoUrl: opts.repo?.trim() || void 0,
|
|
992
928
|
colyseusUrl,
|
|
993
929
|
dashboardUrl: authBaseUrl,
|
|
994
930
|
log
|
|
995
931
|
});
|
|
996
|
-
if (meta) {
|
|
997
|
-
|
|
998
|
-
log.
|
|
999
|
-
|
|
932
|
+
if (!meta) {
|
|
933
|
+
log.plain("");
|
|
934
|
+
log.warn("Setup finished, but creating your game failed \u2014 fix the error above and re-run `genex init`.");
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
return;
|
|
1000
937
|
}
|
|
938
|
+
const { path: metaPath } = await writeProject(meta);
|
|
939
|
+
log.dim(` saved ${c.cyan(metaPath)}`);
|
|
940
|
+
await writeGameConfigFiles(meta, log);
|
|
1001
941
|
log.plain("");
|
|
1002
942
|
log.success("All set. \u{1F680}");
|
|
1003
943
|
}
|
|
@@ -1041,23 +981,11 @@ async function runLink(opts) {
|
|
|
1041
981
|
process.exitCode = 1;
|
|
1042
982
|
return;
|
|
1043
983
|
}
|
|
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
984
|
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
985
|
const meta = {
|
|
1058
986
|
id: project.id,
|
|
1059
987
|
slug: project.slug,
|
|
1060
|
-
|
|
988
|
+
cloneUrl: project.cloneUrl ?? "",
|
|
1061
989
|
apiUrl,
|
|
1062
990
|
colyseusUrl: getColyseusUrl(opts.colyseusUrl),
|
|
1063
991
|
playUrl: project.playUrl ?? void 0,
|
|
@@ -1143,40 +1071,9 @@ async function listOwnSlugs(apiUrl, token, log) {
|
|
|
1143
1071
|
} catch {
|
|
1144
1072
|
}
|
|
1145
1073
|
}
|
|
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
1074
|
|
|
1178
1075
|
// src/lib/deploy.ts
|
|
1179
|
-
import { spawn as
|
|
1076
|
+
import { spawn as spawn3 } from "child_process";
|
|
1180
1077
|
import crypto3 from "crypto";
|
|
1181
1078
|
import fs9 from "fs/promises";
|
|
1182
1079
|
import os2 from "os";
|
|
@@ -1185,7 +1082,7 @@ function run(cmd, args, env) {
|
|
|
1185
1082
|
return new Promise((resolve) => {
|
|
1186
1083
|
let child;
|
|
1187
1084
|
try {
|
|
1188
|
-
child =
|
|
1085
|
+
child = spawn3(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
|
|
1189
1086
|
} catch {
|
|
1190
1087
|
resolve({ code: -1, out: "", err: `${cmd} not found` });
|
|
1191
1088
|
return;
|
|
@@ -1199,6 +1096,9 @@ function run(cmd, args, env) {
|
|
|
1199
1096
|
});
|
|
1200
1097
|
}
|
|
1201
1098
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
|
|
1099
|
+
function isSecretEnvFile(name) {
|
|
1100
|
+
return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
|
|
1101
|
+
}
|
|
1202
1102
|
async function deployGame(ctx, opts, log) {
|
|
1203
1103
|
const cwd = process.cwd();
|
|
1204
1104
|
if (!opts.noBuild && await hasBuildScript(cwd)) {
|
|
@@ -1217,10 +1117,6 @@ async function deployGame(ctx, opts, log) {
|
|
|
1217
1117
|
const rel = path10.relative(cwd, siteDir) || ".";
|
|
1218
1118
|
if (siteDir === cwd) await writeGitignore(cwd, log);
|
|
1219
1119
|
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
1120
|
if (files.length === 0) {
|
|
1225
1121
|
log.warn("Nothing to deploy \u2014 the build produced no files.");
|
|
1226
1122
|
return false;
|
|
@@ -1253,12 +1149,7 @@ async function deployGame(ctx, opts, log) {
|
|
|
1253
1149
|
log.error("Couldn't upload your game \u2014 please try again.");
|
|
1254
1150
|
return false;
|
|
1255
1151
|
}
|
|
1256
|
-
|
|
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;
|
|
1152
|
+
if (!await pushSource(cwd, ctx, log)) return false;
|
|
1262
1153
|
log.step("Publishing\u2026");
|
|
1263
1154
|
if (!await callPublish(ctx, commit, opts, log)) return false;
|
|
1264
1155
|
const index = files.find((f) => f.relPath === "index.html");
|
|
@@ -1280,7 +1171,7 @@ async function collectFiles(root) {
|
|
|
1280
1171
|
const relPath = prefix ? `${prefix}/${e.name}` : e.name;
|
|
1281
1172
|
if (e.isDirectory()) {
|
|
1282
1173
|
if (!EXCLUDE_DIRS.has(e.name)) await walk2(path10.join(dir, e.name), relPath);
|
|
1283
|
-
} else if (e.isFile() && e.name
|
|
1174
|
+
} else if (e.isFile() && !isSecretEnvFile(e.name)) {
|
|
1284
1175
|
out.push({ relPath, bytes: await fs9.readFile(path10.join(dir, e.name)) });
|
|
1285
1176
|
}
|
|
1286
1177
|
}
|
|
@@ -1371,8 +1262,11 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1371
1262
|
}
|
|
1372
1263
|
return true;
|
|
1373
1264
|
}
|
|
1374
|
-
async function pushSource(cwd,
|
|
1265
|
+
async function pushSource(cwd, ctx, log) {
|
|
1375
1266
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1267
|
+
const target = await fetchPushUrl(ctx, log);
|
|
1268
|
+
if (!target) return false;
|
|
1269
|
+
const { pushUrl, managed } = target;
|
|
1376
1270
|
const failed = () => {
|
|
1377
1271
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1378
1272
|
return false;
|
|
@@ -1389,29 +1283,27 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1389
1283
|
if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
|
|
1390
1284
|
await fs9.writeFile(
|
|
1391
1285
|
path10.join(gitDir, "info", "exclude"),
|
|
1392
|
-
|
|
1286
|
+
// .env* are secrets — never publish them; `!` keeps the non-secret template.
|
|
1287
|
+
["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
|
|
1393
1288
|
);
|
|
1394
1289
|
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
|
|
1395
1290
|
await run("git", ["add", "-A"], env);
|
|
1396
|
-
const
|
|
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() : "";
|
|
1291
|
+
const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
|
|
1402
1292
|
if (!tree || tree === EMPTY_TREE) {
|
|
1403
1293
|
log.error("Nothing to publish \u2014 the project has no files.");
|
|
1404
1294
|
return false;
|
|
1405
1295
|
}
|
|
1406
1296
|
const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
|
|
1407
1297
|
await run("git", ["update-ref", "refs/heads/main", commit], base);
|
|
1408
|
-
const push = await run("git", ["push", "-q",
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1298
|
+
const push = await run("git", ["push", "-q", pushUrl, "+refs/heads/main:main"], base);
|
|
1299
|
+
if (push.code === 0) return true;
|
|
1300
|
+
if (!managed) {
|
|
1301
|
+
log.error(
|
|
1302
|
+
"Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
|
|
1303
|
+
);
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
return failed();
|
|
1415
1307
|
} catch {
|
|
1416
1308
|
return failed();
|
|
1417
1309
|
} finally {
|
|
@@ -1419,17 +1311,35 @@ async function pushSource(cwd, sshUrl, keyPath, log) {
|
|
|
1419
1311
|
});
|
|
1420
1312
|
}
|
|
1421
1313
|
}
|
|
1422
|
-
async function
|
|
1314
|
+
async function fetchPushUrl(ctx, log) {
|
|
1315
|
+
let res;
|
|
1423
1316
|
try {
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1317
|
+
res = await apiFetch(`${ctx.apiUrl}/api/projects/${ctx.projectId}/push-token`, {
|
|
1318
|
+
method: "POST",
|
|
1319
|
+
headers: { Authorization: `Bearer ${ctx.token}` }
|
|
1320
|
+
});
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
log.error(`Couldn't reach the API to authorize the source push: ${String(err)}`);
|
|
1323
|
+
return null;
|
|
1427
1324
|
}
|
|
1325
|
+
if (res.status === 401) {
|
|
1326
|
+
log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
if (!res.ok) {
|
|
1330
|
+
log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
const data = await res.json().catch(() => null);
|
|
1334
|
+
if (!data?.pushUrl) {
|
|
1335
|
+
log.error("The API didn't return a push URL.");
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
return { pushUrl: data.pushUrl, managed: data.managed !== false };
|
|
1428
1339
|
}
|
|
1429
|
-
async function
|
|
1340
|
+
async function isDir2(p) {
|
|
1430
1341
|
try {
|
|
1431
|
-
await fs9.
|
|
1432
|
-
return true;
|
|
1342
|
+
return (await fs9.stat(p)).isDirectory();
|
|
1433
1343
|
} catch {
|
|
1434
1344
|
return false;
|
|
1435
1345
|
}
|
|
@@ -1571,7 +1481,7 @@ async function runPublish(opts) {
|
|
|
1571
1481
|
advisoryNudges(log, detections);
|
|
1572
1482
|
if (!opts.noPush) {
|
|
1573
1483
|
const ok = await deployGame(
|
|
1574
|
-
{ projectId: meta.id,
|
|
1484
|
+
{ projectId: meta.id, apiUrl, token },
|
|
1575
1485
|
{
|
|
1576
1486
|
noBuild: opts.noBuild,
|
|
1577
1487
|
matchmaking: detections.matchmaking,
|
|
@@ -1643,7 +1553,7 @@ async function runPreview(opts) {
|
|
|
1643
1553
|
advisoryNudges(log, detections);
|
|
1644
1554
|
const apiUrl = getApiUrl(meta.apiUrl);
|
|
1645
1555
|
const ok = await deployGame(
|
|
1646
|
-
{ projectId: meta.id,
|
|
1556
|
+
{ projectId: meta.id, apiUrl, token },
|
|
1647
1557
|
// Detections reach the server on every preview too: matchmaking so a draft's
|
|
1648
1558
|
// declared preset doesn't silently run the default (null clears a removed
|
|
1649
1559
|
// config), embedSdkVersion/multiplayer so the dashboard's Publish button can
|
|
@@ -2158,6 +2068,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture`)")}
|
|
|
2158
2068
|
${c.bold("Options for `init`")}
|
|
2159
2069
|
<name> Project name (positional; default: current directory name).
|
|
2160
2070
|
--name <name> Same as the positional name.
|
|
2071
|
+
--repo <url> Host the source in your own git repo (https/ssh) instead of a managed one;
|
|
2072
|
+
preview/publish push there with your git credentials.
|
|
2161
2073
|
--agents <list> Agents to install skills for (claude,codex,cursor; default: auto-detect).
|
|
2162
2074
|
--dir <path> Single destination workspace (overrides --agents).
|
|
2163
2075
|
--env <path> Token env file (default: ~/.genex/env).
|
|
@@ -2239,6 +2151,7 @@ function parseArgs(argv) {
|
|
|
2239
2151
|
"--colyseus-url",
|
|
2240
2152
|
"--agents",
|
|
2241
2153
|
"--name",
|
|
2154
|
+
"--repo",
|
|
2242
2155
|
"--title",
|
|
2243
2156
|
"--description",
|
|
2244
2157
|
"--categories",
|
|
@@ -2338,6 +2251,9 @@ function applyValueFlag(options, flag, value) {
|
|
|
2338
2251
|
case "--name":
|
|
2339
2252
|
options.name = value;
|
|
2340
2253
|
break;
|
|
2254
|
+
case "--repo":
|
|
2255
|
+
options.repo = value;
|
|
2256
|
+
break;
|
|
2341
2257
|
case "--title":
|
|
2342
2258
|
options.title = value;
|
|
2343
2259
|
break;
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`).
|
|
@@ -34,9 +34,12 @@ example, the shared-object/ball code, rotation, and host usage. Read
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npm i @genex-ai/multiplayer
|
|
37
|
+
npm i @genex-ai/multiplayer@^0.8.0
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
> Pin `@^0.8.0` (not a bare `npm i`): `inputs`/`onHostTick`, auto-reconnect, and the `reconnecting`
|
|
41
|
+
> events this skill relies on landed in 0.8. An older resolve would throw `room.onHostTick is not a function` at runtime.
|
|
42
|
+
|
|
40
43
|
This skill targets `@genex-ai/multiplayer` **≥ 0.8.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7; matchmake auto-retry + `retry()` since 0.7.1; auto-reconnect + `inputs`/`onHostTick` since 0.8).
|
|
41
44
|
|
|
42
45
|
## Trust model (say it plainly in your game's copy)
|
|
@@ -148,6 +151,7 @@ room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt
|
|
|
148
151
|
room.on("reconnected", () => hideOverlay());
|
|
149
152
|
room.on("disconnect", (code) => {
|
|
150
153
|
// Terminal: server restart, revoked session, or the link never came back.
|
|
154
|
+
if (code === 4409) flushSaves(); // replaced by this player's OTHER tab/device — flush now
|
|
151
155
|
// To play again, read a FRESH token and connect() anew — never reuse the old auth object.
|
|
152
156
|
showMenu("Connection lost");
|
|
153
157
|
});
|
|
@@ -160,7 +164,9 @@ glide on; don't tear the scene down. A deliberate `room.leave()` never auto-reco
|
|
|
160
164
|
**One seat per player (enforced server-side):** joining the same game again — a second tab,
|
|
161
165
|
another device, or a page reload — instantly evicts the previous session (it gets
|
|
162
166
|
`disconnect`, code 4409). You never need to handle "the same player twice" and a reload
|
|
163
|
-
never leaves a ghost avatar behind.
|
|
167
|
+
never leaves a ghost avatar behind. If the evicted tab was the host and holds unsaved world
|
|
168
|
+
state, flush it in the `disconnect` handler (code 4409, above): that tab is still alive, so an
|
|
169
|
+
async `saveWorldState` completes — otherwise a debounced save in flight is lost.
|
|
164
170
|
|
|
165
171
|
## Which channel for which data
|
|
166
172
|
|
|
@@ -187,7 +193,10 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
187
193
|
`{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you); `stateRaw` is
|
|
188
194
|
the raw latest (hit-tests, discrete values).
|
|
189
195
|
- `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
|
|
190
|
-
- `claim(id)` — take ownership (last claim wins; call on kick/contact).
|
|
196
|
+
- `claim(id)` — take ownership (last claim wins; call on kick/contact). Claiming is optimistic:
|
|
197
|
+
you own it locally the instant you call it, but if another player claimed the same tick the
|
|
198
|
+
server's last-claim-wins verdict can revoke you — a `set()` you sent before losing the race is
|
|
199
|
+
dropped. For contested objects, keep publishing while `isMine` stays true, not just once.
|
|
191
200
|
- `set(id, state)` — publish it (only lands while you own it; full flat object each call).
|
|
192
201
|
- `get(id)` → `{ id, owner, isMine, state, stateRaw }` or `undefined`. `state` is auto-smoothed
|
|
193
202
|
(or live if `isMine`); `stateRaw` is the raw latest.
|
|
@@ -212,6 +221,24 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
212
221
|
- `room.onHostTick(hz, cb)` — run a fixed simulation tick only while you are the host
|
|
213
222
|
(auto-starts/stops across host migration). Returns a disposer.
|
|
214
223
|
|
|
224
|
+
## Your message budget (every publish is one relay message)
|
|
225
|
+
|
|
226
|
+
Every `me.set`, `objects.set`, `objects.claim`, `send`, and `inputs.send` costs **one relay
|
|
227
|
+
message**, and the relay caps each connection at **~120 messages/second sustained** (drops
|
|
228
|
+
above that — you'll see a console warning "the relay dropped N of your messages"). The
|
|
229
|
+
budget math that matters:
|
|
230
|
+
|
|
231
|
+
- Your own state (`me.set`) at 15 Hz + ONE driven/owned moving object at 15 Hz = 30/s. Fine.
|
|
232
|
+
- The pattern that blows the budget: **republishing IDLE objects every tick.** A host that
|
|
233
|
+
owns several parked vehicles/props must NOT `objects.set` each of them at full tick rate —
|
|
234
|
+
publish an object **when it changed**, plus a low-rate keepalive (~1–2 Hz) so late joiners
|
|
235
|
+
converge. Unchanged pose ⇒ no message.
|
|
236
|
+
- If you see the drop warning, count your sends-per-tick: streams × tick-rate must stay well
|
|
237
|
+
under 120/s with headroom for claims and events.
|
|
238
|
+
- Over budget, the relay spreads the loss across ALL your streams (everything gets choppy at
|
|
239
|
+
once) rather than freezing one — so a single stuttering object is your cue to check the whole
|
|
240
|
+
budget, not just that object. The warning is the signal; don't design at the edge of the cap.
|
|
241
|
+
|
|
215
242
|
## The loop you must build (input → local → tick → render)
|
|
216
243
|
|
|
217
244
|
1. **Input mutates a local object only** (`me.x += …`). Never network on keypress.
|
|
@@ -259,7 +286,10 @@ details are in [references/realtime-patterns.md](references/realtime-patterns.md
|
|
|
259
286
|
## Host authority (scores, rounds, enemies)
|
|
260
287
|
|
|
261
288
|
One client is the `host`. Let *only* the host write agreed state and simulate shared enemies, so
|
|
262
|
-
there's a single source of truth:
|
|
289
|
+
there's a single source of truth. **But: a host is only the authority for objects it OWNS.**
|
|
290
|
+
If another player owns/drives an object (their claim landed), the host renders it from the
|
|
291
|
+
stream like everyone else — a host branch that pins "its" objects to a local pose without
|
|
292
|
+
checking `owner`/`isMine` shows every other player's driving as a frozen object:
|
|
263
293
|
|
|
264
294
|
```ts
|
|
265
295
|
if (room.isHost) room.shared.set("round", nextRound); // only the host advances the round
|
|
@@ -364,10 +394,12 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
364
394
|
|
|
365
395
|
## Checklist
|
|
366
396
|
|
|
367
|
-
- [ ] `npm i @genex-ai/multiplayer
|
|
397
|
+
- [ ] `npm i @genex-ai/multiplayer@^0.8.0` (auto-reconnect, `inputs`, `onHostTick` — pin `^0.8.0`, a bare install can resolve older); config wired into the build.
|
|
368
398
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
369
399
|
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
370
400
|
- [ ] Contested (sustained-contact) objects use the host-physics pattern, not claim-on-touch.
|
|
401
|
+
- [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
|
|
402
|
+
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
371
403
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
372
404
|
hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
|
|
373
405
|
see `genex-threejs-embed-auth`).
|
|
@@ -81,29 +81,53 @@ The `genex controller` controllers are **local-only physics** — each player si
|
|
|
81
81
|
OWN rig (self-authoritative, zero latency). Networking them is publish-and-playback, never
|
|
82
82
|
remote simulation:
|
|
83
83
|
|
|
84
|
+
The vendored controllers expose their pose as `currPos` (a `THREE.Vector3`) and `currQuat` (a
|
|
85
|
+
`THREE.Quaternion`) — **not** `.position` / `.quaternion` — plus boolean state getters. Character
|
|
86
|
+
animation is driven by **five booleans**, not a single enum; publish the booleans and let remotes
|
|
87
|
+
reconstruct the animation. There is **no** `rig.animState`, no `remoteAnimator.play(...)`, and no
|
|
88
|
+
vehicle `wheelSpinPhase` getter.
|
|
89
|
+
|
|
84
90
|
```ts
|
|
85
|
-
// You: after your controller's update, on the fixed tick (~15Hz)
|
|
91
|
+
// You: after your controller's update(), on the fixed tick (~15Hz).
|
|
92
|
+
const p = character.currPos; // THREE.Vector3 (Vehicle/Drone: same getters)
|
|
93
|
+
const q = character.currQuat; // THREE.Quaternion
|
|
86
94
|
room.me.set({
|
|
87
|
-
x: r2(
|
|
88
|
-
q:
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
x: r2(p.x), y: r2(p.y), z: r2(p.z),
|
|
96
|
+
q: [r2(q.x), r2(q.y), r2(q.z), r2(q.w)], // quaternion array — never a scalar yaw
|
|
97
|
+
// character animation = 5 booleans (the CharacterController exposes each as a getter):
|
|
98
|
+
g: character.isOnGround, f: character.isFalling, m: character.isMoving,
|
|
99
|
+
r: character.runActive, j: character.jumpActive,
|
|
91
100
|
});
|
|
92
101
|
|
|
93
|
-
// Remote players:
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
// Remote players: a VISUAL-ONLY avatar — NO Rapier body, NO controller instance for remotes.
|
|
103
|
+
// Position/rotation from smoothed state; animation from the synced flags via the avatar's own
|
|
104
|
+
// update(flags, dt). The character-controller skill's animations reference owns the flag set.
|
|
105
|
+
const pl = room.players.get(id)!;
|
|
106
|
+
remoteAvatar.group.position.set(pl.state.x, pl.state.y, pl.state.z);
|
|
107
|
+
remoteAvatar.group.quaternion.fromArray(pl.state.q);
|
|
108
|
+
const raw = pl.stateRaw; // discrete flags: read RAW, never smoothed
|
|
109
|
+
remoteAvatar.update(
|
|
110
|
+
{ isOnGround: !!raw.g, isFalling: !!raw.f, isMoving: !!raw.m, runActive: !!raw.r, jumpActive: !!raw.j },
|
|
111
|
+
dt,
|
|
112
|
+
);
|
|
99
113
|
```
|
|
100
114
|
|
|
101
115
|
What to publish per controller:
|
|
102
116
|
|
|
103
|
-
- **character**: `x/y/z`, `
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
- **character**: `currPos` → `x/y/z`, `currQuat` → `q`, and the five booleans above
|
|
118
|
+
(`isOnGround`/`isFalling`/`isMoving`/`runActive`/`jumpActive`). Remotes rebuild the animation
|
|
119
|
+
with `avatar.update(flags, dt)` — see the `genex-threejs-character-controller` animations
|
|
120
|
+
reference for the flag set (single source of truth; don't invent a `play(anim)` call).
|
|
121
|
+
- **vehicle**: body `currPos` → `x/y/z` + `currQuat` → `q`. For visible steering, publish the
|
|
122
|
+
front wheel's `car.wheels.get(frontWheelId)?.steerAngle`; spin the wheels on remotes
|
|
123
|
+
procedurally from the body's speed (position delta) — there is no spin-phase getter, and you
|
|
124
|
+
never sync per-wheel transforms.
|
|
125
|
+
- **drone**: `currPos` → `x/y/z`, `currQuat` → `q`, and `drone.hoverThrottle` if the rotor visual
|
|
126
|
+
needs it. Remote rotor spin is procedural, like vehicle wheels.
|
|
127
|
+
|
|
128
|
+
> Remote **visuals** for vehicle wheels / drone rotors are author-built (a spinning mesh you drive
|
|
129
|
+
> from synced speed/throttle) — the controllers build those internally from a live Rapier body,
|
|
130
|
+
> which remotes don't have. Only the body pose + the flags/params above come over the wire.
|
|
107
131
|
|
|
108
132
|
Player-vs-player physical contact (bumping cars) stays approximate at this tier — each
|
|
109
133
|
client is authoritative over itself, so contacts are cosmetic. If a game's core loop IS
|