@onceworlds/cli 0.3.0 → 0.4.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 +12 -3
  2. package/dist/cli.js +370 -18
  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
@@ -66,7 +67,8 @@ the dashboard can change it too, and the file wins again only when this value ch
66
67
  `controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) and `help` (up to 400
67
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),
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,11 +1,11 @@
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 readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
8
+ import { join as join2, resolve, sep } from "node:path";
9
9
  import { parseArgs } from "node:util";
10
10
 
11
11
  // src/doctor.ts
@@ -74,7 +74,7 @@ function checkListing(config, file = "onceworlds.json") {
74
74
  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
75
  }
76
76
  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.');
77
+ 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
78
  }
79
79
  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
80
  const noControls = !Array.isArray(config.controls) || config.controls.length === 0;
@@ -168,6 +168,12 @@ function checkGame(files) {
168
168
  return found.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
169
169
  }
170
170
 
171
+ // src/init.ts
172
+ import { execFileSync } from "node:child_process";
173
+ import { randomBytes } from "node:crypto";
174
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
175
+ import { basename, dirname, join } from "node:path";
176
+
171
177
  // ../protocol/src/index.ts
172
178
  var OIDC_AUDIENCE = "onceworlds";
173
179
  var LIMITS = {
@@ -214,6 +220,11 @@ var LIMITS = {
214
220
  idleAfterMs: 3e4,
215
221
  /** A running match whose connected players have all been idle this long is over: nobody is playing. */
216
222
  idleAbandonMs: 5 * 6e4,
223
+ /**
224
+ * Rated matches two accounts on opposite sides may play against each other in one game's ladder per UTC day: past it,
225
+ * their matches aren't rated, so two accounts that keep getting matched in a quiet game can't farm wins.
226
+ */
227
+ rankedPairMatchesPerDay: 5,
217
228
  chatLength: 200,
218
229
  chatIntervalMs: 750,
219
230
  chatHistory: 50,
@@ -234,7 +245,7 @@ var LIMITS = {
234
245
  envValueBytes: 4 * 1024,
235
246
  /** All of a game's public variables together, as injected into its pages. */
236
247
  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). */
248
+ /** Proxied request bodies: routes that take a file (audio to transcribe, an image to edit). */
238
249
  proxyRequestBytes: 1024 * 1024,
239
250
  /** Proxied JSON bodies for text, embeddings and image generation. */
240
251
  proxyTextRequestBytes: 256 * 1024,
@@ -264,7 +275,7 @@ var LIMITS = {
264
275
  errorReportsPerPage: 10,
265
276
  gameNameLength: 60,
266
277
  gameDescriptionLength: 1e3,
267
- galleryItems: 10,
278
+ galleryItems: 5,
268
279
  mediaImageBytes: 5 * 1024 * 1024,
269
280
  mediaVideoBytes: 30 * 1024 * 1024,
270
281
  /** GIFs are served as they are (never resized), so they're small: at most this many bytes and pixels a side. */
@@ -273,7 +284,7 @@ var LIMITS = {
273
284
  /** Media an account uploads in the dashboard (or a repo owner's deploys add to game pages) in any 24 hours. */
274
285
  mediaUploadsPerDay: 200,
275
286
  mediaBytesPerDay: 500 * 1024 * 1024,
276
- badgesPerGame: 100,
287
+ badgesPerGame: 50,
277
288
  /** 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
289
  controlHints: 12,
279
290
  controlHintKeys: 4,
@@ -287,6 +298,8 @@ var LIMITS = {
287
298
  gamesPerOwner: 50,
288
299
  /** New games per creator in any rolling 24-hour period. Redeploys do not count. */
289
300
  gamesCreatedPerCreatorPerDay: 3,
301
+ /** 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
+ uploadAccountAgeMs: 7 * 24 * 60 * 6e4,
290
303
  deploysPerOwnerPerDay: 100,
291
304
  /** Finished versions retained per game, including its live version. */
292
305
  deploymentsKeptPerGame: 10,
@@ -294,6 +307,10 @@ var LIMITS = {
294
307
  /** Files listed by all of a repo owner's deploys in any 24 hours (each is a record to keep and clean up). */
295
308
  deployEntriesPerOwnerPerDay: 1e5,
296
309
  bioLength: 500,
310
+ /** Settings > Download my data: rows from any one table, the characters of saves, and how often an account may ask. */
311
+ exportRows: 2e3,
312
+ exportSaveChars: 4 * 1024 * 1024,
313
+ exportEveryMs: 60 * 6e4,
297
314
  friendsMax: 200,
298
315
  /** Requests one player has sent that are still waiting. */
299
316
  friendRequestsMax: 100,
@@ -417,10 +434,213 @@ function isValidDeployPath(path) {
417
434
  }
418
435
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
419
436
  var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "static", "assets", "docs", "status"]);
437
+ var NEW_GAME_RESERVED_SLUGS = /* @__PURE__ */ new Set([
438
+ "star-party",
439
+ "login",
440
+ "log-in",
441
+ "logon",
442
+ "logout",
443
+ "signin",
444
+ "sign-in",
445
+ "signup",
446
+ "sign-up",
447
+ "register",
448
+ "auth",
449
+ "oauth",
450
+ "sso",
451
+ "verify",
452
+ "verified",
453
+ "verification",
454
+ "support",
455
+ "help",
456
+ "helpdesk",
457
+ "contact",
458
+ "official",
459
+ "staff",
460
+ "team",
461
+ "moderator",
462
+ "moderators",
463
+ "mod",
464
+ "mods",
465
+ "security",
466
+ "account",
467
+ "accounts",
468
+ "password",
469
+ "passwords",
470
+ "reset",
471
+ "billing",
472
+ "payment",
473
+ "payments",
474
+ "robux",
475
+ "vbucks",
476
+ "v-bucks",
477
+ "nitro",
478
+ "gift",
479
+ "gifts",
480
+ "giftcard",
481
+ "free",
482
+ "settings",
483
+ "dashboard",
484
+ "discover",
485
+ "games",
486
+ "users",
487
+ "friends",
488
+ "notifications",
489
+ "terms",
490
+ "privacy",
491
+ "changelog",
492
+ "create",
493
+ "guide",
494
+ "about",
495
+ "blog",
496
+ "news",
497
+ "mail",
498
+ "email"
499
+ ]);
500
+ var NEW_GAME_RESERVED_PREFIXES = ["onceworlds", "admin", "free-"];
501
+ var SLUG_AUTH_WORDS = /* @__PURE__ */ new Set(["login", "logon", "signin", "signon", "signup", "verify", "verification", "password", "passwords", "billing"]);
502
+ var BRANDS = ["onceworlds", "roblox", "robux", "vbucks", "fortnite", "minecraft", "xbox", "playstation", "nintendo"];
503
+ var LOOKALIKES = {
504
+ "0": "o",
505
+ "3": "e",
506
+ "4": "a",
507
+ "5": "s",
508
+ "7": "t",
509
+ "8": "b",
510
+ "@": "a",
511
+ "$": "s",
512
+ "|": "l",
513
+ "!": "i",
514
+ "\u0430": "a",
515
+ "\u0432": "b",
516
+ "\u0435": "e",
517
+ "\u043A": "k",
518
+ "\u043C": "m",
519
+ "\u043D": "h",
520
+ "\u043E": "o",
521
+ "\u0440": "p",
522
+ "\u0441": "c",
523
+ "\u0442": "t",
524
+ "\u0443": "y",
525
+ "\u0445": "x",
526
+ "\u0455": "s",
527
+ "\u0456": "i",
528
+ "\u0458": "j",
529
+ "\u04CF": "l",
530
+ "\u0501": "d",
531
+ "\u051D": "w",
532
+ "\u03B1": "a",
533
+ "\u03B2": "b",
534
+ "\u03B5": "e",
535
+ "\u03B9": "i",
536
+ "\u03BA": "k",
537
+ "\u03BD": "v",
538
+ "\u03BF": "o",
539
+ "\u03C1": "p",
540
+ "\u03C4": "t",
541
+ "\u03C5": "u",
542
+ "\u03C7": "x",
543
+ "\u03C9": "w"
544
+ };
420
545
  function isValidSlug(slug) {
421
546
  return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
422
547
  }
548
+ function isReservedSlug(slug, newGame = true) {
549
+ if (RESERVED_SLUGS.has(slug)) return true;
550
+ if (!newGame) return false;
551
+ if (NEW_GAME_RESERVED_SLUGS.has(slug) || NEW_GAME_RESERVED_PREFIXES.some((prefix) => slug.startsWith(prefix))) return true;
552
+ if (!SLUG_RE.test(slug)) return false;
553
+ const parts = slug.split("-");
554
+ if (parts.some((part, i) => SLUG_AUTH_WORDS.has(part) || SLUG_AUTH_WORDS.has(part + (parts[i + 1] ?? "")))) return true;
555
+ return hasBrand(slug);
556
+ }
557
+ function foldedWords(text) {
558
+ const base = text.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "");
559
+ const variants = [];
560
+ for (const one of ["l", "i"]) {
561
+ const mapped = [...base].map((c) => c === "1" ? one : LOOKALIKES[c] ?? c);
562
+ const words = mapped.map((c) => /[a-z]/.test(c) ? c : /[0-9]/.test(c) ? "" : " ").join("").split(" ").filter(Boolean);
563
+ variants.push(words, words.map((w) => w.replace(/vv/g, "w")));
564
+ }
565
+ return variants;
566
+ }
567
+ function hasBrand(text) {
568
+ for (const words of foldedWords(text)) {
569
+ if (words.some((word) => BRANDS.some((brand) => word.includes(brand)))) return true;
570
+ for (let i = 0; i < words.length; i++) {
571
+ let joined = words[i];
572
+ for (let j = i + 1; j < words.length && joined.length < 16; j++) {
573
+ joined += words[j];
574
+ if (BRANDS.includes(joined)) return true;
575
+ }
576
+ }
577
+ }
578
+ return false;
579
+ }
423
580
  var DEFAULT_PRODUCTION_BRANCH = "main";
581
+ function deployWorkflow() {
582
+ return `name: Deploy to Onceworlds
583
+
584
+ # Branches only: tags don't deploy.
585
+ on:
586
+ push:
587
+ branches: ['**']
588
+
589
+ # A newer push to the same branch replaces a deploy still running.
590
+ concurrency:
591
+ group: onceworlds-\${{ github.ref }}
592
+ cancel-in-progress: true
593
+
594
+ permissions:
595
+ contents: read
596
+ id-token: write # lets the Onceworlds CLI prove which repo is deploying; no API keys needed
597
+
598
+ jobs:
599
+ deploy:
600
+ runs-on: ubuntu-latest
601
+ steps:
602
+ # The build never needs the repo's GitHub token, so it isn't left in .git/config.
603
+ - uses: actions/checkout@v5
604
+ with:
605
+ persist-credentials: false
606
+
607
+ - uses: actions/setup-node@v5
608
+ with:
609
+ node-version: 22
610
+
611
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
612
+ if: hashFiles('bun.lock', 'bun.lockb') != ''
613
+
614
+ # Only if package.json has a build script, with the package manager its lockfile belongs to.
615
+ - name: Build
616
+ run: |
617
+ if [ ! -f package.json ] || [ "$(npm pkg get scripts.build)" = "{}" ]; then
618
+ exit 0
619
+ elif [ -f pnpm-lock.yaml ]; then
620
+ corepack enable
621
+ pnpm install --frozen-lockfile
622
+ pnpm run build
623
+ elif [ -f yarn.lock ]; then
624
+ corepack enable
625
+ yarn install
626
+ yarn run build
627
+ elif [ -f bun.lock ] || [ -f bun.lockb ]; then
628
+ bun install
629
+ bun run build
630
+ elif [ -f package-lock.json ]; then
631
+ npm ci
632
+ npm run build
633
+ else
634
+ npm install
635
+ npm run build
636
+ fi
637
+
638
+ # Pushes to the production branch (set in the dashboard or onceworlds.json,
639
+ # default "main") go live; other branches get a preview URL in the job log.
640
+ - name: Deploy
641
+ run: npx --yes @onceworlds/cli@0 deploy
642
+ `;
643
+ }
424
644
  var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
425
645
  var GENRES = [
426
646
  "Action",
@@ -521,10 +741,136 @@ function imageSizeProblem(slot, width, height) {
521
741
  return null;
522
742
  }
523
743
 
744
+ // src/init.ts
745
+ var OUTPUT_DIRS = ["dist", "build", "out", "public", "web", "www", "export"];
746
+ var WORKFLOW_PATH = ".github/workflows/onceworlds.yml";
747
+ async function init(dir, options) {
748
+ const log = options.log ?? console.log;
749
+ const { force } = options;
750
+ const at = (path) => join(dir, path);
751
+ const read = (path) => readFile(at(path), "utf8").catch(() => null);
752
+ const exists = (path) => stat(at(path)).then(() => true, () => false);
753
+ const lines = [];
754
+ let kept = false;
755
+ const put = async (path, content, label = path) => {
756
+ try {
757
+ await mkdir(dirname(at(path)), { recursive: true });
758
+ await writeFile(at(path), content, { flag: force ? "w" : "wx" });
759
+ lines.push(` wrote ${label}`);
760
+ } catch (err) {
761
+ if (err.code !== "EEXIST") throw err;
762
+ lines.push(` kept ${path} (it exists already)`);
763
+ kept = true;
764
+ }
765
+ };
766
+ const append = async (path, existing, line, label) => {
767
+ await writeFile(at(path), `${existing.trimEnd()}
768
+
769
+ ${line}
770
+ `);
771
+ lines.push(` wrote ${label}`);
772
+ };
773
+ const pkg = await readPackage(dir);
774
+ const source = typeof pkg?.name === "string" && pkg.name.replace(/^@[^/]+\//, "") || basename(dir);
775
+ const fromName = slugFrom(source);
776
+ const slug = fromName ?? `game-${randomBytes(3).toString("hex")}`;
777
+ const found = await findDir(pkg, exists);
778
+ const branch = defaultBranch(dir);
779
+ const config = { slug, name: titleFrom(source), description: "", dir: found.dir, ...branch ? { productionBranch: branch } : {} };
780
+ await put("onceworlds.json", `${JSON.stringify(config, null, 2)}
781
+ `, `onceworlds.json (link "${slug}", publishing "${found.dir}")`);
782
+ const present = await findWorkflow(dir);
783
+ if (present && present !== WORKFLOW_PATH) lines.push(` kept ${present} (it already deploys with the Onceworlds CLI)`);
784
+ else await put(WORKFLOW_PATH, deployWorkflow());
785
+ const guide = await fetchGuide(options.fetch ?? fetch, options.api, (line) => lines.push(line));
786
+ const agents = await read("AGENTS.md");
787
+ const separate = agents !== null && !/^# Onceworlds\b/.test(agents);
788
+ const guidePath = separate ? "ONCEWORLDS.md" : "AGENTS.md";
789
+ if (guide !== null) await put(guidePath, guide);
790
+ if (guide !== null || await exists(guidePath)) {
791
+ if (separate && !agents.includes("ONCEWORLDS.md")) {
792
+ 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)");
793
+ }
794
+ const claude = await read("CLAUDE.md");
795
+ if (claude === null) await put("CLAUDE.md", `@${guidePath}
796
+ `);
797
+ else if (!claude.includes(guidePath)) await append("CLAUDE.md", claude, `@${guidePath}`, `CLAUDE.md (a pointer to ${guidePath})`);
798
+ }
799
+ for (const line of lines) log(line);
800
+ if (fromName === null) log(` note "${source}" can't be a game's link, so the link is ${slug}. Change slug in onceworlds.json.`);
801
+ if (found.note) log(` note ${found.note}`);
802
+ if (kept) log(" note --force replaces the files init writes. Your own AGENTS.md and CLAUDE.md are never replaced.");
803
+ log(`
804
+ Next:
805
+ 1. Check onceworlds.json: "slug" is your game's link (onceworlds.com/play/<slug>) and "name" is its title.
806
+ 2. Commit these files and push the repo to GitHub.
807
+ 3. Open ${options.api}/dashboard/new, import the repo, and every push publishes your game.`);
808
+ }
809
+ async function readPackage(dir) {
810
+ try {
811
+ const parsed = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
812
+ return parsed && typeof parsed === "object" ? parsed : null;
813
+ } catch {
814
+ return null;
815
+ }
816
+ }
817
+ function slugFrom(name) {
818
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
819
+ return isValidSlug(slug) && !isReservedSlug(slug) ? slug : null;
820
+ }
821
+ function titleFrom(name) {
822
+ const title = name.replace(/[-_.]+/g, " ").trim().replace(/\s+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
823
+ return (title || "My Game").slice(0, LIMITS.gameNameLength);
824
+ }
825
+ async function findDir(pkg, exists) {
826
+ if (typeof pkg?.scripts?.build === "string") {
827
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
828
+ const out = deps.next ? "out" : deps["react-scripts"] || deps["@sveltejs/kit"] ? "build" : "dist";
829
+ 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.` };
830
+ }
831
+ if (await exists("index.html")) return { dir: "." };
832
+ for (const name of OUTPUT_DIRS) if (await exists(`${name}/index.html`)) return { dir: name };
833
+ return { dir: ".", note: `No index.html here yet. Set "dir" in onceworlds.json to the folder that has your game's index.html.` };
834
+ }
835
+ function defaultBranch(dir) {
836
+ const git = (args) => {
837
+ try {
838
+ return execFileSync("git", args, { cwd: dir, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
839
+ } catch {
840
+ return null;
841
+ }
842
+ };
843
+ return git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"])?.replace(/^origin\//, "") ?? git(["symbolic-ref", "-q", "--short", "HEAD"]);
844
+ }
845
+ async function findWorkflow(dir) {
846
+ const folder = join(dir, ".github/workflows");
847
+ const names = await readdir(folder).catch(() => []);
848
+ 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}`;
850
+ }
851
+ return null;
852
+ }
853
+ async function fetchGuide(fetcher, api, warn) {
854
+ const url = `${api}/agents.md`;
855
+ let problem;
856
+ try {
857
+ const res = await fetcher(url, { signal: AbortSignal.timeout(1e4) });
858
+ const text = res.ok ? await res.text() : "";
859
+ if (text.trim()) return text;
860
+ problem = res.ok ? "it is empty" : `${res.status}`;
861
+ } catch (err) {
862
+ problem = err instanceof Error ? err.message : String(err);
863
+ }
864
+ warn(` ! Skipped AGENTS.md and CLAUDE.md: could not fetch ${url} (${problem}). Run init again when it works.`);
865
+ return null;
866
+ }
867
+
524
868
  // src/index.ts
525
869
  var PRODUCTION_API = "https://onceworlds.com";
526
870
  var LOCAL_API = "http://localhost:8787";
527
871
  var USAGE = `Usage:
872
+ onceworlds init [project-dir] Set a game folder up for Onceworlds: writes onceworlds.json, the GitHub workflow
873
+ that publishes every push, and AGENTS.md and CLAUDE.md for AI coding tools
528
874
  onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
529
875
  onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
530
876
  onceworlds doctor [project-dir] Look for what games often ship with: no viewport tag, small text fields,
@@ -536,6 +882,8 @@ Options:
536
882
  --token <t> Local platform's deploy token (default: "dev")
537
883
  --players <n> dev: also print links for players 2 to n, to play a multiplayer game with yourself
538
884
  --strict doctor: exit with an error when it finds a likely bug (not for notes)
885
+ --force init: replace onceworlds.json, the workflow and the guide when they exist already
886
+ (never your own AGENTS.md or CLAUDE.md)
539
887
 
540
888
  Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
541
889
  workflow runs this on every push, and GitHub vouches for the repo.
@@ -555,6 +903,7 @@ async function main() {
555
903
  token: { type: "string" },
556
904
  players: { type: "string" },
557
905
  strict: { type: "boolean" },
906
+ force: { type: "boolean" },
558
907
  help: { type: "boolean", short: "h" }
559
908
  }
560
909
  });
@@ -565,7 +914,10 @@ async function main() {
565
914
  }
566
915
  const projectDir = resolve(projectArg ?? ".");
567
916
  const api = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
568
- if (command === "deploy") {
917
+ if (command === "init") {
918
+ if (!(await stat2(projectDir).catch(() => null))?.isDirectory()) throw new CliError(`${projectDir} isn't a folder.`);
919
+ await init(projectDir, { api, force: values.force === true });
920
+ } else if (command === "deploy") {
569
921
  await deploy(projectDir, api, values.token);
570
922
  } else if (command === "dev") {
571
923
  const players = Number(values.players ?? 1);
@@ -618,7 +970,7 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
618
970
  console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
619
971
  }
620
972
  await runPool(uploads, 6, async (file) => {
621
- const body = await readFile(file.abs);
973
+ const body = await readFile2(file.abs);
622
974
  await send("PUT", `${api}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
623
975
  });
624
976
  const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
@@ -639,7 +991,7 @@ async function dev(projectDir, api, tokenArg, players = 1) {
639
991
  for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
640
992
  console.log(` Watching ${root}. Press Ctrl+C to stop.
641
993
  `);
642
- const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
994
+ const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile2(join2(projectDir, "onceworlds.json"), "utf8").catch(() => "");
643
995
  let lastSignature = await projectSignature();
644
996
  let timer;
645
997
  let running = false;
@@ -682,7 +1034,7 @@ async function readSources(files) {
682
1034
  const sources = [];
683
1035
  for (const file of files) {
684
1036
  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") });
1037
+ sources.push({ path: file.path, text: await readFile2(file.abs, "utf8") });
686
1038
  }
687
1039
  return sources;
688
1040
  }
@@ -704,10 +1056,10 @@ ${warnings} likely problem${warnings === 1 ? "" : "s"}, ${findings.length - warn
704
1056
  if (strict && warnings > 0) process.exitCode = 1;
705
1057
  }
706
1058
  async function loadConfig(projectDir) {
707
- const file = join(projectDir, "onceworlds.json");
1059
+ const file = join2(projectDir, "onceworlds.json");
708
1060
  let config;
709
1061
  try {
710
- config = JSON.parse(await readFile(file, "utf8"));
1062
+ config = JSON.parse(await readFile2(file, "utf8"));
711
1063
  } catch (err) {
712
1064
  const missing = err.code === "ENOENT";
713
1065
  throw new CliError(missing ? `No onceworlds.json in ${projectDir}.
@@ -728,7 +1080,7 @@ async function loadStore(projectDir, config) {
728
1080
  if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
729
1081
  let data;
730
1082
  try {
731
- data = await readFile(abs);
1083
+ data = await readFile2(abs);
732
1084
  } catch {
733
1085
  throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
734
1086
  }
@@ -792,13 +1144,13 @@ async function collectFiles(root) {
792
1144
  async function walk(dir, prefix) {
793
1145
  let entries;
794
1146
  try {
795
- entries = await readdir(dir, { withFileTypes: true });
1147
+ entries = await readdir2(dir, { withFileTypes: true });
796
1148
  } catch {
797
1149
  throw new CliError(`Cannot read ${dir}. Check "dir" in onceworlds.json.`);
798
1150
  }
799
1151
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
800
1152
  if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name)) continue;
801
- const abs = join(dir, entry.name);
1153
+ const abs = join2(dir, entry.name);
802
1154
  const path = prefix + entry.name;
803
1155
  if (entry.isDirectory()) {
804
1156
  await walk(abs, `${path}/`);
@@ -807,7 +1159,7 @@ async function collectFiles(root) {
807
1159
  if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
808
1160
  continue;
809
1161
  }
810
- const data = await readFile(abs);
1162
+ const data = await readFile2(abs);
811
1163
  if (data.byteLength > LIMITS.deployFileBytes) {
812
1164
  throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
813
1165
  }
@@ -825,7 +1177,7 @@ function signature(files) {
825
1177
  function gitInfo(cwd) {
826
1178
  const run = (args) => {
827
1179
  try {
828
- return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
1180
+ return execFileSync2("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
829
1181
  } catch {
830
1182
  return void 0;
831
1183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",