@onceworlds/cli 0.1.1 → 0.2.1
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 +34 -10
- package/dist/cli.js +226 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,12 +6,17 @@ players) with no server code.
|
|
|
6
6
|
|
|
7
7
|
## Deploy from GitHub Actions
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Deploys run from GitHub Actions: GitHub vouches for the repo, so there are no API
|
|
10
|
+
keys. The easiest setup is to import the repo at https://onceworlds.com/dashboard/new,
|
|
11
|
+
which adds `onceworlds.json` and `.github/workflows/onceworlds.yml` for you (that
|
|
12
|
+
workflow also builds with npm, pnpm, yarn or bun when there's a build script). By
|
|
13
|
+
hand, the minimum is:
|
|
10
14
|
|
|
11
15
|
```yaml
|
|
12
16
|
name: Deploy to Onceworlds
|
|
13
17
|
on:
|
|
14
18
|
push:
|
|
19
|
+
branches: ['**']
|
|
15
20
|
permissions:
|
|
16
21
|
contents: read
|
|
17
22
|
id-token: write # proves which repo is deploying; no API keys needed
|
|
@@ -26,30 +31,49 @@ jobs:
|
|
|
26
31
|
- run: npx --yes @onceworlds/cli@latest deploy
|
|
27
32
|
```
|
|
28
33
|
|
|
29
|
-
Pushes to `main`
|
|
30
|
-
|
|
34
|
+
Pushes to the production branch (`main` unless you change it in the dashboard or
|
|
35
|
+
`onceworlds.json`) go live at `onceworlds.com/play/<slug>`; other branches get a
|
|
36
|
+
preview URL in the job log. Log in to https://onceworlds.com with the GitHub
|
|
31
37
|
account that owns the repo (or any member of its organization) before the first
|
|
32
|
-
deploy.
|
|
38
|
+
deploy. If a deploy fails, the dashboard shows why.
|
|
33
39
|
|
|
34
40
|
New games start private: only the repo's owner (or its organization's members)
|
|
35
|
-
can play them.
|
|
36
|
-
|
|
41
|
+
can play them. To share one, open it in the dashboard and pick Unlisted (anyone
|
|
42
|
+
with the link) or Public (also listed on Onceworlds).
|
|
37
43
|
|
|
38
44
|
## Project file
|
|
39
45
|
|
|
40
46
|
`onceworlds.json` at the root of the repo:
|
|
41
47
|
|
|
42
48
|
```json
|
|
43
|
-
{
|
|
49
|
+
{
|
|
50
|
+
"slug": "kart-rush",
|
|
51
|
+
"name": "Kart Rush",
|
|
52
|
+
"description": "Race friends around twisty tracks.",
|
|
53
|
+
"dir": ".",
|
|
54
|
+
"genre": "Racing",
|
|
55
|
+
"icon": "store/icon.png",
|
|
56
|
+
"thumbnails": ["store/thumb-1.png", "store/thumb-2.png"],
|
|
57
|
+
"badges": [{ "id": "first-win", "name": "Top Step", "description": "Win a race." }]
|
|
58
|
+
}
|
|
44
59
|
```
|
|
45
60
|
|
|
46
61
|
`dir` is the folder to publish (for example `dist` after a build); it must contain
|
|
47
|
-
`index.html`. `productionBranch` (default `main`) picks the branch that goes live
|
|
62
|
+
`index.html`. `productionBranch` (default `main`) picks the branch that goes live;
|
|
63
|
+
the dashboard can change it too, and the file wins again only when this value changes.
|
|
64
|
+
|
|
65
|
+
The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
|
|
66
|
+
16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
|
|
67
|
+
`name`, `description` and an optional square `icon`). Images are PNG, JPEG, WebP
|
|
68
|
+
or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
|
|
69
|
+
these fields only when they change, so edits made in the dashboard last until then.
|
|
48
70
|
|
|
49
71
|
## Commands
|
|
50
72
|
|
|
51
|
-
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
|
|
52
|
-
|
|
73
|
+
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
|
|
74
|
+
(the workflow runs it). It lists every file it skipped because Onceworlds can't serve it.
|
|
75
|
+
- `onceworlds dev [dir]` deploys to a local Onceworlds platform (for platform development)
|
|
76
|
+
and redeploys on every save.
|
|
53
77
|
|
|
54
78
|
Games talk to the platform through the injected `window.onceworlds` SDK. The
|
|
55
79
|
starter template's `AGENTS.md` documents it for AI coding tools.
|
package/dist/cli.js
CHANGED
|
@@ -13,19 +13,25 @@ var OIDC_AUDIENCE = "onceworlds";
|
|
|
13
13
|
var LIMITS = {
|
|
14
14
|
roomMaxPlayersDefault: 16,
|
|
15
15
|
roomMaxPlayersCap: 50,
|
|
16
|
-
|
|
16
|
+
teamsMax: 8,
|
|
17
17
|
messageBytes: 16 * 1024,
|
|
18
18
|
presenceBytes: 1024,
|
|
19
19
|
stateKeyLength: 64,
|
|
20
20
|
stateValueBytes: 16 * 1024,
|
|
21
21
|
stateKeys: 256,
|
|
22
22
|
messagesPerSecond: 60,
|
|
23
|
+
/** Bytes one player may send to a room per second, on average (bursts up to 4x). */
|
|
24
|
+
roomBytesPerSecond: 128 * 1024,
|
|
23
25
|
presenceHz: 20,
|
|
24
26
|
chatLength: 200,
|
|
25
27
|
chatIntervalMs: 750,
|
|
26
28
|
chatHistory: 50,
|
|
29
|
+
chatChannelsPerPlayer: 10,
|
|
27
30
|
saveValueBytes: 64 * 1024,
|
|
28
31
|
saveKeysPerGame: 100,
|
|
32
|
+
/** All of one player's saves in one game together (guests get less until they sign in). */
|
|
33
|
+
saveBytesPerGame: 1024 * 1024,
|
|
34
|
+
guestSaveBytesPerGame: 128 * 1024,
|
|
29
35
|
deployFiles: 2e3,
|
|
30
36
|
deployFileBytes: 25 * 1024 * 1024,
|
|
31
37
|
deployTotalBytes: 100 * 1024 * 1024,
|
|
@@ -33,14 +39,70 @@ var LIMITS = {
|
|
|
33
39
|
envValueBytes: 4 * 1024,
|
|
34
40
|
proxyRequestBytes: 1024 * 1024,
|
|
35
41
|
proxyResponseBytes: 10 * 1024 * 1024,
|
|
42
|
+
proxyMaxOutputTokens: 8192,
|
|
36
43
|
proxyCallsPerDay: 2e3,
|
|
37
|
-
|
|
44
|
+
proxyCallsPerDayMax: 1e5,
|
|
45
|
+
proxyCallsPerPlayerPerDay: 100,
|
|
46
|
+
proxyCallsPerNetworkPerDay: 1e3,
|
|
47
|
+
proxyMediaCallsPerDay: 100,
|
|
48
|
+
proxyMediaCallsPerDayMax: 1e4,
|
|
49
|
+
proxyMediaCallsPerPlayerPerDay: 20,
|
|
50
|
+
proxyMediaCallsPerNetworkPerDay: 25,
|
|
51
|
+
errorReportsPerPage: 10,
|
|
52
|
+
gameNameLength: 60,
|
|
53
|
+
gameDescriptionLength: 1e3,
|
|
54
|
+
galleryItems: 10,
|
|
55
|
+
mediaImageBytes: 5 * 1024 * 1024,
|
|
56
|
+
mediaVideoBytes: 30 * 1024 * 1024,
|
|
57
|
+
badgesPerGame: 100,
|
|
58
|
+
badgeNameLength: 50,
|
|
59
|
+
badgeDescriptionLength: 200,
|
|
60
|
+
leaderboardsPerGame: 25,
|
|
61
|
+
leaderboardTop: 100,
|
|
62
|
+
gamesPerOwner: 50,
|
|
63
|
+
/** New games per creator in any rolling 24-hour period. Redeploys do not count. */
|
|
64
|
+
gamesCreatedPerCreatorPerDay: 3,
|
|
65
|
+
deploysPerOwnerPerDay: 100,
|
|
66
|
+
/** Finished versions retained per game, including its live version. */
|
|
67
|
+
deploymentsKeptPerGame: 10,
|
|
68
|
+
deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
|
|
69
|
+
bioLength: 500,
|
|
70
|
+
friendsMax: 200,
|
|
71
|
+
friendRequestsMax: 100,
|
|
72
|
+
passwordMinLength: 8,
|
|
73
|
+
passwordMaxLength: 72,
|
|
74
|
+
/** Account creation attempts, with or without email, per network (IP, or IPv6 /64) in 24 hours. */
|
|
75
|
+
signupsPerNetworkPerDay: 100,
|
|
76
|
+
/** New guest players per network per day (each one can hold saves). */
|
|
77
|
+
guestsPerNetworkPerDay: 300,
|
|
78
|
+
/** Visits a game gets per network (IP, or IPv6 /64) per day: a script can't pump the count. */
|
|
79
|
+
visitsPerNetworkPerDay: 10,
|
|
80
|
+
/** Seconds of visible play before a play counts as a visit. */
|
|
81
|
+
visitSeconds: 10,
|
|
82
|
+
/** Distinct errors kept per deploy of a game (more are counted by nobody). */
|
|
83
|
+
errorsPerDeployment: 100,
|
|
84
|
+
/** On-screen controls: buttons beside the thumbstick, and their labels. */
|
|
85
|
+
controlButtons: 4,
|
|
86
|
+
controlLabelLength: 12,
|
|
87
|
+
/** Friends in one party, the leader included. */
|
|
88
|
+
partySize: 6,
|
|
89
|
+
partyChatHistory: 50,
|
|
90
|
+
/** Messages outside games: saved chats between friends. */
|
|
91
|
+
messageLength: 500,
|
|
92
|
+
messagesPerPage: 50,
|
|
93
|
+
groupChatMembers: 20,
|
|
94
|
+
groupChatNameLength: 40,
|
|
95
|
+
conversationsListed: 50,
|
|
96
|
+
notificationsListed: 50,
|
|
97
|
+
/** Friends one game invite can go to. */
|
|
98
|
+
invitesPerRequest: 10
|
|
38
99
|
};
|
|
39
100
|
var CONTENT_TYPES = {
|
|
40
101
|
html: "text/html; charset=utf-8",
|
|
41
102
|
htm: "text/html; charset=utf-8",
|
|
42
103
|
js: "text/javascript; charset=utf-8",
|
|
43
104
|
mjs: "text/javascript; charset=utf-8",
|
|
105
|
+
cjs: "text/javascript; charset=utf-8",
|
|
44
106
|
css: "text/css; charset=utf-8",
|
|
45
107
|
json: "application/json; charset=utf-8",
|
|
46
108
|
map: "application/json; charset=utf-8",
|
|
@@ -66,6 +128,7 @@ var CONTENT_TYPES = {
|
|
|
66
128
|
opus: "audio/ogg",
|
|
67
129
|
wav: "audio/wav",
|
|
68
130
|
m4a: "audio/mp4",
|
|
131
|
+
aac: "audio/aac",
|
|
69
132
|
flac: "audio/flac",
|
|
70
133
|
mp4: "video/mp4",
|
|
71
134
|
webm: "video/webm",
|
|
@@ -76,6 +139,11 @@ var CONTENT_TYPES = {
|
|
|
76
139
|
mtl: "text/plain; charset=utf-8",
|
|
77
140
|
fbx: "application/octet-stream",
|
|
78
141
|
wasm: "application/wasm",
|
|
142
|
+
// Shaders, loaded as text.
|
|
143
|
+
glsl: "text/plain; charset=utf-8",
|
|
144
|
+
vert: "text/plain; charset=utf-8",
|
|
145
|
+
frag: "text/plain; charset=utf-8",
|
|
146
|
+
wgsl: "text/plain; charset=utf-8",
|
|
79
147
|
pck: "application/octet-stream",
|
|
80
148
|
data: "application/octet-stream",
|
|
81
149
|
woff: "font/woff",
|
|
@@ -108,6 +176,82 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "st
|
|
|
108
176
|
function isValidSlug(slug) {
|
|
109
177
|
return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
|
|
110
178
|
}
|
|
179
|
+
var DEFAULT_PRODUCTION_BRANCH = "main";
|
|
180
|
+
var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
181
|
+
var GENRES = [
|
|
182
|
+
"Action",
|
|
183
|
+
"Adventure",
|
|
184
|
+
"Fighting",
|
|
185
|
+
"Obby",
|
|
186
|
+
"Puzzle",
|
|
187
|
+
"Racing",
|
|
188
|
+
"Roleplay",
|
|
189
|
+
"Shooter",
|
|
190
|
+
"Simulation",
|
|
191
|
+
"Sports",
|
|
192
|
+
"Strategy",
|
|
193
|
+
"Survival",
|
|
194
|
+
"Tycoon",
|
|
195
|
+
"Horror",
|
|
196
|
+
"Party",
|
|
197
|
+
"Other"
|
|
198
|
+
];
|
|
199
|
+
var MAX_IMAGE_SIDE = 4096;
|
|
200
|
+
function imageInfo(bytes) {
|
|
201
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
202
|
+
const ascii = (at, text) => text.split("").every((c, i) => bytes[at + i] === c.charCodeAt(0));
|
|
203
|
+
if (bytes.length >= 24 && bytes[0] === 137 && ascii(1, "PNG\r\n\n") && ascii(12, "IHDR")) {
|
|
204
|
+
return { type: "image/png", width: view.getUint32(16), height: view.getUint32(20) };
|
|
205
|
+
}
|
|
206
|
+
if (bytes.length >= 10 && (ascii(0, "GIF87a") || ascii(0, "GIF89a"))) {
|
|
207
|
+
return { type: "image/gif", width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
208
|
+
}
|
|
209
|
+
if (bytes.length >= 30 && ascii(0, "RIFF") && ascii(8, "WEBP")) {
|
|
210
|
+
if (ascii(12, "VP8 ")) {
|
|
211
|
+
return { type: "image/webp", width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 };
|
|
212
|
+
}
|
|
213
|
+
if (ascii(12, "VP8L") && bytes[20] === 47) {
|
|
214
|
+
const bits = view.getUint32(21, true);
|
|
215
|
+
return { type: "image/webp", width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
|
|
216
|
+
}
|
|
217
|
+
if (ascii(12, "VP8X")) {
|
|
218
|
+
const width = (bytes[24] | bytes[25] << 8 | bytes[26] << 16) + 1;
|
|
219
|
+
const height = (bytes[27] | bytes[28] << 8 | bytes[29] << 16) + 1;
|
|
220
|
+
return { type: "image/webp", width, height };
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216) {
|
|
225
|
+
let at = 2;
|
|
226
|
+
while (at + 9 < bytes.length) {
|
|
227
|
+
if (bytes[at] !== 255) return null;
|
|
228
|
+
const marker = bytes[at + 1];
|
|
229
|
+
if (marker === 255) {
|
|
230
|
+
at++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const length = view.getUint16(at + 2);
|
|
234
|
+
const isFrame = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
235
|
+
if (isFrame) return { type: "image/jpeg", width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
|
|
236
|
+
at += 2 + length;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
function imageSizeProblem(slot, width, height) {
|
|
242
|
+
if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE) {
|
|
243
|
+
return `Images can be at most ${MAX_IMAGE_SIDE}\xD7${MAX_IMAGE_SIDE} pixels.`;
|
|
244
|
+
}
|
|
245
|
+
if (slot === "thumbnail") {
|
|
246
|
+
if (Math.abs(width * 9 - height * 16) > 0.02 * width * 9) return "Thumbnails must be 16:9, like 1920\xD71080.";
|
|
247
|
+
if (width < 480) return "Thumbnails must be at least 480 pixels wide.";
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const min = slot === "icon" ? 128 : 64;
|
|
251
|
+
if (Math.abs(width - height) > Math.max(2, 0.01 * Math.max(width, height))) return "This image must be square.";
|
|
252
|
+
if (width < min) return `This image must be at least ${min}\xD7${min} pixels.`;
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
111
255
|
|
|
112
256
|
// src/index.ts
|
|
113
257
|
var PRODUCTION_API = "https://onceworlds.com";
|
|
@@ -118,15 +262,18 @@ var USAGE = `Usage:
|
|
|
118
262
|
|
|
119
263
|
Options:
|
|
120
264
|
--api <url> Platform URL (default: $ONCEWORLDS_API, else the ones above)
|
|
121
|
-
--token <t>
|
|
265
|
+
--token <t> Local platform's deploy token (default: "dev")
|
|
266
|
+
|
|
267
|
+
Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
|
|
268
|
+
workflow runs this on every push, and GitHub vouches for the repo.
|
|
122
269
|
|
|
123
270
|
The project directory needs an onceworlds.json:
|
|
124
|
-
{ "slug": "
|
|
271
|
+
{ "slug": "my-game", "name": "My Game", "dir": "." }
|
|
125
272
|
`;
|
|
126
273
|
var CliError = class extends Error {
|
|
127
274
|
};
|
|
128
275
|
var QUIET_SKIPS = /* @__PURE__ */ new Set(["node_modules", "onceworlds.json", "package.json", "package-lock.json", "tsconfig.json", "jsconfig.json", "LICENSE"]);
|
|
129
|
-
var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock", "log", "yml", "yaml", "sh"]);
|
|
276
|
+
var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock", "lockb", "log", "yml", "yaml", "sh"]);
|
|
130
277
|
async function main() {
|
|
131
278
|
const { values, positionals } = parseArgs({
|
|
132
279
|
allowPositionals: true,
|
|
@@ -152,12 +299,17 @@ ${USAGE}`);
|
|
|
152
299
|
async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
153
300
|
const config = await loadConfig(projectDir);
|
|
154
301
|
const root = resolve(projectDir, config.dir ?? ".");
|
|
155
|
-
const files = await collectFiles(root
|
|
302
|
+
const { files, skipped } = await collectFiles(root);
|
|
303
|
+
if (skipped.length > 0) {
|
|
304
|
+
console.warn(` ! ${skipped.length} unsupported file${skipped.length === 1 ? "" : "s"} not deployed:`);
|
|
305
|
+
for (const path of skipped) console.warn(` ${path}`);
|
|
306
|
+
}
|
|
156
307
|
if (!files.some((f) => f.path === "index.html")) {
|
|
157
308
|
throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
|
|
158
309
|
}
|
|
159
310
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
160
311
|
if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
|
|
312
|
+
const { store, images } = await loadStore(projectDir, config);
|
|
161
313
|
if (!quiet) console.log(`Deploying ${config.slug} (${files.length} files, ${formatBytes(total)}) to ${api}`);
|
|
162
314
|
const token = await getToken(api, tokenArg);
|
|
163
315
|
const start = await send("POST", `${api}/api/deploy`, token, {
|
|
@@ -165,12 +317,13 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
165
317
|
slug: config.slug,
|
|
166
318
|
name: config.name,
|
|
167
319
|
description: config.description ?? "",
|
|
168
|
-
productionBranch: config.productionBranch ??
|
|
320
|
+
productionBranch: config.productionBranch ?? DEFAULT_PRODUCTION_BRANCH,
|
|
321
|
+
store
|
|
169
322
|
},
|
|
170
323
|
files: files.map(({ path, hash, size }) => ({ path, hash, size })),
|
|
171
324
|
git: gitInfo(projectDir)
|
|
172
325
|
});
|
|
173
|
-
const byHash = new Map(files.map((f) => [f.hash, f]));
|
|
326
|
+
const byHash = new Map([...images, ...files].map((f) => [f.hash, f]));
|
|
174
327
|
const uploads = start.missing.map((hash) => byHash.get(hash));
|
|
175
328
|
if (uploads.length > 0 && !quiet) {
|
|
176
329
|
console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
|
|
@@ -181,8 +334,9 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
181
334
|
});
|
|
182
335
|
const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
|
|
183
336
|
if (!quiet) {
|
|
337
|
+
const branch = result.productionBranch ?? start.productionBranch;
|
|
184
338
|
if (result.production) console.log(`\u2713 Live: ${result.playUrl}`);
|
|
185
|
-
else console.log(`\u2713 Preview
|
|
339
|
+
else console.log(`\u2713 Preview${branch ? ` (only ${branch} goes live)` : ""}: ${result.previewUrl}`);
|
|
186
340
|
for (const note of result.notes ?? []) console.log(` ${note}`);
|
|
187
341
|
}
|
|
188
342
|
return result;
|
|
@@ -195,7 +349,8 @@ async function dev(projectDir, api, tokenArg) {
|
|
|
195
349
|
Play (reloads on every change): ${first.playUrl}?watch
|
|
196
350
|
Watching ${root}. Press Ctrl+C to stop.
|
|
197
351
|
`);
|
|
198
|
-
|
|
352
|
+
const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
|
|
353
|
+
let lastSignature = await projectSignature();
|
|
199
354
|
let timer;
|
|
200
355
|
let running = false;
|
|
201
356
|
let again = false;
|
|
@@ -206,7 +361,7 @@ async function dev(projectDir, api, tokenArg) {
|
|
|
206
361
|
}
|
|
207
362
|
running = true;
|
|
208
363
|
try {
|
|
209
|
-
const current =
|
|
364
|
+
const current = await projectSignature();
|
|
210
365
|
if (current !== lastSignature) {
|
|
211
366
|
await deploy(projectDir, api, tokenArg, true);
|
|
212
367
|
lastSignature = current;
|
|
@@ -250,7 +405,64 @@ ${USAGE}` : `Could not parse ${file}: ${err.message}`);
|
|
|
250
405
|
if (typeof config.name !== "string" || !config.name.trim()) throw new CliError('"name" is required in onceworlds.json.');
|
|
251
406
|
return config;
|
|
252
407
|
}
|
|
253
|
-
async function
|
|
408
|
+
async function loadStore(projectDir, config) {
|
|
409
|
+
const images = /* @__PURE__ */ new Map();
|
|
410
|
+
const image = async (value, slot, field) => {
|
|
411
|
+
if (typeof value !== "string" || !value.trim()) throw new CliError(`"${field}" in onceworlds.json must be the path of an image, like "store/icon.png".`);
|
|
412
|
+
const abs = resolve(projectDir, value);
|
|
413
|
+
if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
|
|
414
|
+
let data;
|
|
415
|
+
try {
|
|
416
|
+
data = await readFile(abs);
|
|
417
|
+
} catch {
|
|
418
|
+
throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
|
|
419
|
+
}
|
|
420
|
+
if (data.byteLength > LIMITS.mediaImageBytes) {
|
|
421
|
+
throw new CliError(`${value} is ${formatBytes(data.byteLength)}; game page images can be at most ${formatBytes(LIMITS.mediaImageBytes)}.`);
|
|
422
|
+
}
|
|
423
|
+
const info = imageInfo(data);
|
|
424
|
+
if (!info) throw new CliError(`${value} ("${field}") must be a PNG, JPEG, WebP or GIF.`);
|
|
425
|
+
const problem = imageSizeProblem(slot, info.width, info.height);
|
|
426
|
+
if (problem) throw new CliError(`${value} ("${field}") is ${info.width}\xD7${info.height}. ${problem}`);
|
|
427
|
+
const hash = createHash("sha256").update(data).digest("hex");
|
|
428
|
+
images.set(hash, { path: value, abs, hash, size: data.byteLength });
|
|
429
|
+
return { hash, size: data.byteLength };
|
|
430
|
+
};
|
|
431
|
+
const list = (value, max, field) => {
|
|
432
|
+
if (value === void 0) return [];
|
|
433
|
+
if (!Array.isArray(value)) throw new CliError(`"${field}" in onceworlds.json must be a list.`);
|
|
434
|
+
if (value.length > max) throw new CliError(`"${field}" in onceworlds.json can have at most ${max} items.`);
|
|
435
|
+
return value;
|
|
436
|
+
};
|
|
437
|
+
let genre = "";
|
|
438
|
+
if (config.genre !== void 0 && config.genre !== "") {
|
|
439
|
+
genre = GENRES.find((g) => g.toLowerCase() === String(config.genre).toLowerCase()) ?? "";
|
|
440
|
+
if (!genre) throw new CliError(`"genre" in onceworlds.json must be one of: ${GENRES.join(", ")}.`);
|
|
441
|
+
}
|
|
442
|
+
const icon = config.icon === void 0 ? null : await image(config.icon, "icon", "icon");
|
|
443
|
+
const thumbnails = [];
|
|
444
|
+
for (const [i, path] of list(config.thumbnails, LIMITS.galleryItems, "thumbnails").entries()) {
|
|
445
|
+
thumbnails.push(await image(path, "thumbnail", `thumbnails[${i}]`));
|
|
446
|
+
}
|
|
447
|
+
const badges = [];
|
|
448
|
+
for (const [i, value] of list(config.badges, LIMITS.badgesPerGame, "badges").entries()) {
|
|
449
|
+
const b = value ?? {};
|
|
450
|
+
const field = `badges[${i}]`;
|
|
451
|
+
const key = typeof b.id === "string" ? b.id : "";
|
|
452
|
+
if (!BADGE_KEY_RE.test(key)) throw new CliError(`"${field}.id" in onceworlds.json must be lowercase letters, digits and dashes, like "first-win".`);
|
|
453
|
+
if (badges.some((other) => other.key === key)) throw new CliError(`Two badges in onceworlds.json have the id "${key}".`);
|
|
454
|
+
const name = typeof b.name === "string" ? b.name.trim() : "";
|
|
455
|
+
if (!name || name.length > LIMITS.badgeNameLength) throw new CliError(`Badge "${key}" needs a name of 1-${LIMITS.badgeNameLength} characters.`);
|
|
456
|
+
const description = typeof b.description === "string" ? b.description.trim() : "";
|
|
457
|
+
if (description.length > LIMITS.badgeDescriptionLength) {
|
|
458
|
+
throw new CliError(`Badge "${key}": descriptions can be at most ${LIMITS.badgeDescriptionLength} characters.`);
|
|
459
|
+
}
|
|
460
|
+
const badgeIcon = b.icon === void 0 ? null : await image(b.icon, "badge", `${field}.icon`);
|
|
461
|
+
badges.push({ key, name, description, icon: badgeIcon });
|
|
462
|
+
}
|
|
463
|
+
return { store: { genre, icon, thumbnails, badges }, images: [...images.values()] };
|
|
464
|
+
}
|
|
465
|
+
async function collectFiles(root) {
|
|
254
466
|
const files = [];
|
|
255
467
|
const skipped = [];
|
|
256
468
|
async function walk(dir, prefix) {
|
|
@@ -280,9 +492,8 @@ async function collectFiles(root, quiet) {
|
|
|
280
492
|
}
|
|
281
493
|
}
|
|
282
494
|
await walk(root, "");
|
|
283
|
-
if (skipped.length > 0 && !quiet) console.warn(` ! skipped unsupported files: ${skipped.join(", ")}`);
|
|
284
495
|
if (files.length > LIMITS.deployFiles) throw new CliError(`Too many files (${files.length}); the limit is ${LIMITS.deployFiles}.`);
|
|
285
|
-
return files;
|
|
496
|
+
return { files, skipped };
|
|
286
497
|
}
|
|
287
498
|
function signature(files) {
|
|
288
499
|
return files.map((f) => `${f.path}:${f.hash}`).join("\n");
|
|
@@ -299,7 +510,6 @@ function gitInfo(cwd) {
|
|
|
299
510
|
}
|
|
300
511
|
async function getToken(api, tokenArg) {
|
|
301
512
|
if (tokenArg) return tokenArg;
|
|
302
|
-
if (process.env.ONCEWORLDS_TOKEN) return process.env.ONCEWORLDS_TOKEN;
|
|
303
513
|
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
304
514
|
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
305
515
|
if (requestUrl && requestToken) {
|
|
@@ -312,7 +522,7 @@ async function getToken(api, tokenArg) {
|
|
|
312
522
|
const host = new URL(api).hostname;
|
|
313
523
|
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost")) return "dev";
|
|
314
524
|
throw new CliError(
|
|
315
|
-
"
|
|
525
|
+
"Deploys to Onceworlds run from GitHub Actions: push to GitHub and the repo's Onceworlds workflow deploys (it needs `permissions: id-token: write`)."
|
|
316
526
|
);
|
|
317
527
|
}
|
|
318
528
|
async function send(method, url, token, body) {
|