@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.
- package/README.md +14 -5
- package/dist/cli.js +907 -59
- 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
|
|
@@ -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)
|
|
67
|
-
|
|
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`).
|
|
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 = (
|
|
15
|
-
function looksMinified(
|
|
16
|
-
const lines =
|
|
17
|
-
return
|
|
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
|
|
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
|
|
82
|
-
note("listing-help", `No "controls"
|
|
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
|
|
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:
|
|
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:
|
|
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 = (
|
|
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
|
|
458
|
-
if (!
|
|
459
|
-
hints.push({ keys: cleaned, action:
|
|
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
|
|
464
|
-
if (
|
|
465
|
-
return { controls: hints, help:
|
|
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,
|
|
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
|
|
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 =
|
|
567
|
-
const
|
|
568
|
-
if (command === "
|
|
569
|
-
await
|
|
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,
|
|
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,
|
|
1399
|
+
async function deploy(projectDir, api2, tokenArg, quiet = false, dev2 = false) {
|
|
583
1400
|
const config = await loadConfig(projectDir);
|
|
584
|
-
const root =
|
|
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 ${
|
|
603
|
-
const token = await getToken(
|
|
604
|
-
const start = await send("POST", `${
|
|
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
|
|
622
|
-
await send("PUT", `${
|
|
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", `${
|
|
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.
|
|
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,
|
|
1451
|
+
async function dev(projectDir, api2, tokenArg, players = 1) {
|
|
634
1452
|
const config = await loadConfig(projectDir);
|
|
635
|
-
const root =
|
|
636
|
-
const first = await deploy(projectDir,
|
|
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
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
1525
|
+
const file = join4(projectDir, "onceworlds.json");
|
|
708
1526
|
let config;
|
|
709
1527
|
try {
|
|
710
|
-
config = JSON.parse(await
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
"
|
|
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)`);
|