@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.
- package/README.md +12 -3
- package/dist/cli.js +370 -18
- 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).
|
|
13
|
-
|
|
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`).
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1059
|
+
const file = join2(projectDir, "onceworlds.json");
|
|
708
1060
|
let config;
|
|
709
1061
|
try {
|
|
710
|
-
config = JSON.parse(await
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
1180
|
+
return execFileSync2("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
|
|
829
1181
|
} catch {
|
|
830
1182
|
return void 0;
|
|
831
1183
|
}
|