@caisual/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/caisual.mjs +908 -0
  2. package/package.json +27 -0
@@ -0,0 +1,908 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/caisual.ts
4
+ import { createHash } from "node:crypto";
5
+ import { createReadStream, promises as fs } from "node:fs";
6
+ import { basename, extname, join, resolve } from "node:path";
7
+
8
+ // ../contracts/src/slug.ts
9
+ var NOMI_RISERVATI = [
10
+ "www",
11
+ "api",
12
+ "app",
13
+ "play",
14
+ "live",
15
+ "multi",
16
+ "cdn",
17
+ "assets",
18
+ "static",
19
+ "mail",
20
+ "mx",
21
+ "ns1",
22
+ "ns2",
23
+ "autodiscover",
24
+ "_dmarc",
25
+ "admin",
26
+ "login",
27
+ "account",
28
+ "auth",
29
+ "pay",
30
+ "secure",
31
+ "support",
32
+ "help",
33
+ "blog",
34
+ "status",
35
+ "dev",
36
+ "staging",
37
+ "test",
38
+ "caisual",
39
+ "shipz"
40
+ ];
41
+ var RISERVATI = new Set(NOMI_RISERVATI);
42
+ var SLUG_NUOVO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
43
+ var SLUG_STORICO = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
44
+ function isValidSlug(value) {
45
+ return value.length >= 3 && value.length <= 32 && SLUG_NUOVO.test(value) || SLUG_STORICO.test(value);
46
+ }
47
+ function isReservedSlug(value) {
48
+ return RISERVATI.has(value);
49
+ }
50
+
51
+ // ../contracts/src/manifest.ts
52
+ var CAMPI = /* @__PURE__ */ new Set([
53
+ "manifest",
54
+ "id",
55
+ "name",
56
+ "description",
57
+ "cover",
58
+ "screenshots",
59
+ "tags",
60
+ "language",
61
+ "platform",
62
+ "orientation",
63
+ "input",
64
+ "visibility",
65
+ "network",
66
+ "isolated",
67
+ "players",
68
+ "lobby",
69
+ "roles",
70
+ "teams",
71
+ "voice",
72
+ "modes"
73
+ ]);
74
+ var INPUT = /* @__PURE__ */ new Set(["keyboard", "mouse", "touch", "gamepad"]);
75
+ var PLATFORM = /* @__PURE__ */ new Set(["desktop", "mobile", "both"]);
76
+ var ORIENTATION = /* @__PURE__ */ new Set(["landscape", "portrait"]);
77
+ var VISIBILITY = /* @__PURE__ */ new Set(["public", "unlisted"]);
78
+ var VOICE = /* @__PURE__ */ new Set(["none", "room", "team", "proximity"]);
79
+ var TAG = /^[a-z0-9-]+$/;
80
+ var ID_INTERNO = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
81
+ function oggetto(value) {
82
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
83
+ return value;
84
+ }
85
+ function percorsoRelativo(value) {
86
+ if (value === "" || value.startsWith("/") || value.includes("\\") || value.includes("\0")) return false;
87
+ if (value.includes("?") || value.includes("#")) return false;
88
+ const parti = value.split("/");
89
+ if (parti.some((parte) => parte === "" || parte === "." || parte === "..")) return false;
90
+ try {
91
+ const decoded = parti.map((parte) => decodeURIComponent(parte));
92
+ return !decoded.some((parte) => parte === "" || parte === "." || parte === ".." || parte.includes("/"));
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+ function hostValido(value) {
98
+ if (value.length === 0 || value.length > 253) return false;
99
+ if (value.includes("://") || /[/:?#@]/.test(value)) return false;
100
+ const parti = value.split(".");
101
+ return parti.every(
102
+ (parte) => parte.length >= 1 && parte.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parte)
103
+ );
104
+ }
105
+ function interoTra(value, min, max) {
106
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
107
+ }
108
+ function stringaDefault(dati, campo, valoreDefault, errori) {
109
+ const value = dati[campo];
110
+ if (value === void 0) return valoreDefault;
111
+ if (typeof value !== "string") {
112
+ errori.push(`${campo}: must be a string.`);
113
+ return valoreDefault;
114
+ }
115
+ return value;
116
+ }
117
+ function validaManifest(valore) {
118
+ const errori = [];
119
+ const dati = oggetto(valore);
120
+ if (dati === null) return { ok: false, errori: ["manifest: must be a JSON object."] };
121
+ for (const campo of Object.keys(dati)) {
122
+ if (!CAMPI.has(campo)) errori.push(`${campo}: unknown field.`);
123
+ }
124
+ if (dati.manifest === void 0) errori.push("manifest: is required and must be 1.");
125
+ else if (dati.manifest !== 1) errori.push("manifest: must be exactly 1.");
126
+ const id = stringaDefault(dati, "id", "", errori);
127
+ if (dati.id === void 0) errori.push("id: is required.");
128
+ else if (typeof dati.id === "string") {
129
+ if (!isValidSlug(id)) {
130
+ errori.push("id: must be 3-32 lowercase ASCII letters or digits with internal hyphens; historical UUID v4 slugs may be 36 characters.");
131
+ } else if (isReservedSlug(id)) errori.push("id: this slug is reserved.");
132
+ }
133
+ const name = stringaDefault(dati, "name", "", errori);
134
+ if (dati.name === void 0) errori.push("name: is required.");
135
+ else if (typeof dati.name === "string" && (name.trim() === "" || name.length > 60)) {
136
+ errori.push("name: must contain 1-60 characters.");
137
+ }
138
+ const description = stringaDefault(dati, "description", "", errori);
139
+ if (description.length > 500) errori.push("description: must be at most 500 characters.");
140
+ let cover = null;
141
+ if (dati.cover !== void 0 && dati.cover !== null) {
142
+ if (typeof dati.cover !== "string") errori.push("cover: must be a relative file path or null.");
143
+ else if (!percorsoRelativo(dati.cover)) errori.push("cover: must be a relative file path without query, fragment, or parent segments.");
144
+ else cover = dati.cover;
145
+ }
146
+ const screenshots = [];
147
+ if (dati.screenshots !== void 0) {
148
+ if (!Array.isArray(dati.screenshots)) errori.push("screenshots: must be an array of relative file paths.");
149
+ else {
150
+ if (dati.screenshots.length > 8) errori.push("screenshots: must contain at most 8 paths.");
151
+ for (const [indice, value] of dati.screenshots.entries()) {
152
+ if (typeof value !== "string" || !percorsoRelativo(value)) {
153
+ errori.push(`screenshots[${indice}]: must be a relative file path without query, fragment, or parent segments.`);
154
+ } else screenshots.push(value);
155
+ }
156
+ }
157
+ }
158
+ const tags = [];
159
+ if (dati.tags !== void 0) {
160
+ if (!Array.isArray(dati.tags)) errori.push("tags: must be an array.");
161
+ else {
162
+ if (dati.tags.length > 10) errori.push("tags: must contain at most 10 tags.");
163
+ for (const [indice, value] of dati.tags.entries()) {
164
+ if (typeof value !== "string" || value.length > 24 || !TAG.test(value)) {
165
+ errori.push(`tags[${indice}]: must be 1-24 lowercase letters, digits, or hyphens.`);
166
+ } else tags.push(value);
167
+ }
168
+ }
169
+ }
170
+ const language = stringaDefault(dati, "language", "en", errori);
171
+ if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)) {
172
+ errori.push("language: must be a BCP 47 language tag such as en, it, or pt-BR.");
173
+ }
174
+ let platform = "both";
175
+ if (dati.platform === void 0) errori.push("platform: is required.");
176
+ else if (typeof dati.platform !== "string" || !PLATFORM.has(dati.platform)) {
177
+ errori.push("platform: must be desktop, mobile, or both.");
178
+ } else platform = dati.platform;
179
+ let orientation = "landscape";
180
+ if (dati.orientation !== void 0) {
181
+ if (typeof dati.orientation !== "string" || !ORIENTATION.has(dati.orientation)) {
182
+ errori.push("orientation: must be landscape or portrait.");
183
+ } else orientation = dati.orientation;
184
+ }
185
+ const input = [];
186
+ if (dati.input !== void 0) {
187
+ if (!Array.isArray(dati.input)) errori.push("input: must be an array.");
188
+ else for (const [indice, value] of dati.input.entries()) {
189
+ if (typeof value !== "string" || !INPUT.has(value)) {
190
+ errori.push(`input[${indice}]: must be keyboard, mouse, touch, or gamepad.`);
191
+ } else if (input.includes(value)) errori.push(`input[${indice}]: duplicate value ${value}.`);
192
+ else input.push(value);
193
+ }
194
+ }
195
+ let visibility = "public";
196
+ if (dati.visibility !== void 0) {
197
+ if (typeof dati.visibility !== "string" || !VISIBILITY.has(dati.visibility)) {
198
+ errori.push("visibility: must be public or unlisted.");
199
+ } else visibility = dati.visibility;
200
+ }
201
+ const network = [];
202
+ if (dati.network !== void 0) {
203
+ if (!Array.isArray(dati.network)) errori.push("network: must be an array of host names.");
204
+ else for (const [indice, value] of dati.network.entries()) {
205
+ if (typeof value !== "string" || !hostValido(value)) {
206
+ errori.push(`network[${indice}]: must be a host name without scheme, port, path, query, or fragment.`);
207
+ } else if (network.includes(value)) errori.push(`network[${indice}]: duplicate host ${value}.`);
208
+ else network.push(value);
209
+ }
210
+ }
211
+ let isolated = false;
212
+ if (dati.isolated !== void 0) {
213
+ if (typeof dati.isolated !== "boolean") errori.push("isolated: must be a boolean.");
214
+ else isolated = dati.isolated;
215
+ }
216
+ let players = { min: 1, max: 1 };
217
+ if (dati.players !== void 0) {
218
+ const value = oggetto(dati.players);
219
+ if (value === null) errori.push("players: must be an object with min and max.");
220
+ else {
221
+ for (const campo of Object.keys(value)) {
222
+ if (campo !== "min" && campo !== "max") errori.push(`players.${campo}: unknown field.`);
223
+ }
224
+ if (!interoTra(value.min, 1, 16)) errori.push("players.min: must be an integer from 1 to 16.");
225
+ if (!interoTra(value.max, 1, 16)) errori.push("players.max: must be an integer from 1 to 16 in manifest version 1.");
226
+ if (interoTra(value.min, 1, 16) && interoTra(value.max, 1, 16)) {
227
+ if (value.min > value.max) errori.push("players.max: must be greater than or equal to players.min.");
228
+ else players = { min: value.min, max: value.max };
229
+ }
230
+ }
231
+ }
232
+ let lobby = false;
233
+ if (dati.lobby !== void 0) {
234
+ if (typeof dati.lobby !== "boolean") errori.push("lobby: must be a boolean.");
235
+ else lobby = dati.lobby;
236
+ }
237
+ const roles = [];
238
+ if (dati.roles !== void 0) {
239
+ if (!Array.isArray(dati.roles)) errori.push("roles: must be an array.");
240
+ else {
241
+ const ids = /* @__PURE__ */ new Set();
242
+ for (const [indice, raw] of dati.roles.entries()) {
243
+ const value = oggetto(raw);
244
+ if (value === null) {
245
+ errori.push(`roles[${indice}]: must be an object.`);
246
+ continue;
247
+ }
248
+ for (const campo of Object.keys(value)) {
249
+ if (!["id", "min", "max"].includes(campo)) errori.push(`roles[${indice}].${campo}: unknown field.`);
250
+ }
251
+ const idRuolo = value.id;
252
+ const min = value.min;
253
+ const max = value.max;
254
+ let valido = true;
255
+ if (typeof idRuolo !== "string" || idRuolo.length > 32 || !ID_INTERNO.test(idRuolo)) {
256
+ errori.push(`roles[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);
257
+ valido = false;
258
+ } else if (ids.has(idRuolo)) {
259
+ errori.push(`roles[${indice}].id: duplicate role ${idRuolo}.`);
260
+ valido = false;
261
+ } else ids.add(idRuolo);
262
+ if (!interoTra(min, 0, 16)) {
263
+ errori.push(`roles[${indice}].min: must be an integer from 0 to 16.`);
264
+ valido = false;
265
+ }
266
+ if (max !== void 0 && !interoTra(max, 0, 16)) {
267
+ errori.push(`roles[${indice}].max: must be an integer from 0 to 16 when present.`);
268
+ valido = false;
269
+ }
270
+ if (typeof min === "number" && typeof max === "number" && min > max) {
271
+ errori.push(`roles[${indice}].max: must be greater than or equal to min.`);
272
+ valido = false;
273
+ }
274
+ if (valido) roles.push(max === void 0 ? { id: idRuolo, min } : { id: idRuolo, min, max });
275
+ }
276
+ }
277
+ }
278
+ let teams = null;
279
+ if (dati.teams !== void 0 && dati.teams !== null) {
280
+ const value = oggetto(dati.teams);
281
+ if (value === null) errori.push("teams: must be null or an object with min and max.");
282
+ else {
283
+ for (const campo of Object.keys(value)) {
284
+ if (campo !== "min" && campo !== "max") errori.push(`teams.${campo}: unknown field.`);
285
+ }
286
+ if (!interoTra(value.min, 2, 16)) errori.push("teams.min: must be an integer from 2 to 16.");
287
+ if (!interoTra(value.max, 2, 16)) errori.push("teams.max: must be an integer from 2 to 16.");
288
+ if (interoTra(value.min, 2, 16) && interoTra(value.max, 2, 16)) {
289
+ if (value.min > value.max) errori.push("teams.max: must be greater than or equal to teams.min.");
290
+ else teams = { min: value.min, max: value.max };
291
+ }
292
+ }
293
+ }
294
+ let voice = "none";
295
+ if (dati.voice !== void 0) {
296
+ if (typeof dati.voice !== "string" || !VOICE.has(dati.voice)) {
297
+ errori.push("voice: must be none, room, team, or proximity.");
298
+ } else voice = dati.voice;
299
+ }
300
+ const modes = [];
301
+ if (dati.modes !== void 0) {
302
+ if (!Array.isArray(dati.modes)) errori.push("modes: must be an array.");
303
+ else {
304
+ const ids = /* @__PURE__ */ new Set();
305
+ for (const [indice, raw] of dati.modes.entries()) {
306
+ const value = oggetto(raw);
307
+ if (value === null) {
308
+ errori.push(`modes[${indice}]: must be an object.`);
309
+ continue;
310
+ }
311
+ for (const campo of Object.keys(value)) {
312
+ if (campo !== "id" && campo !== "matchmaking") errori.push(`modes[${indice}].${campo}: unknown field.`);
313
+ }
314
+ if (typeof value.id !== "string" || value.id.length > 32 || !ID_INTERNO.test(value.id)) {
315
+ errori.push(`modes[${indice}].id: must be 1-32 lowercase letters, digits, or internal hyphens.`);
316
+ continue;
317
+ }
318
+ if (ids.has(value.id)) {
319
+ errori.push(`modes[${indice}].id: duplicate mode ${value.id}.`);
320
+ continue;
321
+ }
322
+ ids.add(value.id);
323
+ if (value.matchmaking === void 0) {
324
+ modes.push({ id: value.id });
325
+ continue;
326
+ }
327
+ const matchmaking = oggetto(value.matchmaking);
328
+ if (matchmaking === null) {
329
+ errori.push(`modes[${indice}].matchmaking: must be an object.`);
330
+ continue;
331
+ }
332
+ for (const campo of Object.keys(matchmaking)) {
333
+ if (!["key", "timeoutMs", "fallback"].includes(campo)) {
334
+ errori.push(`modes[${indice}].matchmaking.${campo}: unknown field.`);
335
+ }
336
+ }
337
+ let valido = true;
338
+ const key = [];
339
+ if (!Array.isArray(matchmaking.key) || matchmaking.key.length === 0) {
340
+ errori.push(`modes[${indice}].matchmaking.key: must be a non-empty array.`);
341
+ valido = false;
342
+ } else for (const [keyIndice, item] of matchmaking.key.entries()) {
343
+ if (typeof item !== "string" || item.length > 32 || !ID_INTERNO.test(item)) {
344
+ errori.push(`modes[${indice}].matchmaking.key[${keyIndice}]: must be 1-32 lowercase letters, digits, or internal hyphens.`);
345
+ valido = false;
346
+ } else key.push(item);
347
+ }
348
+ if (!Number.isSafeInteger(matchmaking.timeoutMs) || matchmaking.timeoutMs < 1) {
349
+ errori.push(`modes[${indice}].matchmaking.timeoutMs: must be a positive integer.`);
350
+ valido = false;
351
+ }
352
+ if (matchmaking.fallback !== "ghost" && matchmaking.fallback !== "bot") {
353
+ errori.push(`modes[${indice}].matchmaking.fallback: must be ghost or bot.`);
354
+ valido = false;
355
+ }
356
+ if (valido) modes.push({ id: value.id, matchmaking: {
357
+ key,
358
+ timeoutMs: matchmaking.timeoutMs,
359
+ fallback: matchmaking.fallback
360
+ } });
361
+ }
362
+ }
363
+ }
364
+ if (errori.length > 0) return { ok: false, errori };
365
+ return { ok: true, manifest: {
366
+ manifest: 1,
367
+ id,
368
+ name,
369
+ description,
370
+ cover,
371
+ screenshots,
372
+ tags,
373
+ language,
374
+ platform,
375
+ orientation,
376
+ input,
377
+ visibility,
378
+ network,
379
+ isolated,
380
+ players,
381
+ lobby,
382
+ roles,
383
+ teams,
384
+ voice,
385
+ modes
386
+ } };
387
+ }
388
+
389
+ // ../../docs/publish.md
390
+ var publish_default = '# Publish a game on Caisual\n\nCaisual hosts browser games supplied as folders. Each publish creates an immutable version and moves the game\'s stable link to that version.\nThe current publishing flow is for single-player games and does not require changes in the Caisual dashboard.\n\n## Game folder\n\nUse this structure:\n\n```text\nmy-game/\n caisual.json\n client/\n index.html\n ...\n```\n\n`caisual.json` and `client/index.html` are required. Put every file used by the game under `client/`.\n\nRun `npx @caisual/cli init my-game` to create a minimal folder, or create these files yourself.\n\n## caisual.json\n\nThe file must contain one JSON object. Unknown fields are rejected. This is a complete example for the current single-player release:\n\n```json\n{\n "manifest": 1,\n "id": "my-game",\n "name": "My Game",\n "description": "A short description of the game.",\n "cover": "cover.png",\n "screenshots": ["screenshots/level-one.png"],\n "tags": ["puzzle"],\n "language": "en",\n "platform": "both",\n "orientation": "landscape",\n "input": ["keyboard", "mouse", "touch"],\n "visibility": "public",\n "network": [],\n "isolated": false,\n "players": { "min": 1, "max": 1 },\n "lobby": false,\n "roles": [],\n "teams": null,\n "voice": "none",\n "modes": []\n}\n```\n\n- `manifest` is required and must be `1`.\n- `id` is required. Use 3 to 32 lowercase ASCII letters or digits, with single hyphens only between groups. The ID becomes the URL slug. Choose it carefully because it cannot be renamed or reused after deletion.\n- `name` is required and must contain 1 to 60 characters.\n- `description` is optional, defaults to an empty string, and can contain at most 500 characters.\n- `cover` is optional. Use a relative path inside `client/`, or `null`. Do not include a query, fragment, empty segment, or parent segment.\n- `screenshots` is optional and defaults to `[]`. It accepts up to 8 relative paths inside `client/`.\n- `tags` is optional and defaults to `[]`. It accepts up to 10 values. Each value uses 1 to 24 lowercase letters, digits, or hyphens.\n- `language` is optional and defaults to `en`. Use a BCP 47 language tag such as `en`, `it`, or `pt-BR`.\n- `platform` is required. Use `desktop` when the game needs a keyboard, mouse, large display, or desktop performance. Use `mobile` when it is designed only for touch and small screens. Use `both` only after checking that layout, performance, and controls work on both.\n- `orientation` is optional and defaults to `landscape`. Use `landscape` or `portrait` to describe the intended mobile layout. The device may not honor an orientation request.\n- `input` is optional and defaults to `[]`. Include every supported input from `keyboard`, `mouse`, `touch`, and `gamepad`. Do not claim an input until the game is usable with it.\n- `visibility` is optional and defaults to `public`. Use `public` for catalog eligibility or `unlisted` for access by direct link only.\n- `network` is optional and defaults to `[]`. List every external host contacted or loaded by the game, without scheme, port, path, query, or fragment, for example `api.example.com`. If an external host is missing, the browser blocks the request. Keep the array empty when the game uses only its own files and Caisual services.\n- `isolated` is optional and defaults to `false`. Use `true` only when the game requires shared memory or threaded WebAssembly. Every external host in `network` must then send headers compatible with cross-origin isolation.\n- `players` is optional and defaults to `{ "min": 1, "max": 1 }`. Both values are integers from 1 to 16 and `max` must be at least `min`. Keep both at `1` in the current release.\n- `lobby` is optional and defaults to `false`. Keep it `false` in the current release.\n- `roles` is optional and defaults to `[]`. Each future entry has an `id` of 1 to 32 lowercase letters, digits, or internal hyphens, a `min` integer from 0 to 16, and an optional `max` in the same range. Keep it empty in the current release.\n- `teams` is optional and defaults to `null`. A future object has `min` and `max` integers from 2 to 16, with `max` at least `min`. Keep it `null` in the current release.\n- `voice` is optional and defaults to `none`. The accepted values are `none`, `room`, `team`, and `proximity`. Use `none` in the current release.\n- `modes` is optional and defaults to `[]`. A future mode has a unique `id` using 1 to 32 lowercase letters, digits, or internal hyphens. It may have `matchmaking` with a non-empty `key` array using the same format, a positive integer `timeoutMs`, and `fallback` set to `ghost` or `bot`. Keep it empty in the current release.\n\nThe CLI prints every manifest error in one run. Fix every listed field and rule before retrying.\n\n## client/index.html\n\n`index.html` must be at the root of `client/`. Use relative URLs such as `./game.js` or `assets/sprite.png`. Do not use root-relative URLs such as `/game.js`, and do not use parent paths that leave the published `client/` tree.\n\nDo not register a service worker. The game runs in an iframe on its own origin inside `caisual.com`. Test it without assuming access to the parent page, parent cookies, or files outside `client/`.\n\n## Limits\n\n- At most 2,000 files per version.\n- At most 50,000,000 bytes per file.\n- At most 200,000,000 bytes for all files in one version.\n- Dotfiles, dot-directories, and directories named `node_modules` are ignored.\n- Symbolic links and other non-regular files are rejected.\n\nReduce or split files that exceed the per-file limit. Remove generated files that the browser does not need.\n\n## Publish\n\nUse the key supplied by the creator. Set it in the environment so it does not enter shell history as a command-line flag:\n\n```sh\nexport CAISUAL_KEY=\'ck_...\'\nnpx @caisual/cli publish\n```\n\nRun the command from the game folder, or pass the folder path after `publish`. For local portal development only, set `CAISUAL_ORIGIN` to the local HTTP origin.\n\nThe CLI validates the folder, computes every file size and SHA-256 digest, creates a new version, uploads the files, completes the version, and prints the game URL. The stable URL is `https://caisual.com/g/<id>`.\n\nThe first games from a new creator are reviewed before they can appear in the public catalog. Their stable links still work while review is pending.\n\n## Update, unlist, or delete\n\nTo update a game, change its files without changing `id`, then run `npx @caisual/cli publish` again. This creates a new version and keeps the same stable game URL.\n\nTo remove a game from the catalog, set `visibility` to `unlisted` and publish, or change visibility from the dashboard. To delete a game, use the dashboard. Deletion is permanent and its ID cannot be reused.\n\n## Common errors\n\n- `CAISUAL_KEY is required`: export the creator\'s key in the same shell before publishing.\n- `The publish API key is not valid`: create a new key in the account dashboard if the old key expired or was revoked.\n- `caisual.json is not valid`: read every reported field and rule, fix all of them, then retry.\n- `client/index.html: file not found`: place `index.html` directly under `client/`, not in a nested build folder.\n- `referenced file not found`: make sure `cover` and every screenshot path match a file under `client/`, including letter case.\n- `file is larger than 50 MB`: compress, reduce, or split the asset and update its references.\n- `upload failed` or a temporary portal error: keep the files unchanged and retry the same publish command. The CLI retries temporary upload failures automatically.\n- An external request works locally but fails after publishing: add its host to `network` and publish a new version.\n- A threaded WebAssembly game fails to start: set `isolated` to `true` and verify that every declared external host supports cross-origin isolation.\n\nMultiplayer rooms arrive with the kit; the same folder and command will publish them.\n';
391
+
392
+ // src/caisual.ts
393
+ var DEFAULT_ORIGIN = "https://caisual.com";
394
+ var MAX_FILE_BYTES = 5e7;
395
+ var MAX_VERSION_BYTES = 2e8;
396
+ var MAX_FILES = 2e3;
397
+ var UPLOAD_CONCURRENCY = 4;
398
+ var MAX_RETRIES = 3;
399
+ var SERVER_MESSAGE = "Multiplayer servers arrive in the next kit version. Remove server.js to publish this game as single player.";
400
+ var CliError = class extends Error {
401
+ constructor(exitCode, message) {
402
+ super(message);
403
+ this.exitCode = exitCode;
404
+ this.name = "CliError";
405
+ }
406
+ exitCode;
407
+ };
408
+ var ApiError = class extends Error {
409
+ constructor(status, code, message, hints) {
410
+ super(message);
411
+ this.status = status;
412
+ this.code = code;
413
+ this.hints = hints;
414
+ this.name = "ApiError";
415
+ }
416
+ status;
417
+ code;
418
+ hints;
419
+ };
420
+ function help() {
421
+ return `Caisual ${"0.1.0"}
422
+
423
+ Usage:
424
+ caisual init [folder]
425
+ caisual publish [folder]
426
+ caisual skill
427
+ caisual --help
428
+ caisual --version
429
+
430
+ Environment:
431
+ CAISUAL_KEY Required by publish. It is never accepted as a flag.
432
+ CAISUAL_ORIGIN Portal origin for development. Defaults to ${DEFAULT_ORIGIN}.
433
+ `;
434
+ }
435
+ function slugFromFolder(folderName) {
436
+ let slug = folderName.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 32).replace(/-+$/g, "");
437
+ if (slug.length < 3) slug = slug === "" ? "my-game" : `${slug}-game`;
438
+ if (isReservedSlug(slug)) slug = `${slug.slice(0, 27).replace(/-+$/g, "")}-game`;
439
+ return slug.slice(0, 32).replace(/-+$/g, "");
440
+ }
441
+ function displayName(folderName) {
442
+ const name = folderName.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
443
+ return (name === "" ? "My Game" : name).slice(0, 60);
444
+ }
445
+ async function exists(path) {
446
+ try {
447
+ await fs.lstat(path);
448
+ return true;
449
+ } catch (error) {
450
+ if (error.code === "ENOENT") return false;
451
+ throw error;
452
+ }
453
+ }
454
+ async function writeNewFile(path, content) {
455
+ try {
456
+ await fs.writeFile(path, content, { encoding: "utf8", flag: "wx" });
457
+ return true;
458
+ } catch (error) {
459
+ if (error.code === "EEXIST") return false;
460
+ throw error;
461
+ }
462
+ }
463
+ async function init(folderArgument) {
464
+ const root = resolve(process.cwd(), folderArgument);
465
+ try {
466
+ await fs.mkdir(join(root, "client"), { recursive: true });
467
+ } catch {
468
+ throw new CliError(2, `The game folder could not be created: ${root}`);
469
+ }
470
+ const folderName = basename(root);
471
+ const manifest = {
472
+ manifest: 1,
473
+ id: slugFromFolder(folderName),
474
+ name: displayName(folderName),
475
+ platform: "both"
476
+ };
477
+ const manifestPath = join(root, "caisual.json");
478
+ const indexPath = join(root, "client", "index.html");
479
+ const index = `<!doctype html>
480
+ <html lang="en">
481
+ <head>
482
+ <meta charset="utf-8">
483
+ <meta name="viewport" content="width=device-width, initial-scale=1">
484
+ <title>Hello</title>
485
+ </head>
486
+ <body>
487
+ <main>Hello</main>
488
+ <!-- Add the Caisual kit here when it becomes available. -->
489
+ </body>
490
+ </html>
491
+ `;
492
+ const manifestCreated = await writeNewFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
493
+ `);
494
+ const indexCreated = await writeNewFile(indexPath, index);
495
+ process.stdout.write(`${manifestCreated ? "Created" : "Kept"} ${manifestPath}
496
+ `);
497
+ process.stdout.write(`${indexCreated ? "Created" : "Kept"} ${indexPath}
498
+ `);
499
+ }
500
+ async function sha256(path) {
501
+ const hash = createHash("sha256");
502
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
503
+ return hash.digest("hex");
504
+ }
505
+ async function mapLimited(items, limit, operation) {
506
+ const results = new Array(items.length);
507
+ let nextIndex = 0;
508
+ async function worker() {
509
+ while (true) {
510
+ const index = nextIndex;
511
+ nextIndex += 1;
512
+ if (index >= items.length) return;
513
+ results[index] = await operation(items[index], index);
514
+ }
515
+ }
516
+ await Promise.all(Array.from(
517
+ { length: Math.min(limit, items.length) },
518
+ () => worker()
519
+ ));
520
+ return results;
521
+ }
522
+ async function listClientFiles(clientRoot) {
523
+ let rootStat;
524
+ try {
525
+ rootStat = await fs.stat(clientRoot);
526
+ } catch {
527
+ throw new CliError(2, "client/: folder not found.");
528
+ }
529
+ if (!rootStat.isDirectory()) throw new CliError(2, "client/: must be a folder.");
530
+ const found = [];
531
+ async function visit(folder, prefix) {
532
+ const entries = await fs.readdir(folder, { withFileTypes: true });
533
+ entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
534
+ for (const entry of entries) {
535
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
536
+ const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
537
+ const absolutePath = join(folder, entry.name);
538
+ if (entry.isDirectory()) {
539
+ await visit(absolutePath, relativePath);
540
+ continue;
541
+ }
542
+ if (!entry.isFile()) {
543
+ throw new CliError(2, `${relativePath}: only regular files are supported.`);
544
+ }
545
+ const fileStat = await fs.stat(absolutePath);
546
+ if (fileStat.size > MAX_FILE_BYTES) {
547
+ throw new CliError(2, `${relativePath}: file is larger than 50 MB (${fileStat.size} bytes).`);
548
+ }
549
+ found.push({ path: relativePath, absolutePath, bytes: fileStat.size });
550
+ if (found.length > MAX_FILES) {
551
+ throw new CliError(2, `client/: a version can contain at most ${MAX_FILES} files.`);
552
+ }
553
+ }
554
+ }
555
+ await visit(clientRoot, "");
556
+ if (!found.some((file) => file.path === "index.html")) {
557
+ throw new CliError(2, "client/index.html: file not found.");
558
+ }
559
+ const totalBytes = found.reduce((total, file) => total + file.bytes, 0);
560
+ if (totalBytes > MAX_VERSION_BYTES) {
561
+ throw new CliError(2, "client/: a version can contain at most 200 MB in total.");
562
+ }
563
+ return await mapLimited(found, UPLOAD_CONCURRENCY, async (file) => ({
564
+ ...file,
565
+ sha256: await sha256(file.absolutePath)
566
+ }));
567
+ }
568
+ function portalOrigin() {
569
+ const raw = process.env.CAISUAL_ORIGIN?.trim() || DEFAULT_ORIGIN;
570
+ let url;
571
+ try {
572
+ url = new URL(raw);
573
+ } catch {
574
+ throw new CliError(1, "CAISUAL_ORIGIN must be a valid HTTP or HTTPS origin.");
575
+ }
576
+ if (!["http:", "https:"].includes(url.protocol) || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
577
+ throw new CliError(1, "CAISUAL_ORIGIN must be a valid HTTP or HTTPS origin.");
578
+ }
579
+ return url.origin;
580
+ }
581
+ function object(value) {
582
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
583
+ }
584
+ async function apiError(response) {
585
+ let payload;
586
+ try {
587
+ payload = await response.json();
588
+ } catch {
589
+ payload = null;
590
+ }
591
+ const envelope = object(payload);
592
+ const detail = object(envelope?.error);
593
+ const message = typeof detail?.message === "string" ? detail.message : `The portal returned HTTP ${response.status}.`;
594
+ const code = typeof detail?.code === "string" ? detail.code : "http_error";
595
+ const hints = Array.isArray(detail?.hints) ? detail.hints.filter((hint) => typeof hint === "string") : [];
596
+ return new ApiError(response.status, code, message, hints);
597
+ }
598
+ async function requestJson(url, init2) {
599
+ let response;
600
+ try {
601
+ response = await fetch(url, init2);
602
+ } catch (error) {
603
+ const detail = error instanceof Error ? error.message : String(error);
604
+ throw new CliError(1, `The portal could not be reached: ${detail}`);
605
+ }
606
+ if (!response.ok) throw await apiError(response);
607
+ let payload;
608
+ try {
609
+ payload = await response.json();
610
+ } catch {
611
+ throw new CliError(1, "The portal returned an invalid JSON response.");
612
+ }
613
+ const result = object(payload);
614
+ if (result === null) throw new CliError(1, "The portal returned an invalid JSON response.");
615
+ return result;
616
+ }
617
+ function contentType(path) {
618
+ const types = {
619
+ ".aac": "audio/aac",
620
+ ".avif": "image/avif",
621
+ ".bmp": "image/bmp",
622
+ ".css": "text/css; charset=utf-8",
623
+ ".csv": "text/csv; charset=utf-8",
624
+ ".gif": "image/gif",
625
+ ".glb": "model/gltf-binary",
626
+ ".gltf": "model/gltf+json",
627
+ ".html": "text/html; charset=utf-8",
628
+ ".ico": "image/vnd.microsoft.icon",
629
+ ".jpeg": "image/jpeg",
630
+ ".jpg": "image/jpeg",
631
+ ".js": "text/javascript; charset=utf-8",
632
+ ".json": "application/json; charset=utf-8",
633
+ ".mjs": "text/javascript; charset=utf-8",
634
+ ".map": "application/json; charset=utf-8",
635
+ ".mp3": "audio/mpeg",
636
+ ".mp4": "video/mp4",
637
+ ".oga": "audio/ogg",
638
+ ".ogg": "audio/ogg",
639
+ ".opus": "audio/ogg",
640
+ ".otf": "font/otf",
641
+ ".pdf": "application/pdf",
642
+ ".png": "image/png",
643
+ ".svg": "image/svg+xml",
644
+ ".ttf": "font/ttf",
645
+ ".txt": "text/plain; charset=utf-8",
646
+ ".wasm": "application/wasm",
647
+ ".wav": "audio/wav",
648
+ ".webm": "video/webm",
649
+ ".webmanifest": "application/manifest+json",
650
+ ".webp": "image/webp",
651
+ ".woff": "font/woff",
652
+ ".woff2": "font/woff2",
653
+ ".xml": "application/xml; charset=utf-8",
654
+ ".zip": "application/zip"
655
+ };
656
+ return types[extname(path).toLowerCase()] ?? "application/octet-stream";
657
+ }
658
+ function progressLine(path, sent, total, retry) {
659
+ const percentage = total === 0 ? 100 : Math.min(100, Math.floor(sent / total * 100));
660
+ const blocks = Math.floor(percentage / 10);
661
+ const suffix = retry === 0 ? "" : ` retry ${retry}/${MAX_RETRIES}`;
662
+ return `${path} [${"#".repeat(blocks)}${".".repeat(10 - blocks)}] ${percentage}%${suffix}
663
+ `;
664
+ }
665
+ async function* uploadBody(file, retry) {
666
+ let sent = 0;
667
+ let lastBlock = -1;
668
+ process.stdout.write(progressLine(file.path, 0, file.bytes, retry));
669
+ if (file.bytes === 0) return;
670
+ for await (const chunk of createReadStream(file.absolutePath)) {
671
+ const buffer = chunk;
672
+ sent += buffer.byteLength;
673
+ const block = Math.floor(Math.min(100, sent / file.bytes * 100) / 10);
674
+ if (block > lastBlock) {
675
+ process.stdout.write(progressLine(file.path, sent, file.bytes, retry));
676
+ lastBlock = block;
677
+ }
678
+ yield buffer;
679
+ }
680
+ }
681
+ function sleep(milliseconds) {
682
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
683
+ }
684
+ async function uploadFile(file, target, key, origin) {
685
+ let uploadUrl;
686
+ try {
687
+ uploadUrl = new URL(target.url, origin);
688
+ } catch {
689
+ throw new CliError(1, `The portal returned an invalid upload URL for ${file.path}.`);
690
+ }
691
+ if (uploadUrl.origin !== origin) {
692
+ throw new CliError(1, `The portal returned an unsafe upload URL for ${file.path}.`);
693
+ }
694
+ for (let retry = 0; retry <= MAX_RETRIES; retry += 1) {
695
+ try {
696
+ const response = await fetch(uploadUrl, {
697
+ method: "PUT",
698
+ headers: {
699
+ Authorization: `Bearer ${key}`,
700
+ "Content-Type": contentType(file.path),
701
+ "Content-Length": String(file.bytes)
702
+ },
703
+ body: uploadBody(file, retry),
704
+ duplex: "half"
705
+ });
706
+ if (response.ok) {
707
+ return;
708
+ }
709
+ if (response.status < 500 || retry === MAX_RETRIES) throw await apiError(response);
710
+ await response.arrayBuffer();
711
+ process.stderr.write(`${file.path}: upload failed with HTTP ${response.status}; retrying.
712
+ `);
713
+ } catch (error) {
714
+ if (error instanceof ApiError || error instanceof CliError) throw error;
715
+ if (retry === MAX_RETRIES) {
716
+ const detail = error instanceof Error ? error.message : String(error);
717
+ throw new CliError(1, `${file.path}: upload failed after ${MAX_RETRIES} retries: ${detail}`);
718
+ }
719
+ process.stderr.write(`${file.path}: network error; retrying.
720
+ `);
721
+ }
722
+ await sleep(100 * 2 ** retry);
723
+ }
724
+ }
725
+ function parseUploads(payload, files) {
726
+ const versionId = payload.versionId;
727
+ if (!Number.isSafeInteger(versionId) || versionId <= 0 || !Array.isArray(payload.uploads)) {
728
+ throw new CliError(1, "The portal returned an invalid version response.");
729
+ }
730
+ const byPath = /* @__PURE__ */ new Map();
731
+ for (const raw of payload.uploads) {
732
+ const target = object(raw);
733
+ if (typeof target?.path !== "string" || typeof target.url !== "string" || target.method !== "PUT") {
734
+ throw new CliError(1, "The portal returned an invalid upload target.");
735
+ }
736
+ if (byPath.has(target.path)) throw new CliError(1, `The portal returned ${target.path} more than once.`);
737
+ byPath.set(target.path, { path: target.path, url: target.url, method: "PUT" });
738
+ }
739
+ const targets = files.map((file) => {
740
+ const target = byPath.get(file.path);
741
+ if (target === void 0) throw new CliError(1, `The portal did not return an upload URL for ${file.path}.`);
742
+ return target;
743
+ });
744
+ if (byPath.size !== files.length) throw new CliError(1, "The portal returned an upload URL for an unknown file.");
745
+ return {
746
+ versionId,
747
+ n: Number.isSafeInteger(payload.n) ? payload.n : null,
748
+ targets
749
+ };
750
+ }
751
+ async function readManifest(root) {
752
+ const path = join(root, "caisual.json");
753
+ let source;
754
+ try {
755
+ source = await fs.readFile(path, "utf8");
756
+ } catch {
757
+ throw new CliError(2, "caisual.json: file not found or unreadable.");
758
+ }
759
+ let value;
760
+ try {
761
+ value = JSON.parse(source);
762
+ } catch {
763
+ throw new CliError(2, "caisual.json: must contain valid JSON.");
764
+ }
765
+ const result = validaManifest(value);
766
+ if (!result.ok) {
767
+ throw new CliError(2, `caisual.json is not valid:
768
+ ${result.errori.map((error) => `- ${error}`).join("\n")}`);
769
+ }
770
+ return result.manifest;
771
+ }
772
+ async function publish(folderArgument) {
773
+ const root = resolve(process.cwd(), folderArgument);
774
+ let rootStat;
775
+ try {
776
+ rootStat = await fs.stat(root);
777
+ } catch {
778
+ throw new CliError(2, `The game folder was not found: ${root}`);
779
+ }
780
+ if (!rootStat.isDirectory()) throw new CliError(2, `The game path is not a folder: ${root}`);
781
+ if (await exists(join(root, "server.js"))) throw new CliError(2, SERVER_MESSAGE);
782
+ const manifest = await readManifest(root);
783
+ const files = await listClientFiles(join(root, "client"));
784
+ const filePaths = new Set(files.map((file) => file.path));
785
+ for (const required of [manifest.cover, ...manifest.screenshots]) {
786
+ if (required !== null && !filePaths.has(required)) {
787
+ throw new CliError(2, `caisual.json: referenced file not found in client/: ${required}`);
788
+ }
789
+ }
790
+ const key = process.env.CAISUAL_KEY?.trim();
791
+ if (!key) {
792
+ throw new CliError(3, "CAISUAL_KEY is required. Set it with: export CAISUAL_KEY=ck_...");
793
+ }
794
+ const origin = portalOrigin();
795
+ const declared = files.map(({ path, bytes, sha256: digest }) => ({
796
+ path,
797
+ bytes,
798
+ sha256: digest
799
+ }));
800
+ process.stdout.write(`Preparing ${files.length} file${files.length === 1 ? "" : "s"}.
801
+ `);
802
+ const opened = await requestJson(`${origin}/api/versions`, {
803
+ method: "POST",
804
+ headers: {
805
+ Authorization: `Bearer ${key}`,
806
+ "Content-Type": "application/json; charset=utf-8"
807
+ },
808
+ body: JSON.stringify({ manifest, files: declared })
809
+ });
810
+ const version = parseUploads(opened, files);
811
+ await mapLimited(files, UPLOAD_CONCURRENCY, async (file, index) => {
812
+ await uploadFile(file, version.targets[index], key, origin);
813
+ });
814
+ const completed = await requestJson(`${origin}/api/versions/${version.versionId}/complete`, {
815
+ method: "POST",
816
+ headers: { Authorization: `Bearer ${key}` }
817
+ });
818
+ if (typeof completed.url !== "string") {
819
+ throw new CliError(1, "The portal completed the version without returning the game URL.");
820
+ }
821
+ if (version.n !== null) process.stdout.write(`Published version ${version.n}.
822
+ `);
823
+ process.stdout.write(`${completed.url}
824
+ `);
825
+ }
826
+ async function installSkill() {
827
+ const root = process.cwd();
828
+ const skillPath = join(root, ".claude", "skills", "caisual", "SKILL.md");
829
+ const skill = `---
830
+ name: caisual
831
+ description: Create and publish a browser game on Caisual.
832
+ ---
833
+
834
+ ${publish_default.trim()}
835
+ `;
836
+ await fs.mkdir(join(root, ".claude", "skills", "caisual"), { recursive: true });
837
+ let currentSkill = null;
838
+ try {
839
+ currentSkill = await fs.readFile(skillPath, "utf8");
840
+ } catch (error) {
841
+ if (error.code !== "ENOENT") throw error;
842
+ }
843
+ if (currentSkill !== skill) await fs.writeFile(skillPath, skill, "utf8");
844
+ const agentsPath = join(root, "AGENTS.md");
845
+ let agents = "";
846
+ try {
847
+ agents = await fs.readFile(agentsPath, "utf8");
848
+ } catch (error) {
849
+ if (error.code !== "ENOENT") throw error;
850
+ }
851
+ if (!/^## Caisual\s*$/m.test(agents)) {
852
+ const section = "## Caisual\nRead `.claude/skills/caisual/SKILL.md` before creating or publishing a Caisual game.\nUse the current guide at https://caisual.com/publish.md.\n";
853
+ const separator = agents === "" ? "" : agents.endsWith("\n\n") ? "" : agents.endsWith("\n") ? "\n" : "\n\n";
854
+ await fs.writeFile(agentsPath, `${agents}${separator}${section}`, "utf8");
855
+ }
856
+ process.stdout.write(`Installed ${skillPath}
857
+ `);
858
+ }
859
+ async function run(argumentsList) {
860
+ const [command, ...argumentsAfterCommand] = argumentsList;
861
+ if (command === void 0 || command === "--help" || command === "-h" || command === "help") {
862
+ process.stdout.write(help());
863
+ return;
864
+ }
865
+ if (command === "--version" || command === "-V") {
866
+ process.stdout.write(`${"0.1.0"}
867
+ `);
868
+ return;
869
+ }
870
+ if (command === "init") {
871
+ if (argumentsAfterCommand.length > 1) throw new CliError(1, "Usage: caisual init [folder]");
872
+ await init(argumentsAfterCommand[0] ?? ".");
873
+ return;
874
+ }
875
+ if (command === "publish") {
876
+ if (argumentsAfterCommand.length > 1) throw new CliError(1, "Usage: caisual publish [folder]");
877
+ await publish(argumentsAfterCommand[0] ?? ".");
878
+ return;
879
+ }
880
+ if (command === "skill") {
881
+ if (argumentsAfterCommand.length > 0) throw new CliError(1, "Usage: caisual skill");
882
+ await installSkill();
883
+ return;
884
+ }
885
+ throw new CliError(1, `Unknown command: ${command}
886
+
887
+ ${help()}`);
888
+ }
889
+ try {
890
+ await run(process.argv.slice(2));
891
+ } catch (error) {
892
+ if (error instanceof ApiError) {
893
+ process.stderr.write(`${error.message}
894
+ `);
895
+ for (const hint of error.hints) process.stderr.write(`Hint: ${hint}
896
+ `);
897
+ process.exitCode = error.status === 401 ? 3 : 1;
898
+ } else if (error instanceof CliError) {
899
+ process.stderr.write(`${error.message}
900
+ `);
901
+ process.exitCode = error.exitCode;
902
+ } else {
903
+ const detail = error instanceof Error ? error.message : String(error);
904
+ process.stderr.write(`${detail}
905
+ `);
906
+ process.exitCode = 1;
907
+ }
908
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@caisual/cli",
3
+ "version": "0.1.0",
4
+ "description": "Create and publish browser games on Caisual",
5
+ "type": "module",
6
+ "bin": {
7
+ "caisual": "dist/caisual.mjs"
8
+ },
9
+ "files": [
10
+ "dist/caisual.mjs"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "build": "node build.mjs",
17
+ "prepack": "pnpm build",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "pnpm build && node --test test/*.test.mjs"
20
+ },
21
+ "devDependencies": {
22
+ "@caisual/contracts": "workspace:*",
23
+ "@types/node": "^26.2.0",
24
+ "esbuild": "^0.28.1",
25
+ "typescript": "^7.0.2"
26
+ }
27
+ }