@onceworlds/cli 0.3.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 +14 -5
  2. package/dist/cli.js +907 -59
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,8 +9,9 @@ players) with no server code.
9
9
  Deploys run from GitHub Actions: GitHub vouches for the repo, so there are no API
10
10
  keys. The easiest setup is to import the repo at https://onceworlds.com/dashboard/new,
11
11
  which adds `onceworlds.json` and `.github/workflows/onceworlds.yml` for you (that
12
- workflow also builds with npm, pnpm, yarn or bun when there's a build script). By
13
- hand, the minimum is:
12
+ workflow also builds with npm, pnpm, yarn or bun when there's a build script). To write
13
+ the same files into a folder before it is on GitHub, run `npx @onceworlds/cli init` there.
14
+ By hand, the minimum is:
14
15
 
15
16
  ```yaml
16
17
  name: Deploy to Onceworlds
@@ -63,10 +64,11 @@ with the link) or Public (also listed on Onceworlds).
63
64
  `index.html`. `productionBranch` (default `main`) picks the branch that goes live;
64
65
  the dashboard can change it too, and the file wins again only when this value changes.
65
66
 
66
- `controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) and `help` (up to 400
67
- 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),
68
69
  16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
69
- `name`, `description` and an optional square `icon`). Images are PNG, JPEG, WebP
70
+ `name`, `description` and an optional square `icon`). A game page holds at most 5
71
+ thumbnails and videos together, and a game at most 50 badges. Images are PNG, JPEG, WebP
70
72
  or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
71
73
  these fields only when they change, so edits made in the dashboard last until then.
72
74
 
@@ -77,6 +79,13 @@ from the running game and commit them under `store/`. `onceworlds doctor` says w
77
79
 
78
80
  ## Commands
79
81
 
82
+ - `onceworlds init [dir]` sets a game folder up, with the files the dashboard's import commits. It writes
83
+ `onceworlds.json` (the link and name come from `package.json`'s name or the folder's; `dir` is your build's
84
+ output folder when `package.json` has a build script, else `.` or the folder with your `index.html`),
85
+ `.github/workflows/onceworlds.yml`, and the guide for AI coding tools as `AGENTS.md` with a `CLAUDE.md` that
86
+ imports it (next to a `AGENTS.md` of your own it writes `ONCEWORLDS.md` and adds a pointer to yours). It never
87
+ overwrites a file you have: `--force` replaces only what init writes, never your own `AGENTS.md` or `CLAUDE.md`.
88
+ Then commit, push, and import the repo at https://onceworlds.com/dashboard/new.
80
89
  - `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
81
90
  (the workflow runs it). It lists every file it skipped because Onceworlds can't serve it.
82
91
  - `onceworlds dev [dir]` deploys to a local Onceworlds platform (for platform development)
package/dist/cli.js CHANGED
@@ -1,20 +1,65 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { execFileSync } from "node:child_process";
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, readFile } from "node:fs/promises";
8
- import { join, 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 }];
@@ -74,12 +119,12 @@ function checkListing(config, file = "onceworlds.json") {
74
119
  note("listing-icon", `No "icon": the game's card shows a plain colored placeholder. Add a square image (512\xD7512, simple, no words), like "icon": "store/icon.png".`);
75
120
  }
76
121
  if (!Array.isArray(config.thumbnails) || config.thumbnails.length === 0) {
77
- note("listing-thumbnails", 'No "thumbnails": the game page and its card have no cover. Add three or more 16:9 pictures (1280\xD7720) of the game being played; the first is the cover.');
122
+ note("listing-thumbnails", 'No "thumbnails": the game page and its card have no cover. Add 3-5 16:9 pictures (1280\xD7720) of the game being played; the first is the cover.');
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
  }
@@ -168,6 +213,12 @@ function checkGame(files) {
168
213
  return found.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
169
214
  }
170
215
 
216
+ // src/init.ts
217
+ import { execFileSync } from "node:child_process";
218
+ import { randomBytes } from "node:crypto";
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";
221
+
171
222
  // ../protocol/src/index.ts
172
223
  var OIDC_AUDIENCE = "onceworlds";
173
224
  var LIMITS = {
@@ -214,6 +265,11 @@ var LIMITS = {
214
265
  idleAfterMs: 3e4,
215
266
  /** A running match whose connected players have all been idle this long is over: nobody is playing. */
216
267
  idleAbandonMs: 5 * 6e4,
268
+ /**
269
+ * Rated matches two accounts on opposite sides may play against each other in one game's ladder per UTC day: past it,
270
+ * their matches aren't rated, so two accounts that keep getting matched in a quiet game can't farm wins.
271
+ */
272
+ rankedPairMatchesPerDay: 5,
217
273
  chatLength: 200,
218
274
  chatIntervalMs: 750,
219
275
  chatHistory: 50,
@@ -234,7 +290,7 @@ var LIMITS = {
234
290
  envValueBytes: 4 * 1024,
235
291
  /** All of a game's public variables together, as injected into its pages. */
236
292
  envTotalBytes: 16 * 1024,
237
- /** Proxied request bodies: routes that take a file (audio to transcribe, an image to edit, model inputs on Replicate and fal). */
293
+ /** Proxied request bodies: routes that take a file (audio to transcribe, an image to edit). */
238
294
  proxyRequestBytes: 1024 * 1024,
239
295
  /** Proxied JSON bodies for text, embeddings and image generation. */
240
296
  proxyTextRequestBytes: 256 * 1024,
@@ -264,7 +320,7 @@ var LIMITS = {
264
320
  errorReportsPerPage: 10,
265
321
  gameNameLength: 60,
266
322
  gameDescriptionLength: 1e3,
267
- galleryItems: 10,
323
+ galleryItems: 5,
268
324
  mediaImageBytes: 5 * 1024 * 1024,
269
325
  mediaVideoBytes: 30 * 1024 * 1024,
270
326
  /** GIFs are served as they are (never resized), so they're small: at most this many bytes and pixels a side. */
@@ -273,7 +329,7 @@ var LIMITS = {
273
329
  /** Media an account uploads in the dashboard (or a repo owner's deploys add to game pages) in any 24 hours. */
274
330
  mediaUploadsPerDay: 200,
275
331
  mediaBytesPerDay: 500 * 1024 * 1024,
276
- badgesPerGame: 100,
332
+ badgesPerGame: 50,
277
333
  /** The menu's "How to play": up to this many controls (each with a few keys and a short action), and a few lines of help. */
278
334
  controlHints: 12,
279
335
  controlHintKeys: 4,
@@ -287,13 +343,27 @@ var LIMITS = {
287
343
  gamesPerOwner: 50,
288
344
  /** New games per creator in any rolling 24-hour period. Redeploys do not count. */
289
345
  gamesCreatedPerCreatorPerDay: 3,
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. */
347
+ uploadAccountAgeMs: 7 * 24 * 60 * 6e4,
290
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,
291
357
  /** Finished versions retained per game, including its live version. */
292
358
  deploymentsKeptPerGame: 10,
293
359
  deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
294
360
  /** Files listed by all of a repo owner's deploys in any 24 hours (each is a record to keep and clean up). */
295
361
  deployEntriesPerOwnerPerDay: 1e5,
296
362
  bioLength: 500,
363
+ /** Settings > Download my data: rows from any one table, the characters of saves, and how often an account may ask. */
364
+ exportRows: 2e3,
365
+ exportSaveChars: 4 * 1024 * 1024,
366
+ exportEveryMs: 60 * 6e4,
297
367
  friendsMax: 200,
298
368
  /** Requests one player has sent that are still waiting. */
299
369
  friendRequestsMax: 100,
@@ -417,10 +487,213 @@ function isValidDeployPath(path) {
417
487
  }
418
488
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
419
489
  var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "static", "assets", "docs", "status"]);
490
+ var NEW_GAME_RESERVED_SLUGS = /* @__PURE__ */ new Set([
491
+ "star-party",
492
+ "login",
493
+ "log-in",
494
+ "logon",
495
+ "logout",
496
+ "signin",
497
+ "sign-in",
498
+ "signup",
499
+ "sign-up",
500
+ "register",
501
+ "auth",
502
+ "oauth",
503
+ "sso",
504
+ "verify",
505
+ "verified",
506
+ "verification",
507
+ "support",
508
+ "help",
509
+ "helpdesk",
510
+ "contact",
511
+ "official",
512
+ "staff",
513
+ "team",
514
+ "moderator",
515
+ "moderators",
516
+ "mod",
517
+ "mods",
518
+ "security",
519
+ "account",
520
+ "accounts",
521
+ "password",
522
+ "passwords",
523
+ "reset",
524
+ "billing",
525
+ "payment",
526
+ "payments",
527
+ "robux",
528
+ "vbucks",
529
+ "v-bucks",
530
+ "nitro",
531
+ "gift",
532
+ "gifts",
533
+ "giftcard",
534
+ "free",
535
+ "settings",
536
+ "dashboard",
537
+ "discover",
538
+ "games",
539
+ "users",
540
+ "friends",
541
+ "notifications",
542
+ "terms",
543
+ "privacy",
544
+ "changelog",
545
+ "create",
546
+ "guide",
547
+ "about",
548
+ "blog",
549
+ "news",
550
+ "mail",
551
+ "email"
552
+ ]);
553
+ var NEW_GAME_RESERVED_PREFIXES = ["onceworlds", "admin", "free-"];
554
+ var SLUG_AUTH_WORDS = /* @__PURE__ */ new Set(["login", "logon", "signin", "signon", "signup", "verify", "verification", "password", "passwords", "billing"]);
555
+ var BRANDS = ["onceworlds", "roblox", "robux", "vbucks", "fortnite", "minecraft", "xbox", "playstation", "nintendo"];
556
+ var LOOKALIKES = {
557
+ "0": "o",
558
+ "3": "e",
559
+ "4": "a",
560
+ "5": "s",
561
+ "7": "t",
562
+ "8": "b",
563
+ "@": "a",
564
+ "$": "s",
565
+ "|": "l",
566
+ "!": "i",
567
+ "\u0430": "a",
568
+ "\u0432": "b",
569
+ "\u0435": "e",
570
+ "\u043A": "k",
571
+ "\u043C": "m",
572
+ "\u043D": "h",
573
+ "\u043E": "o",
574
+ "\u0440": "p",
575
+ "\u0441": "c",
576
+ "\u0442": "t",
577
+ "\u0443": "y",
578
+ "\u0445": "x",
579
+ "\u0455": "s",
580
+ "\u0456": "i",
581
+ "\u0458": "j",
582
+ "\u04CF": "l",
583
+ "\u0501": "d",
584
+ "\u051D": "w",
585
+ "\u03B1": "a",
586
+ "\u03B2": "b",
587
+ "\u03B5": "e",
588
+ "\u03B9": "i",
589
+ "\u03BA": "k",
590
+ "\u03BD": "v",
591
+ "\u03BF": "o",
592
+ "\u03C1": "p",
593
+ "\u03C4": "t",
594
+ "\u03C5": "u",
595
+ "\u03C7": "x",
596
+ "\u03C9": "w"
597
+ };
420
598
  function isValidSlug(slug) {
421
599
  return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
422
600
  }
601
+ function isReservedSlug(slug, newGame2 = true) {
602
+ if (RESERVED_SLUGS.has(slug)) return true;
603
+ if (!newGame2) return false;
604
+ if (NEW_GAME_RESERVED_SLUGS.has(slug) || NEW_GAME_RESERVED_PREFIXES.some((prefix) => slug.startsWith(prefix))) return true;
605
+ if (!SLUG_RE.test(slug)) return false;
606
+ const parts = slug.split("-");
607
+ if (parts.some((part, i) => SLUG_AUTH_WORDS.has(part) || SLUG_AUTH_WORDS.has(part + (parts[i + 1] ?? "")))) return true;
608
+ return hasBrand(slug);
609
+ }
610
+ function foldedWords(text2) {
611
+ const base = text2.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "");
612
+ const variants = [];
613
+ for (const one of ["l", "i"]) {
614
+ const mapped = [...base].map((c) => c === "1" ? one : LOOKALIKES[c] ?? c);
615
+ const words = mapped.map((c) => /[a-z]/.test(c) ? c : /[0-9]/.test(c) ? "" : " ").join("").split(" ").filter(Boolean);
616
+ variants.push(words, words.map((w) => w.replace(/vv/g, "w")));
617
+ }
618
+ return variants;
619
+ }
620
+ function hasBrand(text2) {
621
+ for (const words of foldedWords(text2)) {
622
+ if (words.some((word) => BRANDS.some((brand) => word.includes(brand)))) return true;
623
+ for (let i = 0; i < words.length; i++) {
624
+ let joined = words[i];
625
+ for (let j = i + 1; j < words.length && joined.length < 16; j++) {
626
+ joined += words[j];
627
+ if (BRANDS.includes(joined)) return true;
628
+ }
629
+ }
630
+ }
631
+ return false;
632
+ }
423
633
  var DEFAULT_PRODUCTION_BRANCH = "main";
634
+ function deployWorkflow() {
635
+ return `name: Deploy to Onceworlds
636
+
637
+ # Branches only: tags don't deploy.
638
+ on:
639
+ push:
640
+ branches: ['**']
641
+
642
+ # A newer push to the same branch replaces a deploy still running.
643
+ concurrency:
644
+ group: onceworlds-\${{ github.ref }}
645
+ cancel-in-progress: true
646
+
647
+ permissions:
648
+ contents: read
649
+ id-token: write # lets the Onceworlds CLI prove which repo is deploying; no API keys needed
650
+
651
+ jobs:
652
+ deploy:
653
+ runs-on: ubuntu-latest
654
+ steps:
655
+ # The build never needs the repo's GitHub token, so it isn't left in .git/config.
656
+ - uses: actions/checkout@v5
657
+ with:
658
+ persist-credentials: false
659
+
660
+ - uses: actions/setup-node@v5
661
+ with:
662
+ node-version: 22
663
+
664
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
665
+ if: hashFiles('bun.lock', 'bun.lockb') != ''
666
+
667
+ # Only if package.json has a build script, with the package manager its lockfile belongs to.
668
+ - name: Build
669
+ run: |
670
+ if [ ! -f package.json ] || [ "$(npm pkg get scripts.build)" = "{}" ]; then
671
+ exit 0
672
+ elif [ -f pnpm-lock.yaml ]; then
673
+ corepack enable
674
+ pnpm install --frozen-lockfile
675
+ pnpm run build
676
+ elif [ -f yarn.lock ]; then
677
+ corepack enable
678
+ yarn install
679
+ yarn run build
680
+ elif [ -f bun.lock ] || [ -f bun.lockb ]; then
681
+ bun install
682
+ bun run build
683
+ elif [ -f package-lock.json ]; then
684
+ npm ci
685
+ npm run build
686
+ else
687
+ npm install
688
+ npm run build
689
+ fi
690
+
691
+ # Pushes to the production branch (set in the dashboard or onceworlds.json,
692
+ # default "main") go live; other branches get a preview URL in the job log.
693
+ - name: Deploy
694
+ run: npx --yes @onceworlds/cli@0 deploy
695
+ `;
696
+ }
424
697
  var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
425
698
  var GENRES = [
426
699
  "Action",
@@ -440,7 +713,7 @@ var GENRES = [
440
713
  "Party",
441
714
  "Other"
442
715
  ];
443
- 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();
444
717
  function checkGuide(controls, help) {
445
718
  const hints = [];
446
719
  if (controls !== void 0 && controls !== null) {
@@ -454,20 +727,20 @@ function checkGuide(controls, help) {
454
727
  }
455
728
  const cleaned = keys.map((k) => typeof k === "string" ? tidy(k) : "");
456
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"`);
457
- const text2 = typeof action === "string" ? tidy(action) : "";
458
- if (!text2 || text2.length > LIMITS.controlActionLength) throw new Error(`${name}.action is what the keys do, in at most ${LIMITS.controlActionLength} characters`);
459
- 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 });
460
733
  });
461
734
  }
462
735
  if (help !== void 0 && help !== null && typeof help !== "string") throw new Error('"help" must be text');
463
- const text = typeof help === "string" ? help.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim() : "";
464
- if (text.length > LIMITS.helpLength) throw new Error(`"help" can be at most ${LIMITS.helpLength} characters`);
465
- 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 };
466
739
  }
467
740
  var MAX_IMAGE_SIDE = 4096;
468
741
  function imageInfo(bytes) {
469
742
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
470
- 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));
471
744
  if (bytes.length >= 24 && bytes[0] === 137 && ascii(1, "PNG\r\n\n") && ascii(12, "IHDR")) {
472
745
  return { type: "image/png", width: view.getUint32(16), height: view.getUint32(20) };
473
746
  }
@@ -521,11 +794,540 @@ function imageSizeProblem(slot, width, height) {
521
794
  return null;
522
795
  }
523
796
 
797
+ // src/init.ts
798
+ var OUTPUT_DIRS = ["dist", "build", "out", "public", "web", "www", "export"];
799
+ var WORKFLOW_PATH = ".github/workflows/onceworlds.yml";
800
+ async function init(dir, options) {
801
+ const log = options.log ?? console.log;
802
+ const { force } = options;
803
+ const at = (path) => join2(dir, path);
804
+ const read = (path) => readFile2(at(path), "utf8").catch(() => null);
805
+ const exists = (path) => stat(at(path)).then(() => true, () => false);
806
+ const lines = [];
807
+ let kept = false;
808
+ const put = async (path, content, label = path) => {
809
+ try {
810
+ await mkdir2(dirname2(at(path)), { recursive: true });
811
+ await writeFile2(at(path), content, { flag: force ? "w" : "wx" });
812
+ lines.push(` wrote ${label}`);
813
+ } catch (err) {
814
+ if (err.code !== "EEXIST") throw err;
815
+ lines.push(` kept ${path} (it exists already)`);
816
+ kept = true;
817
+ }
818
+ };
819
+ const append = async (path, existing, line, label) => {
820
+ await writeFile2(at(path), `${existing.trimEnd()}
821
+
822
+ ${line}
823
+ `);
824
+ lines.push(` wrote ${label}`);
825
+ };
826
+ const pkg = await readPackage(dir);
827
+ const source = typeof pkg?.name === "string" && pkg.name.replace(/^@[^/]+\//, "") || basename(dir);
828
+ const fromName = slugFrom(source);
829
+ const slug = fromName ?? `game-${randomBytes(3).toString("hex")}`;
830
+ const found = await findDir(pkg, exists);
831
+ const branch = defaultBranch(dir);
832
+ const config = { slug, name: titleFrom(source), description: "", dir: found.dir, ...branch ? { productionBranch: branch } : {} };
833
+ await put("onceworlds.json", `${JSON.stringify(config, null, 2)}
834
+ `, `onceworlds.json (link "${slug}", publishing "${found.dir}")`);
835
+ const present = await findWorkflow(dir);
836
+ if (present && present !== WORKFLOW_PATH) lines.push(` kept ${present} (it already deploys with the Onceworlds CLI)`);
837
+ else await put(WORKFLOW_PATH, deployWorkflow());
838
+ const guide = await fetchGuide(options.fetch ?? fetch, options.api, (line) => lines.push(line));
839
+ const agents = await read("AGENTS.md");
840
+ const separate = agents !== null && !/^# Onceworlds\b/.test(agents);
841
+ const guidePath = separate ? "ONCEWORLDS.md" : "AGENTS.md";
842
+ if (guide !== null) await put(guidePath, guide);
843
+ if (guide !== null || await exists(guidePath)) {
844
+ if (separate && !agents.includes("ONCEWORLDS.md")) {
845
+ await append("AGENTS.md", agents, "## Onceworlds\n\nThis game is published on Onceworlds. Read ONCEWORLDS.md for the platform rules and SDK.", "AGENTS.md (a pointer to ONCEWORLDS.md)");
846
+ }
847
+ const claude = await read("CLAUDE.md");
848
+ if (claude === null) await put("CLAUDE.md", `@${guidePath}
849
+ `);
850
+ else if (!claude.includes(guidePath)) await append("CLAUDE.md", claude, `@${guidePath}`, `CLAUDE.md (a pointer to ${guidePath})`);
851
+ }
852
+ for (const line of lines) log(line);
853
+ if (fromName === null) log(` note "${source}" can't be a game's link, so the link is ${slug}. Change slug in onceworlds.json.`);
854
+ if (found.note) log(` note ${found.note}`);
855
+ if (kept) log(" note --force replaces the files init writes. Your own AGENTS.md and CLAUDE.md are never replaced.");
856
+ log(`
857
+ Next:
858
+ 1. Check onceworlds.json: "slug" is your game's link (onceworlds.com/play/<slug>) and "name" is its title.
859
+ 2. Commit these files and push the repo to GitHub.
860
+ 3. Open ${options.api}/dashboard/new, import the repo, and every push publishes your game.`);
861
+ }
862
+ async function readPackage(dir) {
863
+ try {
864
+ const parsed = JSON.parse(await readFile2(join2(dir, "package.json"), "utf8"));
865
+ return parsed && typeof parsed === "object" ? parsed : null;
866
+ } catch {
867
+ return null;
868
+ }
869
+ }
870
+ function slugFrom(name) {
871
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
872
+ return isValidSlug(slug) && !isReservedSlug(slug) ? slug : null;
873
+ }
874
+ function titleFrom(name) {
875
+ const title = name.replace(/[-_.]+/g, " ").trim().replace(/\s+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
876
+ return (title || "My Game").slice(0, LIMITS.gameNameLength);
877
+ }
878
+ async function findDir(pkg, exists) {
879
+ if (typeof pkg?.scripts?.build === "string") {
880
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
881
+ const out = deps.next ? "out" : deps["react-scripts"] || deps["@sveltejs/kit"] ? "build" : "dist";
882
+ return { dir: out, note: `"dir" is ${out}, where the build script is expected to write the game. Change it in onceworlds.json if yours writes elsewhere.` };
883
+ }
884
+ if (await exists("index.html")) return { dir: "." };
885
+ for (const name of OUTPUT_DIRS) if (await exists(`${name}/index.html`)) return { dir: name };
886
+ return { dir: ".", note: `No index.html here yet. Set "dir" in onceworlds.json to the folder that has your game's index.html.` };
887
+ }
888
+ function defaultBranch(dir) {
889
+ const git = (args) => {
890
+ try {
891
+ return execFileSync("git", args, { cwd: dir, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
892
+ } catch {
893
+ return null;
894
+ }
895
+ };
896
+ return git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"])?.replace(/^origin\//, "") ?? git(["symbolic-ref", "-q", "--short", "HEAD"]);
897
+ }
898
+ async function findWorkflow(dir) {
899
+ const folder = join2(dir, ".github/workflows");
900
+ const names = await readdir(folder).catch(() => []);
901
+ for (const name of names.filter((n) => /\.ya?ml$/.test(n)).slice(0, 20)) {
902
+ if ((await readFile2(join2(folder, name), "utf8").catch(() => "")).includes("@onceworlds/cli")) return `.github/workflows/${name}`;
903
+ }
904
+ return null;
905
+ }
906
+ async function fetchGuide(fetcher, api2, warn) {
907
+ const url = `${api2}/agents.md`;
908
+ let problem;
909
+ try {
910
+ const res = await fetcher(url, { signal: AbortSignal.timeout(1e4) });
911
+ const text2 = res.ok ? await res.text() : "";
912
+ if (text2.trim()) return text2;
913
+ problem = res.ok ? "it is empty" : `${res.status}`;
914
+ } catch (err) {
915
+ problem = err instanceof Error ? err.message : String(err);
916
+ }
917
+ warn(` ! Skipped AGENTS.md and CLAUDE.md: could not fetch ${url} (${problem}). Run init again when it works.`);
918
+ return null;
919
+ }
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
+
524
1317
  // src/index.ts
1318
+ var CLI_VERSION = "0.5.0";
525
1319
  var PRODUCTION_API = "https://onceworlds.com";
526
1320
  var LOCAL_API = "http://localhost:8787";
527
1321
  var USAGE = `Usage:
528
- onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
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)
1327
+ onceworlds init [project-dir] Set a game folder up for Onceworlds: writes onceworlds.json, the GitHub workflow
1328
+ that publishes every push, and AGENTS.md and CLAUDE.md for AI coding tools
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
529
1331
  onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
530
1332
  onceworlds doctor [project-dir] Look for what games often ship with: no viewport tag, small text fields,
531
1333
  localStorage, dialogs, Date.now() for shared time, keys with no touch
@@ -536,9 +1338,12 @@ Options:
536
1338
  --token <t> Local platform's deploy token (default: "dev")
537
1339
  --players <n> dev: also print links for players 2 to n, to play a multiplayer game with yourself
538
1340
  --strict doctor: exit with an error when it finds a likely bug (not for notes)
1341
+ --force init: replace onceworlds.json, the workflow and the guide when they exist already
1342
+ (never your own AGENTS.md or CLAUDE.md)
539
1343
 
540
- Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
541
- 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.
542
1347
 
543
1348
  The project directory needs an onceworlds.json:
544
1349
  { "slug": "my-game", "name": "My Game", "dir": "." }
@@ -555,6 +1360,7 @@ async function main() {
555
1360
  token: { type: "string" },
556
1361
  players: { type: "string" },
557
1362
  strict: { type: "boolean" },
1363
+ force: { type: "boolean" },
558
1364
  help: { type: "boolean", short: "h" }
559
1365
  }
560
1366
  });
@@ -563,14 +1369,25 @@ async function main() {
563
1369
  console.log(USAGE);
564
1370
  return;
565
1371
  }
566
- const projectDir = resolve(projectArg ?? ".");
567
- const api = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
568
- if (command === "deploy") {
569
- await deploy(projectDir, api, values.token);
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") {
1383
+ if (!(await stat2(projectDir).catch(() => null))?.isDirectory()) throw new CliError(`${projectDir} isn't a folder.`);
1384
+ await init(projectDir, { api: api2, force: values.force === true });
1385
+ } else if (command === "deploy") {
1386
+ await deploy(projectDir, api2, values.token);
570
1387
  } else if (command === "dev") {
571
1388
  const players = Number(values.players ?? 1);
572
1389
  if (!Number.isInteger(players) || players < 1 || players > 9) throw new CliError("--players is a number from 1 to 9.");
573
- await dev(projectDir, api, values.token, players);
1390
+ await dev(projectDir, api2, values.token, players);
574
1391
  } else if (command === "doctor") {
575
1392
  await doctor(projectDir, values.strict === true);
576
1393
  } else {
@@ -579,9 +1396,9 @@ async function main() {
579
1396
  ${USAGE}`);
580
1397
  }
581
1398
  }
582
- async function deploy(projectDir, api, tokenArg, quiet = false) {
1399
+ async function deploy(projectDir, api2, tokenArg, quiet = false, dev2 = false) {
583
1400
  const config = await loadConfig(projectDir);
584
- const root = resolve(projectDir, config.dir ?? ".");
1401
+ const root = resolve2(projectDir, config.dir ?? ".");
585
1402
  const { files, skipped } = await collectFiles(root);
586
1403
  if (skipped.length > 0) {
587
1404
  console.warn(` ! ${skipped.length} unsupported file${skipped.length === 1 ? "" : "s"} not deployed:`);
@@ -599,9 +1416,9 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
599
1416
  const total = files.reduce((sum, f) => sum + f.size, 0);
600
1417
  if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
601
1418
  const { store, images } = await loadStore(projectDir, config);
602
- if (!quiet) console.log(`Deploying ${config.slug} (${files.length} files, ${formatBytes(total)}) to ${api}`);
603
- const token = await getToken(api, tokenArg);
604
- 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, {
605
1422
  config: {
606
1423
  slug: config.slug,
607
1424
  name: config.name,
@@ -618,28 +1435,29 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
618
1435
  console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
619
1436
  }
620
1437
  await runPool(uploads, 6, async (file) => {
621
- const body = await readFile(file.abs);
622
- 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);
623
1440
  });
624
- 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, {});
625
1442
  if (!quiet) {
626
1443
  const branch = result.productionBranch ?? start.productionBranch;
627
- 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}`);
628
1446
  else console.log(`\u2713 Preview${branch ? ` (only ${branch} goes live)` : ""}: ${result.previewUrl}`);
629
1447
  for (const note of result.notes ?? []) console.log(` ${note}`);
630
1448
  }
631
1449
  return result;
632
1450
  }
633
- async function dev(projectDir, api, tokenArg, players = 1) {
1451
+ async function dev(projectDir, api2, tokenArg, players = 1) {
634
1452
  const config = await loadConfig(projectDir);
635
- const root = resolve(projectDir, config.dir ?? ".");
636
- const first = await deploy(projectDir, api, tokenArg);
1453
+ const root = resolve2(projectDir, config.dir ?? ".");
1454
+ const first = await deploy(projectDir, api2, tokenArg, false, true);
637
1455
  console.log(`
638
1456
  Play (reloads on every change): ${first.playUrl}?watch`);
639
1457
  for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
640
1458
  console.log(` Watching ${root}. Press Ctrl+C to stop.
641
1459
  `);
642
- const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
1460
+ const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile3(join4(projectDir, "onceworlds.json"), "utf8").catch(() => "");
643
1461
  let lastSignature = await projectSignature();
644
1462
  let timer;
645
1463
  let running = false;
@@ -653,7 +1471,7 @@ async function dev(projectDir, api, tokenArg, players = 1) {
653
1471
  try {
654
1472
  const current = await projectSignature();
655
1473
  if (current !== lastSignature) {
656
- await deploy(projectDir, api, tokenArg, true);
1474
+ await deploy(projectDir, api2, tokenArg, true, true);
657
1475
  lastSignature = current;
658
1476
  console.log(` \u21BB ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} redeployed`);
659
1477
  }
@@ -682,13 +1500,13 @@ async function readSources(files) {
682
1500
  const sources = [];
683
1501
  for (const file of files) {
684
1502
  if (!/\.(html?|m?js|css)$/i.test(file.path) || file.size > 15e5) continue;
685
- sources.push({ path: file.path, text: await readFile(file.abs, "utf8") });
1503
+ sources.push({ path: file.path, text: await readFile3(file.abs, "utf8") });
686
1504
  }
687
1505
  return sources;
688
1506
  }
689
1507
  async function doctor(projectDir, strict) {
690
1508
  const config = await loadConfig(projectDir);
691
- const root = resolve(projectDir, config.dir ?? ".");
1509
+ const root = resolve2(projectDir, config.dir ?? ".");
692
1510
  const { files } = await collectFiles(root);
693
1511
  const sources = await readSources(files);
694
1512
  const findings = [...checkGame(sources), ...checkListing(config)];
@@ -704,10 +1522,10 @@ ${warnings} likely problem${warnings === 1 ? "" : "s"}, ${findings.length - warn
704
1522
  if (strict && warnings > 0) process.exitCode = 1;
705
1523
  }
706
1524
  async function loadConfig(projectDir) {
707
- const file = join(projectDir, "onceworlds.json");
1525
+ const file = join4(projectDir, "onceworlds.json");
708
1526
  let config;
709
1527
  try {
710
- config = JSON.parse(await readFile(file, "utf8"));
1528
+ config = JSON.parse(await readFile3(file, "utf8"));
711
1529
  } catch (err) {
712
1530
  const missing = err.code === "ENOENT";
713
1531
  throw new CliError(missing ? `No onceworlds.json in ${projectDir}.
@@ -724,11 +1542,11 @@ async function loadStore(projectDir, config) {
724
1542
  const images = /* @__PURE__ */ new Map();
725
1543
  const image = async (value, slot, field) => {
726
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".`);
727
- const abs = resolve(projectDir, value);
1545
+ const abs = resolve2(projectDir, value);
728
1546
  if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
729
1547
  let data;
730
1548
  try {
731
- data = await readFile(abs);
1549
+ data = await readFile3(abs);
732
1550
  } catch {
733
1551
  throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
734
1552
  }
@@ -792,13 +1610,13 @@ async function collectFiles(root) {
792
1610
  async function walk(dir, prefix) {
793
1611
  let entries;
794
1612
  try {
795
- entries = await readdir(dir, { withFileTypes: true });
1613
+ entries = await readdir3(dir, { withFileTypes: true });
796
1614
  } catch {
797
1615
  throw new CliError(`Cannot read ${dir}. Check "dir" in onceworlds.json.`);
798
1616
  }
799
1617
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
800
1618
  if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name)) continue;
801
- const abs = join(dir, entry.name);
1619
+ const abs = join4(dir, entry.name);
802
1620
  const path = prefix + entry.name;
803
1621
  if (entry.isDirectory()) {
804
1622
  await walk(abs, `${path}/`);
@@ -807,7 +1625,7 @@ async function collectFiles(root) {
807
1625
  if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
808
1626
  continue;
809
1627
  }
810
- const data = await readFile(abs);
1628
+ const data = await readFile3(abs);
811
1629
  if (data.byteLength > LIMITS.deployFileBytes) {
812
1630
  throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
813
1631
  }
@@ -825,15 +1643,19 @@ function signature(files) {
825
1643
  function gitInfo(cwd) {
826
1644
  const run = (args) => {
827
1645
  try {
828
- return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
1646
+ return execFileSync2("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
829
1647
  } catch {
830
1648
  return void 0;
831
1649
  }
832
1650
  };
833
1651
  return { sha: run(["rev-parse", "HEAD"]), ref: run(["symbolic-ref", "-q", "HEAD"]) };
834
1652
  }
835
- async function getToken(api, tokenArg) {
1653
+ async function getToken(api2, tokenArg, dev2 = false) {
836
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;
837
1659
  const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
838
1660
  const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
839
1661
  if (requestUrl && requestToken) {
@@ -843,12 +1665,38 @@ async function getToken(api, tokenArg) {
843
1665
  if (!res.ok) throw new CliError(`Could not get a GitHub OIDC token (${res.status}).`);
844
1666
  return (await res.json()).value;
845
1667
  }
846
- const host = new URL(api).hostname;
847
- if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost")) return "dev";
1668
+ if (local) return "dev";
848
1669
  throw new CliError(
849
- "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`)."
850
1671
  );
851
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
+ }
852
1700
  async function send(method, url, token, body) {
853
1701
  const binary = body instanceof Uint8Array;
854
1702
  let res;
@@ -857,9 +1705,9 @@ async function send(method, url, token, body) {
857
1705
  method,
858
1706
  headers: {
859
1707
  Authorization: `Bearer ${token}`,
860
- "Content-Type": binary ? "application/octet-stream" : "application/json"
1708
+ ...body === void 0 ? {} : { "Content-Type": binary ? "application/octet-stream" : "application/json" }
861
1709
  },
862
- body: binary ? body : JSON.stringify(body)
1710
+ body: body === void 0 ? void 0 : binary ? body : JSON.stringify(body)
863
1711
  });
864
1712
  } catch {
865
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.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",