@onceworlds/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +570 -74
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,8 +64,8 @@ with the link) or Public (also listed on Onceworlds).
64
64
  `index.html`. `productionBranch` (default `main`) picks the branch that goes live;
65
65
  the dashboard can change it too, and the file wins again only when this value changes.
66
66
 
67
- `controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) and `help` (up to 400
68
- characters) fill "How to play" in the platform menu. The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
67
+ `controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) fill "How to play" in the platform
68
+ menu, folded until a player opens it (`help` is still accepted but no longer shown). The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
69
69
  16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
70
70
  `name`, `description` and an optional square `icon`). A game page holds at most 5
71
71
  thumbnails and videos together, and a game at most 50 badges. Images are PNG, JPEG, WebP
package/dist/cli.js CHANGED
@@ -4,17 +4,62 @@
4
4
  import { execFileSync as execFileSync2 } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import { watch } from "node:fs";
7
- import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
8
- import { join as join2, resolve, sep } from "node:path";
7
+ import { readdir as readdir3, readFile as readFile3, stat as stat2 } from "node:fs/promises";
8
+ import { join as join4, resolve as resolve2, sep } from "node:path";
9
9
  import { parseArgs } from "node:util";
10
10
 
11
+ // src/credentials.ts
12
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ function configFile() {
16
+ const base = process.env.ONCEWORLDS_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "onceworlds");
17
+ return join(base, "credentials.json");
18
+ }
19
+ async function readAll() {
20
+ try {
21
+ const data = JSON.parse(await readFile(configFile(), "utf8"));
22
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+ var originOf = (api2) => new URL(api2).origin;
28
+ async function readCredential(api2) {
29
+ const fromEnv = process.env.ONCEWORLDS_TOKEN;
30
+ if (fromEnv) return { token: fromEnv, username: "" };
31
+ const found = (await readAll())[originOf(api2)];
32
+ return found && typeof found.token === "string" && found.token ? found : null;
33
+ }
34
+ async function saveCredential(api2, credential) {
35
+ const file = configFile();
36
+ await mkdir(dirname(file), { recursive: true, mode: 448 });
37
+ const all = await readAll();
38
+ all[originOf(api2)] = credential;
39
+ const temp = `${file}.${process.pid}.tmp`;
40
+ await writeFile(temp, JSON.stringify(all, null, 2), { mode: 384 });
41
+ await rename(temp, file);
42
+ await chmod(file, 384).catch(() => {
43
+ });
44
+ }
45
+ async function removeCredential(api2) {
46
+ const all = await readAll();
47
+ if (!(originOf(api2) in all)) return false;
48
+ delete all[originOf(api2)];
49
+ const file = configFile();
50
+ const temp = `${file}.${process.pid}.tmp`;
51
+ await writeFile(temp, JSON.stringify(all, null, 2), { mode: 384 });
52
+ await rename(temp, file);
53
+ return true;
54
+ }
55
+
11
56
  // src/doctor.ts
12
57
  var ROOM_MAX_PLAYERS = 30;
13
58
  var MINIFIED_LINE = 400;
14
- var lineOf = (text, index) => text.slice(0, index).split("\n").length;
15
- function looksMinified(text) {
16
- const lines = text.split("\n");
17
- return text.length > 2e4 && text.length / lines.length > MINIFIED_LINE;
59
+ var lineOf = (text2, index) => text2.slice(0, index).split("\n").length;
60
+ function looksMinified(text2) {
61
+ const lines = text2.split("\n");
62
+ return text2.length > 2e4 && text2.length / lines.length > MINIFIED_LINE;
18
63
  }
19
64
  function cssIn(file) {
20
65
  if (/\.css$/i.test(file.path)) return [{ css: file.text, offset: 0 }];
@@ -78,8 +123,8 @@ function checkListing(config, file = "onceworlds.json") {
78
123
  }
79
124
  if (!filled(config.genre)) note("listing-genre", `No "genre": players can't find the game under a Discover genre. Pick one of the genres in AGENTS.md.`);
80
125
  const noControls = !Array.isArray(config.controls) || config.controls.length === 0;
81
- if (noControls && !filled(config.help)) {
82
- note("listing-help", `No "controls" or "help": the menu's How to play is empty. List every key or button the game uses and a line on how to win.`);
126
+ if (noControls) {
127
+ note("listing-help", `No "controls": the menu's How to play is empty. List every key or button the game uses.`);
83
128
  }
84
129
  return found;
85
130
  }
@@ -171,8 +216,8 @@ function checkGame(files) {
171
216
  // src/init.ts
172
217
  import { execFileSync } from "node:child_process";
173
218
  import { randomBytes } from "node:crypto";
174
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
175
- import { basename, dirname, join } from "node:path";
219
+ import { mkdir as mkdir2, readdir, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
220
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
176
221
 
177
222
  // ../protocol/src/index.ts
178
223
  var OIDC_AUDIENCE = "onceworlds";
@@ -301,6 +346,14 @@ var LIMITS = {
301
346
  /** An account made with just a username (no email, Google or GitHub) can publish a new game by upload once it is this old. */
302
347
  uploadAccountAgeMs: 7 * 24 * 60 * 6e4,
303
348
  deploysPerOwnerPerDay: 100,
349
+ /** Drafts (what a creator's AI tool pushes to try a game out, see cli.ts) in any 24 hours, per account: they're counted apart from deploys. */
350
+ draftsPerOwnerPerDay: 1e3,
351
+ /** Drafts kept per game (the newest ones; a published draft becomes an ordinary version). */
352
+ draftsKeptPerGame: 3,
353
+ /** Tools a creator has connected to one account (Claude Code, Codex, other computers). */
354
+ cliTokensPerAccount: 10,
355
+ /** How long a `onceworlds login` code can be approved on the site. */
356
+ cliDeviceCodeSeconds: 600,
304
357
  /** Finished versions retained per game, including its live version. */
305
358
  deploymentsKeptPerGame: 10,
306
359
  deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
@@ -545,17 +598,17 @@ var LOOKALIKES = {
545
598
  function isValidSlug(slug) {
546
599
  return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
547
600
  }
548
- function isReservedSlug(slug, newGame = true) {
601
+ function isReservedSlug(slug, newGame2 = true) {
549
602
  if (RESERVED_SLUGS.has(slug)) return true;
550
- if (!newGame) return false;
603
+ if (!newGame2) return false;
551
604
  if (NEW_GAME_RESERVED_SLUGS.has(slug) || NEW_GAME_RESERVED_PREFIXES.some((prefix) => slug.startsWith(prefix))) return true;
552
605
  if (!SLUG_RE.test(slug)) return false;
553
606
  const parts = slug.split("-");
554
607
  if (parts.some((part, i) => SLUG_AUTH_WORDS.has(part) || SLUG_AUTH_WORDS.has(part + (parts[i + 1] ?? "")))) return true;
555
608
  return hasBrand(slug);
556
609
  }
557
- function foldedWords(text) {
558
- const base = text.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "");
610
+ function foldedWords(text2) {
611
+ const base = text2.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "");
559
612
  const variants = [];
560
613
  for (const one of ["l", "i"]) {
561
614
  const mapped = [...base].map((c) => c === "1" ? one : LOOKALIKES[c] ?? c);
@@ -564,8 +617,8 @@ function foldedWords(text) {
564
617
  }
565
618
  return variants;
566
619
  }
567
- function hasBrand(text) {
568
- for (const words of foldedWords(text)) {
620
+ function hasBrand(text2) {
621
+ for (const words of foldedWords(text2)) {
569
622
  if (words.some((word) => BRANDS.some((brand) => word.includes(brand)))) return true;
570
623
  for (let i = 0; i < words.length; i++) {
571
624
  let joined = words[i];
@@ -660,7 +713,7 @@ var GENRES = [
660
713
  "Party",
661
714
  "Other"
662
715
  ];
663
- var tidy = (text) => text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
716
+ var tidy = (text2) => text2.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
664
717
  function checkGuide(controls, help) {
665
718
  const hints = [];
666
719
  if (controls !== void 0 && controls !== null) {
@@ -674,20 +727,20 @@ function checkGuide(controls, help) {
674
727
  }
675
728
  const cleaned = keys.map((k) => typeof k === "string" ? tidy(k) : "");
676
729
  if (cleaned.some((k) => !k || k.length > LIMITS.controlKeyLength)) throw new Error(`${name}.keys are short names (at most ${LIMITS.controlKeyLength} characters), like "Space" or "Left click"`);
677
- const text2 = typeof action === "string" ? tidy(action) : "";
678
- if (!text2 || text2.length > LIMITS.controlActionLength) throw new Error(`${name}.action is what the keys do, in at most ${LIMITS.controlActionLength} characters`);
679
- hints.push({ keys: cleaned, action: text2 });
730
+ const text3 = typeof action === "string" ? tidy(action) : "";
731
+ if (!text3 || text3.length > LIMITS.controlActionLength) throw new Error(`${name}.action is what the keys do, in at most ${LIMITS.controlActionLength} characters`);
732
+ hints.push({ keys: cleaned, action: text3 });
680
733
  });
681
734
  }
682
735
  if (help !== void 0 && help !== null && typeof help !== "string") throw new Error('"help" must be text');
683
- const text = typeof help === "string" ? help.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim() : "";
684
- if (text.length > LIMITS.helpLength) throw new Error(`"help" can be at most ${LIMITS.helpLength} characters`);
685
- return { controls: hints, help: text };
736
+ const text2 = typeof help === "string" ? help.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim() : "";
737
+ if (text2.length > LIMITS.helpLength) throw new Error(`"help" can be at most ${LIMITS.helpLength} characters`);
738
+ return { controls: hints, help: text2 };
686
739
  }
687
740
  var MAX_IMAGE_SIDE = 4096;
688
741
  function imageInfo(bytes) {
689
742
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
690
- const ascii = (at, text) => text.split("").every((c, i) => bytes[at + i] === c.charCodeAt(0));
743
+ const ascii = (at, text2) => text2.split("").every((c, i) => bytes[at + i] === c.charCodeAt(0));
691
744
  if (bytes.length >= 24 && bytes[0] === 137 && ascii(1, "PNG\r\n\n") && ascii(12, "IHDR")) {
692
745
  return { type: "image/png", width: view.getUint32(16), height: view.getUint32(20) };
693
746
  }
@@ -747,15 +800,15 @@ var WORKFLOW_PATH = ".github/workflows/onceworlds.yml";
747
800
  async function init(dir, options) {
748
801
  const log = options.log ?? console.log;
749
802
  const { force } = options;
750
- const at = (path) => join(dir, path);
751
- const read = (path) => readFile(at(path), "utf8").catch(() => null);
803
+ const at = (path) => join2(dir, path);
804
+ const read = (path) => readFile2(at(path), "utf8").catch(() => null);
752
805
  const exists = (path) => stat(at(path)).then(() => true, () => false);
753
806
  const lines = [];
754
807
  let kept = false;
755
808
  const put = async (path, content, label = path) => {
756
809
  try {
757
- await mkdir(dirname(at(path)), { recursive: true });
758
- await writeFile(at(path), content, { flag: force ? "w" : "wx" });
810
+ await mkdir2(dirname2(at(path)), { recursive: true });
811
+ await writeFile2(at(path), content, { flag: force ? "w" : "wx" });
759
812
  lines.push(` wrote ${label}`);
760
813
  } catch (err) {
761
814
  if (err.code !== "EEXIST") throw err;
@@ -764,7 +817,7 @@ async function init(dir, options) {
764
817
  }
765
818
  };
766
819
  const append = async (path, existing, line, label) => {
767
- await writeFile(at(path), `${existing.trimEnd()}
820
+ await writeFile2(at(path), `${existing.trimEnd()}
768
821
 
769
822
  ${line}
770
823
  `);
@@ -808,7 +861,7 @@ Next:
808
861
  }
809
862
  async function readPackage(dir) {
810
863
  try {
811
- const parsed = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
864
+ const parsed = JSON.parse(await readFile2(join2(dir, "package.json"), "utf8"));
812
865
  return parsed && typeof parsed === "object" ? parsed : null;
813
866
  } catch {
814
867
  return null;
@@ -843,20 +896,20 @@ function defaultBranch(dir) {
843
896
  return git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"])?.replace(/^origin\//, "") ?? git(["symbolic-ref", "-q", "--short", "HEAD"]);
844
897
  }
845
898
  async function findWorkflow(dir) {
846
- const folder = join(dir, ".github/workflows");
899
+ const folder = join2(dir, ".github/workflows");
847
900
  const names = await readdir(folder).catch(() => []);
848
901
  for (const name of names.filter((n) => /\.ya?ml$/.test(n)).slice(0, 20)) {
849
- if ((await readFile(join(folder, name), "utf8").catch(() => "")).includes("@onceworlds/cli")) return `.github/workflows/${name}`;
902
+ if ((await readFile2(join2(folder, name), "utf8").catch(() => "")).includes("@onceworlds/cli")) return `.github/workflows/${name}`;
850
903
  }
851
904
  return null;
852
905
  }
853
- async function fetchGuide(fetcher, api, warn) {
854
- const url = `${api}/agents.md`;
906
+ async function fetchGuide(fetcher, api2, warn) {
907
+ const url = `${api2}/agents.md`;
855
908
  let problem;
856
909
  try {
857
910
  const res = await fetcher(url, { signal: AbortSignal.timeout(1e4) });
858
- const text = res.ok ? await res.text() : "";
859
- if (text.trim()) return text;
911
+ const text2 = res.ok ? await res.text() : "";
912
+ if (text2.trim()) return text2;
860
913
  problem = res.ok ? "it is empty" : `${res.status}`;
861
914
  } catch (err) {
862
915
  problem = err instanceof Error ? err.message : String(err);
@@ -865,13 +918,416 @@ async function fetchGuide(fetcher, api, warn) {
865
918
  return null;
866
919
  }
867
920
 
921
+ // src/login.ts
922
+ import { spawn } from "node:child_process";
923
+ import { hostname } from "node:os";
924
+ async function post(url, body) {
925
+ let res;
926
+ try {
927
+ res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
928
+ } catch {
929
+ throw new Error(`Could not reach ${new URL(url).origin}.`);
930
+ }
931
+ return { status: res.status, data: await res.json().catch(() => ({})) };
932
+ }
933
+ function openBrowser(url) {
934
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
935
+ try {
936
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
937
+ child.on("error", () => {
938
+ });
939
+ child.unref();
940
+ } catch {
941
+ }
942
+ }
943
+ var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
944
+ async function login(api2) {
945
+ const started = await post(`${api2}/api/cli/device`, { label: hostname().slice(0, 40) });
946
+ if (started.status !== 200) throw new Error(String(started.data.message ?? `Login failed (${started.status}).`));
947
+ const start = started.data;
948
+ console.log(`
949
+ Your code: ${start.userCode}
950
+
951
+ Approve it in your browser: ${start.verifyUrl}
952
+ `);
953
+ openBrowser(start.verifyUrl);
954
+ console.log(" Waiting for you to approve it\u2026");
955
+ const deadline = Date.now() + start.expiresIn * 1e3;
956
+ let interval = Math.max(2, start.interval) * 1e3;
957
+ while (Date.now() < deadline) {
958
+ await sleep(interval);
959
+ const polled = await post(`${api2}/api/cli/token`, { deviceCode: start.deviceCode });
960
+ if (polled.status === 200) {
961
+ const done = polled.data;
962
+ await saveCredential(api2, { token: done.token, username: done.username });
963
+ console.log(`
964
+ \u2713 Logged in as @${done.username}. Your AI can push drafts now: it can't publish, only you can.`);
965
+ return;
966
+ }
967
+ const error = String(polled.data.error ?? "");
968
+ if (error === "authorization_pending") continue;
969
+ if (error === "slow_down") {
970
+ interval += 2e3;
971
+ continue;
972
+ }
973
+ throw new Error(String(polled.data.message ?? `Login failed (${polled.status}).`));
974
+ }
975
+ throw new Error("That code expired. Run onceworlds login again.");
976
+ }
977
+ async function logout(api2) {
978
+ console.log(await removeCredential(api2) ? "\u2713 Logged out on this computer." : "You were not logged in.");
979
+ }
980
+
981
+ // src/mcp.ts
982
+ import { mkdir as mkdir3, readdir as readdir2, writeFile as writeFile3 } from "node:fs/promises";
983
+ import { createInterface } from "node:readline";
984
+ import { dirname as dirname3, isAbsolute, join as join3, relative, resolve } from "node:path";
985
+
986
+ // src/zip.ts
987
+ import { inflateRawSync } from "node:zlib";
988
+ var MAX_ENTRIES = 500;
989
+ var MAX_BYTES = 50 * 1024 * 1024;
990
+ function readZip(zip) {
991
+ let end = -1;
992
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65535); i--) {
993
+ if (zip.readUInt32LE(i) === 101010256) {
994
+ end = i;
995
+ break;
996
+ }
997
+ }
998
+ if (end < 0) throw new Error("Not a zip file.");
999
+ const count = zip.readUInt16LE(end + 10);
1000
+ let cursor = zip.readUInt32LE(end + 16);
1001
+ if (count > MAX_ENTRIES) throw new Error("The zip has too many files.");
1002
+ const entries = [];
1003
+ let total = 0;
1004
+ for (let n = 0; n < count; n++) {
1005
+ if (zip.readUInt32LE(cursor) !== 33639248) throw new Error("The zip is damaged.");
1006
+ const flags = zip.readUInt16LE(cursor + 8);
1007
+ const method = zip.readUInt16LE(cursor + 10);
1008
+ const compressed = zip.readUInt32LE(cursor + 20);
1009
+ const size = zip.readUInt32LE(cursor + 24);
1010
+ const nameLength = zip.readUInt16LE(cursor + 28);
1011
+ const extraLength = zip.readUInt16LE(cursor + 30);
1012
+ const commentLength = zip.readUInt16LE(cursor + 32);
1013
+ const local = zip.readUInt32LE(cursor + 42);
1014
+ const path = zip.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8");
1015
+ cursor += 46 + nameLength + extraLength + commentLength;
1016
+ if (path.endsWith("/")) continue;
1017
+ if (flags & 1) throw new Error("The zip is encrypted.");
1018
+ total += size;
1019
+ if (total > MAX_BYTES) throw new Error("The zip is too big.");
1020
+ if (zip.readUInt32LE(local) !== 67324752) throw new Error("The zip is damaged.");
1021
+ const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
1022
+ const body = zip.subarray(start, start + compressed);
1023
+ let data;
1024
+ if (method === 0) data = Buffer.from(body);
1025
+ else if (method === 8) data = inflateRawSync(body, { maxOutputLength: MAX_BYTES });
1026
+ else throw new Error(`The zip uses a compression method (${method}) this tool can't read.`);
1027
+ if (data.length !== size) throw new Error("The zip is damaged.");
1028
+ entries.push({ path, data });
1029
+ }
1030
+ return entries;
1031
+ }
1032
+
1033
+ // src/mcp.ts
1034
+ var INSTRUCTIONS = `Onceworlds hosts browser games with multiplayer, saves and accounts built in. These tools build a game on the user's account as drafts: the user plays every draft on their Create page and publishes it there. You never publish.
1035
+
1036
+ How to work:
1037
+ 1. read_guide once. It is the reference for the platform's SDK (window.onceworlds) and what a multiplayer game must handle (lobby, ready, reloads, a host leaving, phones).
1038
+ 2. new_game starts a folder from a template (party: rounds with a lobby, the best start for most multiplayer games; starter: a small real-time game). Build the game in that folder: plain HTML, CSS and JavaScript, or a library from a CDN (jsdelivr, cdnjs). Keep onceworlds.json's name and description right.
1039
+ 3. check, then deploy_preview after every meaningful change. The user sees each push on their Create page within seconds.
1040
+ 4. game_status shows the errors the user's plays of your latest draft hit. Fix them and push again.
1041
+ Not logged in? Ask the user to run \`npx @onceworlds/cli login\` in a terminal and approve the code in their browser.`;
1042
+ var TOOLS = [
1043
+ {
1044
+ name: "read_guide",
1045
+ title: "Read the Onceworlds guide",
1046
+ description: 'The platform guide (AGENTS.md): the SDK, multiplayer rooms and matches, what to handle, how to design and ship a game. Read it before building. Pass `section` (part of a heading, like "Matches" or "Secrets") to get only that part.',
1047
+ inputSchema: { type: "object", properties: { section: { type: "string", description: "Part of a heading to return only that section." } }, additionalProperties: false },
1048
+ annotations: { readOnlyHint: true }
1049
+ },
1050
+ {
1051
+ name: "new_game",
1052
+ title: "Start a game from a template",
1053
+ description: "Creates a folder with a working multiplayer game to build on (templates: party, starter) and writes its onceworlds.json. The folder must not exist yet or must be empty. Build the game there, then push drafts with deploy_preview.",
1054
+ inputSchema: {
1055
+ type: "object",
1056
+ properties: {
1057
+ slug: { type: "string", description: "The game's link: 1-40 lowercase letters, digits or single dashes." },
1058
+ name: { type: "string", description: "The game's name." },
1059
+ description: { type: "string", description: "One line for the game page." },
1060
+ template: { type: "string", enum: ["party", "starter"], description: "party (default): lobby and rounds. starter: a small real-time game." },
1061
+ dir: { type: "string", description: "Folder to create (default: ./<slug>)." }
1062
+ },
1063
+ required: ["slug", "name"],
1064
+ additionalProperties: false
1065
+ }
1066
+ },
1067
+ {
1068
+ name: "check",
1069
+ title: "Check the game for common mistakes",
1070
+ description: `Looks through the game's files for what games often ship with (no viewport tag, tiny text fields, missing touch controls, localStorage, Date.now() for shared time). Suggestions only; fix the "likely problems".`,
1071
+ inputSchema: { type: "object", properties: { dir: { type: "string", description: "The game folder (default: the current folder)." } }, additionalProperties: false },
1072
+ annotations: { readOnlyHint: true }
1073
+ },
1074
+ {
1075
+ name: "deploy_preview",
1076
+ title: "Push a draft for the user to play",
1077
+ description: "Uploads the game as a draft (only changed files move) and returns where the user plays it. Creates the game on first push. Call it after every meaningful change. A draft is private to the user until they publish it on the site.",
1078
+ inputSchema: { type: "object", properties: { dir: { type: "string", description: "The game folder (default: the current folder)." } }, additionalProperties: false }
1079
+ },
1080
+ {
1081
+ name: "game_status",
1082
+ title: "Latest draft and the errors it hit",
1083
+ description: "The game's newest draft and the uncaught errors the user's plays of it hit (message, file and line, how many times). Look here after the user has played.",
1084
+ inputSchema: {
1085
+ type: "object",
1086
+ properties: {
1087
+ slug: { type: "string", description: "The game's link (default: the one in the folder's onceworlds.json)." },
1088
+ dir: { type: "string", description: "The game folder (default: the current folder)." }
1089
+ },
1090
+ additionalProperties: false
1091
+ },
1092
+ annotations: { readOnlyHint: true }
1093
+ },
1094
+ {
1095
+ name: "list_games",
1096
+ title: "List the user's games",
1097
+ description: "The games made with these tools on the user's account.",
1098
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1099
+ annotations: { readOnlyHint: true }
1100
+ }
1101
+ ];
1102
+ var text = (value) => ({ content: [{ type: "text", text: value }] });
1103
+ var failure = (value) => ({ content: [{ type: "text", text: value }], isError: true });
1104
+ var ToolError = class extends Error {
1105
+ };
1106
+ var LOGIN_HINT = "Not logged in. Ask the user to run `npx @onceworlds/cli login` in a terminal and approve the code in their browser.";
1107
+ async function serveMcp(host) {
1108
+ console.log = (...args) => console.error(...args);
1109
+ const send2 = (message) => process.stdout.write(`${JSON.stringify(message)}
1110
+ `);
1111
+ const reader = createInterface({ input: process.stdin });
1112
+ const pending = /* @__PURE__ */ new Set();
1113
+ for await (const line of reader) {
1114
+ if (!line.trim()) continue;
1115
+ const work = (async () => {
1116
+ let message;
1117
+ try {
1118
+ message = JSON.parse(line);
1119
+ } catch {
1120
+ send2({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
1121
+ return;
1122
+ }
1123
+ for (const one of Array.isArray(message) ? message : [message]) {
1124
+ const reply = await handleMessage(host, one);
1125
+ if (reply) send2(reply);
1126
+ }
1127
+ })().finally(() => pending.delete(work));
1128
+ pending.add(work);
1129
+ }
1130
+ await Promise.all(pending);
1131
+ }
1132
+ var PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
1133
+ async function handleMessage(host, message) {
1134
+ const id = message.id;
1135
+ const method = typeof message.method === "string" ? message.method : "";
1136
+ const params = message.params && typeof message.params === "object" ? message.params : {};
1137
+ if (id === void 0 || id === null) return null;
1138
+ const ok = (result) => ({ jsonrpc: "2.0", id, result });
1139
+ const error = (code, text2) => ({ jsonrpc: "2.0", id, error: { code, message: text2 } });
1140
+ if (method === "initialize") {
1141
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
1142
+ return ok({
1143
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[1],
1144
+ capabilities: { tools: {} },
1145
+ serverInfo: { name: "onceworlds", title: "Onceworlds", version: host.version },
1146
+ instructions: INSTRUCTIONS
1147
+ });
1148
+ }
1149
+ if (method === "ping") return ok({});
1150
+ if (method === "tools/list") return ok({ tools: TOOLS });
1151
+ if (method === "tools/call") {
1152
+ const name = typeof params.name === "string" ? params.name : "";
1153
+ if (!TOOLS.some((tool) => tool.name === name)) return error(-32602, `Unknown tool: ${name}`);
1154
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
1155
+ try {
1156
+ return ok(await callTool(host, name, args));
1157
+ } catch (err) {
1158
+ return ok(failure(err instanceof Error ? err.message : String(err)));
1159
+ }
1160
+ }
1161
+ return error(-32601, `Method not found: ${method}`);
1162
+ }
1163
+ var stringArg = (args, key) => typeof args[key] === "string" && args[key] !== "" ? args[key] : void 0;
1164
+ async function callTool(host, name, args) {
1165
+ const dir = resolve(stringArg(args, "dir") ?? ".");
1166
+ switch (name) {
1167
+ case "read_guide":
1168
+ return text(await readGuide(host, stringArg(args, "section")));
1169
+ case "new_game":
1170
+ return text(await newGame(host, args));
1171
+ case "check": {
1172
+ const findings = await host.findings(dir);
1173
+ return text(formatFindings(findings, dir));
1174
+ }
1175
+ case "deploy_preview": {
1176
+ if (!await host.token()) return failure(LOGIN_HINT);
1177
+ const findings = await host.findings(dir).catch(() => []);
1178
+ const result = await host.deploy(dir);
1179
+ const warnings = findings.filter((f) => f.level === "warn");
1180
+ return text(
1181
+ [
1182
+ `Draft pushed. The user plays it here: ${result.createUrl ?? result.previewUrl}`,
1183
+ ...warnings.length ? ["", `${warnings.length} likely problem${warnings.length === 1 ? "" : "s"} found, fix them:`, ...warnings.map((f) => ` ${f.file}:${f.line} ${f.message}`)] : [],
1184
+ ...result.notes?.length ? ["", ...result.notes] : [],
1185
+ "",
1186
+ "Ask the user to play it. After they have, game_status shows the errors they hit."
1187
+ ].join("\n")
1188
+ );
1189
+ }
1190
+ case "game_status": {
1191
+ const token = await host.token();
1192
+ if (!token) return failure(LOGIN_HINT);
1193
+ const slug = stringArg(args, "slug") ?? await host.slugOf(dir);
1194
+ if (!slug || !isValidSlug(slug)) throw new ToolError("Say which game: pass slug, or run this in the game's folder.");
1195
+ return text(formatStatus(await api(host, token, `/api/cli/games/${slug}`)));
1196
+ }
1197
+ case "list_games": {
1198
+ const token = await host.token();
1199
+ if (!token) return failure(LOGIN_HINT);
1200
+ const data = await api(host, token, "/api/cli/games");
1201
+ return text(data.games.length ? data.games.map((g) => `${g.slug} ${g.name} ${g.live ? "live" : "not published"} ${g.createUrl}`).join("\n") : "No games yet.");
1202
+ }
1203
+ }
1204
+ throw new ToolError(`Unknown tool: ${name}`);
1205
+ }
1206
+ async function api(host, token, path) {
1207
+ let res;
1208
+ try {
1209
+ res = await fetch(`${host.api}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } });
1210
+ } catch {
1211
+ throw new ToolError(`Could not reach ${host.api}.`);
1212
+ }
1213
+ const data = await res.json().catch(() => ({}));
1214
+ if (!res.ok) throw new ToolError(data.message ?? `${path} failed (${res.status})`);
1215
+ return data;
1216
+ }
1217
+ async function readGuide(host, section) {
1218
+ let guide;
1219
+ try {
1220
+ const res = await fetch(`${host.api}/agents.md`);
1221
+ if (!res.ok) throw new Error(String(res.status));
1222
+ guide = await res.text();
1223
+ } catch {
1224
+ throw new ToolError(`Could not read the guide from ${host.api}/agents.md.`);
1225
+ }
1226
+ if (!section) return guide;
1227
+ const wanted = section.toLowerCase();
1228
+ const lines = guide.split("\n");
1229
+ const out = [];
1230
+ let level = 0;
1231
+ let inside = false;
1232
+ let fenced = false;
1233
+ for (const line of lines) {
1234
+ if (line.startsWith("```")) fenced = !fenced;
1235
+ const heading = !fenced ? /^(#{1,6})\s+(.*)$/.exec(line) : null;
1236
+ if (heading) {
1237
+ const depth = heading[1].length;
1238
+ if (inside && depth <= level) inside = false;
1239
+ if (!inside && heading[2].toLowerCase().includes(wanted)) {
1240
+ inside = true;
1241
+ level = depth;
1242
+ }
1243
+ }
1244
+ if (inside) out.push(line);
1245
+ }
1246
+ if (out.length === 0) {
1247
+ const headings = lines.filter((l) => /^#{2,3}\s/.test(l)).map((l) => l.replace(/^#+\s+/, ""));
1248
+ throw new ToolError(`No section matches "${section}". Sections: ${headings.join("; ")}`);
1249
+ }
1250
+ return out.join("\n");
1251
+ }
1252
+ async function newGame(host, args) {
1253
+ const slug = stringArg(args, "slug") ?? "";
1254
+ const name = (stringArg(args, "name") ?? "").trim();
1255
+ const description = (stringArg(args, "description") ?? "").trim().slice(0, 280);
1256
+ const template = stringArg(args, "template") ?? "party";
1257
+ if (!isValidSlug(slug)) throw new ToolError("slug must be 1-40 lowercase letters, digits or single dashes.");
1258
+ if (!name || name.length > LIMITS.gameNameLength) throw new ToolError(`name must be 1-${LIMITS.gameNameLength} characters.`);
1259
+ if (template !== "party" && template !== "starter") throw new ToolError("template is party or starter.");
1260
+ const dir = resolve(stringArg(args, "dir") ?? slug);
1261
+ const existing = await readdir2(dir).catch(() => null);
1262
+ if (existing && existing.length > 0) throw new ToolError(`${dir} already has files. Pick another folder, or build in it directly.`);
1263
+ let zip;
1264
+ try {
1265
+ const res = await fetch(`${host.api}/onceworlds-${template}.zip`);
1266
+ if (!res.ok) throw new Error(String(res.status));
1267
+ zip = Buffer.from(await res.arrayBuffer());
1268
+ } catch {
1269
+ throw new ToolError(`Could not download the ${template} template from ${host.api}.`);
1270
+ }
1271
+ const entries = readZip(zip);
1272
+ const top = entries[0]?.path.split("/")[0] ?? "";
1273
+ const written = [];
1274
+ for (const entry of entries) {
1275
+ const path = entry.path.startsWith(`${top}/`) ? entry.path.slice(top.length + 1) : entry.path;
1276
+ const target = resolve(dir, path);
1277
+ const inside = relative(dir, target);
1278
+ if (!path || inside.startsWith("..") || isAbsolute(inside)) throw new ToolError("The template has an unsafe path.");
1279
+ await mkdir3(dirname3(target), { recursive: true });
1280
+ await writeFile3(target, entry.data);
1281
+ written.push(path);
1282
+ }
1283
+ await writeFile3(join3(dir, "onceworlds.json"), `${JSON.stringify({ slug, name, description, dir: "." }, null, 2)}
1284
+ `);
1285
+ return [
1286
+ `Created ${dir} from the ${template} template (${written.join(", ")}, onceworlds.json).`,
1287
+ "",
1288
+ "Next: read the folder's AGENTS.md (or read_guide), build the game, then check and deploy_preview."
1289
+ ].join("\n");
1290
+ }
1291
+ function formatFindings(findings, dir) {
1292
+ if (findings.length === 0) return `Checked ${dir}. Nothing to suggest.`;
1293
+ const warn = findings.filter((f) => f.level === "warn");
1294
+ return [
1295
+ ...findings.map((f) => `${f.level === "warn" ? "!" : "\xB7"} ${f.file}:${f.line} ${f.message}`),
1296
+ "",
1297
+ `${warn.length} likely problem${warn.length === 1 ? "" : "s"}, ${findings.length - warn.length} note${findings.length - warn.length === 1 ? "" : "s"}.`
1298
+ ].join("\n");
1299
+ }
1300
+ function ago(ms) {
1301
+ const seconds = Math.max(0, Math.round((Date.now() - ms) / 1e3));
1302
+ if (seconds < 60) return "just now";
1303
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
1304
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
1305
+ return `${Math.floor(seconds / 86400)} d ago`;
1306
+ }
1307
+ function formatStatus(raw) {
1308
+ const status = raw;
1309
+ const lines = [`${status.name} (${status.slug}): ${status.live ? "published" : "not published yet"}. ${status.createUrl}`];
1310
+ if (!status.draft) lines.push("No draft yet: call deploy_preview.");
1311
+ else lines.push(`Latest draft ${status.draft.id}, pushed ${ago(status.draft.createdAt)}.`);
1312
+ if (status.errors.length === 0) lines.push(status.draft ? "No errors reported from it (the user may not have played it yet)." : "");
1313
+ else lines.push("", "Errors players hit:", ...status.errors.map((e) => ` ${e.message} (${e.source}${e.count > 1 ? `, ${e.count} times` : ""}, ${ago(e.lastSeen)})`));
1314
+ return lines.filter((line, i, all) => line !== "" || all[i - 1] !== "").join("\n").trim();
1315
+ }
1316
+
868
1317
  // src/index.ts
1318
+ var CLI_VERSION = "0.5.0";
869
1319
  var PRODUCTION_API = "https://onceworlds.com";
870
1320
  var LOCAL_API = "http://localhost:8787";
871
1321
  var USAGE = `Usage:
1322
+ onceworlds login Connect this computer to your Onceworlds account (approve a code in your browser)
1323
+ onceworlds logout Forget this computer's login
1324
+ onceworlds whoami Who you're logged in as
1325
+ onceworlds mcp Run as an MCP server, so Claude Code, Codex and other AI tools can build games
1326
+ on your account (they push drafts; only you publish, on onceworlds.com/create)
872
1327
  onceworlds init [project-dir] Set a game folder up for Onceworlds: writes onceworlds.json, the GitHub workflow
873
1328
  that publishes every push, and AGENTS.md and CLAUDE.md for AI coding tools
874
- onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
1329
+ onceworlds deploy [project-dir] Upload the game (to ${PRODUCTION_API}): from GitHub Actions it goes live; after login
1330
+ it is a draft you play and publish on onceworlds.com/create
875
1331
  onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
876
1332
  onceworlds doctor [project-dir] Look for what games often ship with: no viewport tag, small text fields,
877
1333
  localStorage, dialogs, Date.now() for shared time, keys with no touch
@@ -885,8 +1341,9 @@ Options:
885
1341
  --force init: replace onceworlds.json, the workflow and the guide when they exist already
886
1342
  (never your own AGENTS.md or CLAUDE.md)
887
1343
 
888
- Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
889
- workflow runs this on every push, and GitHub vouches for the repo.
1344
+ Deploys to onceworlds.com from a repo run from GitHub Actions: the repo's Onceworlds
1345
+ workflow runs this on every push, and GitHub vouches for the repo. From your own
1346
+ computer, onceworlds login lets you (and your AI tool) push drafts of games on your account.
890
1347
 
891
1348
  The project directory needs an onceworlds.json:
892
1349
  { "slug": "my-game", "name": "My Game", "dir": "." }
@@ -912,17 +1369,25 @@ async function main() {
912
1369
  console.log(USAGE);
913
1370
  return;
914
1371
  }
915
- const projectDir = resolve(projectArg ?? ".");
916
- const api = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
917
- if (command === "init") {
1372
+ const projectDir = resolve2(projectArg ?? ".");
1373
+ const api2 = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
1374
+ if (command === "login") {
1375
+ await login(api2);
1376
+ } else if (command === "logout") {
1377
+ await logout(api2);
1378
+ } else if (command === "whoami") {
1379
+ await whoami(api2);
1380
+ } else if (command === "mcp") {
1381
+ await serveMcp(mcpHost(api2));
1382
+ } else if (command === "init") {
918
1383
  if (!(await stat2(projectDir).catch(() => null))?.isDirectory()) throw new CliError(`${projectDir} isn't a folder.`);
919
- await init(projectDir, { api, force: values.force === true });
1384
+ await init(projectDir, { api: api2, force: values.force === true });
920
1385
  } else if (command === "deploy") {
921
- await deploy(projectDir, api, values.token);
1386
+ await deploy(projectDir, api2, values.token);
922
1387
  } else if (command === "dev") {
923
1388
  const players = Number(values.players ?? 1);
924
1389
  if (!Number.isInteger(players) || players < 1 || players > 9) throw new CliError("--players is a number from 1 to 9.");
925
- await dev(projectDir, api, values.token, players);
1390
+ await dev(projectDir, api2, values.token, players);
926
1391
  } else if (command === "doctor") {
927
1392
  await doctor(projectDir, values.strict === true);
928
1393
  } else {
@@ -931,9 +1396,9 @@ async function main() {
931
1396
  ${USAGE}`);
932
1397
  }
933
1398
  }
934
- async function deploy(projectDir, api, tokenArg, quiet = false) {
1399
+ async function deploy(projectDir, api2, tokenArg, quiet = false, dev2 = false) {
935
1400
  const config = await loadConfig(projectDir);
936
- const root = resolve(projectDir, config.dir ?? ".");
1401
+ const root = resolve2(projectDir, config.dir ?? ".");
937
1402
  const { files, skipped } = await collectFiles(root);
938
1403
  if (skipped.length > 0) {
939
1404
  console.warn(` ! ${skipped.length} unsupported file${skipped.length === 1 ? "" : "s"} not deployed:`);
@@ -951,9 +1416,9 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
951
1416
  const total = files.reduce((sum, f) => sum + f.size, 0);
952
1417
  if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
953
1418
  const { store, images } = await loadStore(projectDir, config);
954
- if (!quiet) console.log(`Deploying ${config.slug} (${files.length} files, ${formatBytes(total)}) to ${api}`);
955
- const token = await getToken(api, tokenArg);
956
- const start = await send("POST", `${api}/api/deploy`, token, {
1419
+ if (!quiet) console.log(`Deploying ${config.slug} (${files.length} files, ${formatBytes(total)}) to ${api2}`);
1420
+ const token = await getToken(api2, tokenArg, dev2);
1421
+ const start = await send("POST", `${api2}/api/deploy`, token, {
957
1422
  config: {
958
1423
  slug: config.slug,
959
1424
  name: config.name,
@@ -970,28 +1435,29 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
970
1435
  console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
971
1436
  }
972
1437
  await runPool(uploads, 6, async (file) => {
973
- const body = await readFile2(file.abs);
974
- await send("PUT", `${api}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
1438
+ const body = await readFile3(file.abs);
1439
+ await send("PUT", `${api2}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
975
1440
  });
976
- const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
1441
+ const result = await send("POST", `${api2}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
977
1442
  if (!quiet) {
978
1443
  const branch = result.productionBranch ?? start.productionBranch;
979
- if (result.production) console.log(`\u2713 Live: ${result.playUrl}`);
1444
+ if (result.draft) console.log(`\u2713 Draft: play it and publish it at ${result.createUrl}`);
1445
+ else if (result.production) console.log(`\u2713 Live: ${result.playUrl}`);
980
1446
  else console.log(`\u2713 Preview${branch ? ` (only ${branch} goes live)` : ""}: ${result.previewUrl}`);
981
1447
  for (const note of result.notes ?? []) console.log(` ${note}`);
982
1448
  }
983
1449
  return result;
984
1450
  }
985
- async function dev(projectDir, api, tokenArg, players = 1) {
1451
+ async function dev(projectDir, api2, tokenArg, players = 1) {
986
1452
  const config = await loadConfig(projectDir);
987
- const root = resolve(projectDir, config.dir ?? ".");
988
- const first = await deploy(projectDir, api, tokenArg);
1453
+ const root = resolve2(projectDir, config.dir ?? ".");
1454
+ const first = await deploy(projectDir, api2, tokenArg, false, true);
989
1455
  console.log(`
990
1456
  Play (reloads on every change): ${first.playUrl}?watch`);
991
1457
  for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
992
1458
  console.log(` Watching ${root}. Press Ctrl+C to stop.
993
1459
  `);
994
- const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile2(join2(projectDir, "onceworlds.json"), "utf8").catch(() => "");
1460
+ const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile3(join4(projectDir, "onceworlds.json"), "utf8").catch(() => "");
995
1461
  let lastSignature = await projectSignature();
996
1462
  let timer;
997
1463
  let running = false;
@@ -1005,7 +1471,7 @@ async function dev(projectDir, api, tokenArg, players = 1) {
1005
1471
  try {
1006
1472
  const current = await projectSignature();
1007
1473
  if (current !== lastSignature) {
1008
- await deploy(projectDir, api, tokenArg, true);
1474
+ await deploy(projectDir, api2, tokenArg, true, true);
1009
1475
  lastSignature = current;
1010
1476
  console.log(` \u21BB ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} redeployed`);
1011
1477
  }
@@ -1034,13 +1500,13 @@ async function readSources(files) {
1034
1500
  const sources = [];
1035
1501
  for (const file of files) {
1036
1502
  if (!/\.(html?|m?js|css)$/i.test(file.path) || file.size > 15e5) continue;
1037
- sources.push({ path: file.path, text: await readFile2(file.abs, "utf8") });
1503
+ sources.push({ path: file.path, text: await readFile3(file.abs, "utf8") });
1038
1504
  }
1039
1505
  return sources;
1040
1506
  }
1041
1507
  async function doctor(projectDir, strict) {
1042
1508
  const config = await loadConfig(projectDir);
1043
- const root = resolve(projectDir, config.dir ?? ".");
1509
+ const root = resolve2(projectDir, config.dir ?? ".");
1044
1510
  const { files } = await collectFiles(root);
1045
1511
  const sources = await readSources(files);
1046
1512
  const findings = [...checkGame(sources), ...checkListing(config)];
@@ -1056,10 +1522,10 @@ ${warnings} likely problem${warnings === 1 ? "" : "s"}, ${findings.length - warn
1056
1522
  if (strict && warnings > 0) process.exitCode = 1;
1057
1523
  }
1058
1524
  async function loadConfig(projectDir) {
1059
- const file = join2(projectDir, "onceworlds.json");
1525
+ const file = join4(projectDir, "onceworlds.json");
1060
1526
  let config;
1061
1527
  try {
1062
- config = JSON.parse(await readFile2(file, "utf8"));
1528
+ config = JSON.parse(await readFile3(file, "utf8"));
1063
1529
  } catch (err) {
1064
1530
  const missing = err.code === "ENOENT";
1065
1531
  throw new CliError(missing ? `No onceworlds.json in ${projectDir}.
@@ -1076,11 +1542,11 @@ async function loadStore(projectDir, config) {
1076
1542
  const images = /* @__PURE__ */ new Map();
1077
1543
  const image = async (value, slot, field) => {
1078
1544
  if (typeof value !== "string" || !value.trim()) throw new CliError(`"${field}" in onceworlds.json must be the path of an image, like "store/icon.png".`);
1079
- const abs = resolve(projectDir, value);
1545
+ const abs = resolve2(projectDir, value);
1080
1546
  if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
1081
1547
  let data;
1082
1548
  try {
1083
- data = await readFile2(abs);
1549
+ data = await readFile3(abs);
1084
1550
  } catch {
1085
1551
  throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
1086
1552
  }
@@ -1144,13 +1610,13 @@ async function collectFiles(root) {
1144
1610
  async function walk(dir, prefix) {
1145
1611
  let entries;
1146
1612
  try {
1147
- entries = await readdir2(dir, { withFileTypes: true });
1613
+ entries = await readdir3(dir, { withFileTypes: true });
1148
1614
  } catch {
1149
1615
  throw new CliError(`Cannot read ${dir}. Check "dir" in onceworlds.json.`);
1150
1616
  }
1151
1617
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1152
1618
  if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name)) continue;
1153
- const abs = join2(dir, entry.name);
1619
+ const abs = join4(dir, entry.name);
1154
1620
  const path = prefix + entry.name;
1155
1621
  if (entry.isDirectory()) {
1156
1622
  await walk(abs, `${path}/`);
@@ -1159,7 +1625,7 @@ async function collectFiles(root) {
1159
1625
  if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
1160
1626
  continue;
1161
1627
  }
1162
- const data = await readFile2(abs);
1628
+ const data = await readFile3(abs);
1163
1629
  if (data.byteLength > LIMITS.deployFileBytes) {
1164
1630
  throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
1165
1631
  }
@@ -1184,8 +1650,12 @@ function gitInfo(cwd) {
1184
1650
  };
1185
1651
  return { sha: run(["rev-parse", "HEAD"]), ref: run(["symbolic-ref", "-q", "HEAD"]) };
1186
1652
  }
1187
- async function getToken(api, tokenArg) {
1653
+ async function getToken(api2, tokenArg, dev2 = false) {
1188
1654
  if (tokenArg) return tokenArg;
1655
+ const local = isLocalApi(api2);
1656
+ if (dev2 && local) return "dev";
1657
+ const stored = await readCredential(api2);
1658
+ if (stored) return stored.token;
1189
1659
  const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
1190
1660
  const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
1191
1661
  if (requestUrl && requestToken) {
@@ -1195,12 +1665,38 @@ async function getToken(api, tokenArg) {
1195
1665
  if (!res.ok) throw new CliError(`Could not get a GitHub OIDC token (${res.status}).`);
1196
1666
  return (await res.json()).value;
1197
1667
  }
1198
- const host = new URL(api).hostname;
1199
- if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost")) return "dev";
1668
+ if (local) return "dev";
1200
1669
  throw new CliError(
1201
- "Deploys to Onceworlds run from GitHub Actions: push to GitHub and the repo's Onceworlds workflow deploys (it needs `permissions: id-token: write`)."
1670
+ "Not logged in. Run `onceworlds login` to push drafts of your games, or push to GitHub and let the repo's Onceworlds workflow deploy (it needs `permissions: id-token: write`)."
1202
1671
  );
1203
1672
  }
1673
+ function isLocalApi(api2) {
1674
+ const host = new URL(api2).hostname;
1675
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost");
1676
+ }
1677
+ async function whoami(api2) {
1678
+ const stored = await readCredential(api2);
1679
+ if (!stored) {
1680
+ console.log(`Not logged in to ${api2}. Run: onceworlds login`);
1681
+ return;
1682
+ }
1683
+ const me = await send("GET", `${api2}/api/cli/me`, stored.token, void 0);
1684
+ console.log(`@${me.username} on ${api2}`);
1685
+ }
1686
+ function mcpHost(api2) {
1687
+ return {
1688
+ api: api2,
1689
+ version: CLI_VERSION,
1690
+ deploy: (dir) => deploy(dir, api2, void 0),
1691
+ findings: async (dir) => {
1692
+ const config = await loadConfig(dir);
1693
+ const { files } = await collectFiles(resolve2(dir, config.dir ?? "."));
1694
+ return [...checkGame(await readSources(files)), ...checkListing(config)];
1695
+ },
1696
+ slugOf: async (dir) => (await loadConfig(dir).catch(() => null))?.slug ?? null,
1697
+ token: async () => (await readCredential(api2))?.token ?? null
1698
+ };
1699
+ }
1204
1700
  async function send(method, url, token, body) {
1205
1701
  const binary = body instanceof Uint8Array;
1206
1702
  let res;
@@ -1209,9 +1705,9 @@ async function send(method, url, token, body) {
1209
1705
  method,
1210
1706
  headers: {
1211
1707
  Authorization: `Bearer ${token}`,
1212
- "Content-Type": binary ? "application/octet-stream" : "application/json"
1708
+ ...body === void 0 ? {} : { "Content-Type": binary ? "application/octet-stream" : "application/json" }
1213
1709
  },
1214
- body: binary ? body : JSON.stringify(body)
1710
+ body: body === void 0 ? void 0 : binary ? body : JSON.stringify(body)
1215
1711
  });
1216
1712
  } catch {
1217
1713
  throw new CliError(`Could not reach ${new URL(url).origin}. Is the platform running? (npm run dev)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",