@xgamesfun/xcore 0.1.0 → 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.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/add.mjs CHANGED
@@ -1,10 +1,11 @@
1
- // `xcore add signin|database`: games.fun's player sign-in, saves and leaderboards, and a database of the game's own,
1
+ // `xcore add signin|database`: xgames.fun's player sign-in, saves and leaderboards, and a database of the game's own,
2
2
  // put into a game made without the kit, in the shape its engine takes:
3
3
  // - a web page: gamesfun.js beside the page (the folder its build copies as it is) and a <script> in index.html;
4
4
  // - a Godot 4 project: addons/gamesfun/gamesfun.gd, autoloaded as GamesFun in project.godot;
5
5
  // - a Unity project: Assets/Plugins/GamesFun/ (GamesFun.jslib and GamesFun.cs).
6
- // The database is server code (docs/SERVER.md): server/database.js, one Durable Object with SQLite storage, and the
7
- // Worker that routes api/db/ to it, declared in gamesfun.json → server. Publish puts it on games.fun with the game.
6
+ // The database is the game's own D1 (gamesfun.json → database, docs/SERVER.md): a game with a server of its own reaches
7
+ // it from its code (server/gf-db.js), and a game with none gets xcore's stock server, which serves the page's gf.db.
8
+ // The older database, server/database.js (one Durable Object with SQLite storage), is kept for a game that has it.
8
9
  import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
9
10
  import { existsSync, readFileSync } from 'node:fs';
10
11
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -12,6 +13,14 @@ import path from 'node:path';
12
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
14
  import { CMD } from './command.mjs';
14
15
  import { CONFIG_FILE, pageRoots } from './publish/page.mjs';
16
+ import {
17
+ DATABASE_DEFAULTS,
18
+ DEFAULT_COLLECTIONS,
19
+ OLD_DEFAULT_RULES,
20
+ OWNER_SPACES,
21
+ readServerConfig,
22
+ } from './server/contract.mjs';
23
+ import { dataCallLine, ownerlessDataCalls } from './server/data-calls.mjs';
15
24
  import { BACKEND_FILE } from './publish/scan.mjs';
16
25
  import { serverRpc } from './solana-rpc.mjs';
17
26
  import { isPageFolder, shown as shownPath } from './games.mjs';
@@ -195,7 +204,7 @@ export async function addSignin(dir) {
195
204
  'A script that also runs without the autoload (headless tests, godot --script) cannot use the bare name GamesFun: there, get_node_or_null("/root/GamesFun"), and play on when it is null.',
196
205
  'Export for the Web with Thread Support off, then adopt the project with that folder: ' +
197
206
  `${CMD} adopt . --out <the export folder>.`,
198
- 'In the editor and in a desktop build, sign-in, saves, leaderboards and the database answer an error and the game plays on; on games.fun and under xcore check they work (a local player on this machine).',
207
+ 'In the editor and in a desktop build, sign-in, saves, leaderboards and the database answer an error and the game plays on; on xgames.fun and under xcore check they work (a local player on this machine).',
199
208
  ],
200
209
  };
201
210
  }
@@ -211,7 +220,7 @@ export async function addSignin(dir) {
211
220
  'When the browser keeps the sign-in window shut (a phone, a post on X), GamesFun.SignInCode gives {code, site}: show "Go to <site>/go and enter <code>".',
212
221
  'Build for WebGL with Compression Format Disabled (or Decompression Fallback on) and threads off, then adopt the project with the build folder: ' +
213
222
  `${CMD} adopt . --out <the build folder>.`,
214
- 'In the editor every call answers an error and the game plays on; in a WebGL build, on games.fun and under xcore check, it works (a local player on this machine).',
223
+ 'In the editor every call answers an error and the game plays on; in a WebGL build, on xgames.fun and under xcore check, it works (a local player on this machine).',
215
224
  ],
216
225
  };
217
226
  }
@@ -260,7 +269,7 @@ export async function addSignin(dir) {
260
269
  : []),
261
270
  'In the game: const gf = GamesFun; const player = await gf.signIn() (from a click), gf.onChange(player => …), await gf.saves.put("main", {...}), await gf.scores.submit("best", score), await gf.scores.top("best"); every call is listed at the top of gamesfun.js.',
262
271
  'When the browser keeps the sign-in window shut (a phone, a post on X), gf.onCode(({code, site, window}) => …) says what to show: "Go to <site>/go and enter <code>". Show it plainly when window is false (the browser blocked the window). When a window opened, show it only as a quiet line, never an overlay: the player is still signing in there, and X’s login page hides from the game whether that window is still open.',
263
- 'On this machine (localhost, xcore check) a local player and this browser’s storage stand in; under xcore dev a local sign-in, the platform and the game’s server answer as games.fun will; once published, games.fun signs players in.',
272
+ 'On this machine (localhost, xcore check) a local player and this browser’s storage stand in; under xcore dev a local sign-in, the platform and the game’s server answer as xgames.fun will; once published, xgames.fun signs players in.',
264
273
  ],
265
274
  };
266
275
  }
@@ -273,6 +282,13 @@ const PARTS = {
273
282
  handler: 'handleDatabase',
274
283
  what: 'its database answers api/db/',
275
284
  },
285
+ // the page's documents in the game's own D1 (gamesfun.json → database.collections): written by addDocs, not addPart
286
+ docs: {
287
+ file: 'gf-docs.js',
288
+ object: null,
289
+ handler: 'handleDocs',
290
+ what: "the page's gf.db answers api/db/ from the game's database, under gamesfun.json → database.collections",
291
+ },
276
292
  rooms: {
277
293
  file: 'rooms.js',
278
294
  object: ROOM_OBJECT,
@@ -297,8 +313,10 @@ const OURS = 'Written by `xcore add';
297
313
  /** server/index.js for the parts xcore wrote (only ever rewritten while it is xcore's own). */
298
314
  export function serverIndex(parts) {
299
315
  const list = Object.keys(PARTS).filter(k => parts.includes(k));
300
- const commands = list.map(k => (k === 'rooms' ? 'multiplayer' : k)).join('` and `xcore add ');
301
- return `// The game's server on games.fun (docs/SERVER.md in @xgamesfun/xcore). ${OURS} ${commands}\`:
316
+ const commands = [...new Set(list.map(k => ({ rooms: 'multiplayer', docs: 'database' })[k] ?? k))].join(
317
+ '` and `xcore add ',
318
+ );
319
+ return `// The game's server on xgames.fun (docs/SERVER.md in @xgamesfun/xcore). ${OURS} ${commands}\`:
302
320
  // ${list.map(k => PARTS[k].what).join('; ')}. To add routes of the game's own, delete the words "Written by" above
303
321
  // first: xcore then leaves this file alone (and says which two lines to add for anything it adds later).
304
322
  ${list.map(k => `import { ${PARTS[k].handler} } from './${PARTS[k].file}';`).join('\n')}
@@ -323,22 +341,8 @@ ${list.map(k => ` const ${k} = await ${PARTS[k].handler}(request, env);\n
323
341
  `;
324
342
  }
325
343
 
326
- /** One part of server code (the database, the rooms) into the game: its file, its object, its route. */
327
- async function addPart(dir, part, { render = text => text } = {}) {
328
- const signin = await addSignin(dir);
329
- const written = [...signin.written];
330
- const spec = PARTS[part];
331
- const server = path.join(dir, 'server');
332
- // the settings at its top (ROOM, RULES, LIMITS, PAY) are the person's: kept as they set them
333
- await put(path.join(server, spec.file), render(read(`server/${spec.file}`)), written, dir, { settings: true });
334
- const index = ['index.js', 'index.mjs'].map(f => path.join(server, f)).find(f => existsSync(f));
335
- const indexText = index ? await readFile(index, 'utf8') : null;
336
- const ours = indexText === null || indexText.includes(OURS);
337
- if (ours) {
338
- const parts = Object.keys(PARTS).filter(k => k === part || existsSync(path.join(server, PARTS[k].file)));
339
- await put(index ?? path.join(server, 'index.js'), serverIndex(parts), written, dir);
340
- }
341
- // gamesfun.json → server.objects: the part's Durable Object, beside any the server has
344
+ /** gamesfun.json, read and checked for one more part: valid JSON, and room for its Durable Object. Throws in words. */
345
+ async function serverConfig(dir, spec) {
342
346
  const cfgFile = path.join(dir, CONFIG_FILE);
343
347
  let cfg = {};
344
348
  if (existsSync(cfgFile)) {
@@ -349,9 +353,30 @@ async function addPart(dir, part, { render = text => text } = {}) {
349
353
  }
350
354
  }
351
355
  const objects = Array.isArray(cfg.server?.objects) ? cfg.server.objects : [];
356
+ if (spec.object && !objects.some(o => o?.binding === spec.object.binding) && objects.length >= 8)
357
+ throw new Error(`${CONFIG_FILE} already declares 8 Durable Objects, the most a game's server may have.`);
358
+ return { cfgFile, cfg, objects };
359
+ }
360
+
361
+ /** One part of server code (the database, the rooms) into the game: its file, its object, its route. */
362
+ async function addPart(dir, part, { render = text => text } = {}) {
363
+ // everything that can refuse is asked before the first file is written, so a refusal leaves the game as it was
364
+ await serverConfig(dir, PARTS[part]);
365
+ // a game whose database the stock server served: from now on its own server answers api/*, and server/gf-docs.js
366
+ // goes on answering the page's gf.db there
367
+ const wasStock = !!(await readServerConfig(dir)).stock;
368
+ const signin = await addSignin(dir);
369
+ const written = [...signin.written];
370
+ const spec = PARTS[part];
371
+ const server = path.join(dir, 'server');
372
+ // the settings at its top (ROOM, RULES, LIMITS, PAY) are the person's: kept as they set them
373
+ await put(path.join(server, spec.file), render(read(`server/${spec.file}`)), written, dir, { settings: true });
374
+ const docs = await addDocs(dir, written, { wasStock });
375
+ const { index, indexText, ours } = await routeParts(dir, written, part);
376
+ // gamesfun.json → server.objects: the part's Durable Object, beside any the server has (read again: adding the
377
+ // sign-in may have written it)
378
+ const { cfgFile, cfg, objects } = await serverConfig(dir, spec);
352
379
  if (spec.object && !objects.some(o => o?.binding === spec.object.binding)) {
353
- if (objects.length >= 8)
354
- throw new Error(`${CONFIG_FILE} already declares 8 Durable Objects, the most a game's server may have.`);
355
380
  cfg.server = { ...(cfg.server ?? {}), objects: [...objects, spec.object] };
356
381
  await writeFile(cfgFile, JSON.stringify(cfg, null, 2) + '\n');
357
382
  written.push(`declared ${spec.object.class} (${spec.object.binding}) in ${CONFIG_FILE} → server.objects`);
@@ -362,11 +387,75 @@ async function addPart(dir, part, { render = text => text } = {}) {
362
387
  : [
363
388
  `server/${path.basename(index)} is your own: route ${{ rooms: 'the rooms', database: "the database's calls", rpc: 'api/rpc', vouchers: 'api/vouchers/' }[part]} in it.${spec.object ? ` Add \`export { ${spec.object.class} } from './${spec.file}';\` at the top, and` : ''} First thing in fetch: \`const r = await ${spec.handler}(request, env); if (r) return r;\` (with \`import { ${spec.handler} } from './${spec.file}';\`).`,
364
389
  ];
390
+ route.push(...docsRoute(docs, { ours, indexText, index, wasStock }));
365
391
  return { signin, written, route };
366
392
  }
367
393
 
394
+ /** database.js's documents code, between its marks: what the stock server and server/gf-docs.js share. */
395
+ export function docsSource() {
396
+ const db = read('server/database.js');
397
+ const start = db.indexOf("// ---- the documents' code");
398
+ const end = db.indexOf("// ---- end of the documents' code ----");
399
+ if (start < 0 || end < start) throw new Error("database.js has lost its documents' marks");
400
+ const core = db.slice(db.indexOf('\n', start) + 1, end).trim();
401
+ return read('server/gf-docs.js').replace('// __DOCS_CORE__', core);
402
+ }
403
+
404
+ /**
405
+ * The page's documents for a game with a server of its own: server/gf-docs.js, which answers api/db/ from the game's D1
406
+ * under gamesfun.json → database.collections, when the game has a database with collections. A game the stock server
407
+ * served (`wasStock`) keeps the rules it had: written out in gamesfun.json, so the helper reads the same ones.
408
+ * → true when the game has the helper
409
+ */
410
+ async function addDocs(dir, written, { wasStock = false } = {}) {
411
+ const { cfgFile, cfg } = await serverConfig(dir, {});
412
+ const d = cfg.database;
413
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return false;
414
+ if (d.collections === undefined) {
415
+ if (!wasStock) return false;
416
+ d.collections = structuredClone(DEFAULT_COLLECTIONS);
417
+ await writeFile(cfgFile, JSON.stringify(cfg, null, 2) + '\n');
418
+ written.push(
419
+ `wrote the stock server's rules into ${CONFIG_FILE} → database.collections, which server/gf-docs.js reads`,
420
+ );
421
+ }
422
+ await put(path.join(dir, 'server', PARTS.docs.file), docsSource(), written, dir, { settings: true });
423
+ return true;
424
+ }
425
+
368
426
  /**
369
- * The RPC's origin in backend.json → origins, which is all a game's server may call on games.fun: the folder's own
427
+ * server/index.js routing every part xcore wrote into server/, rewritten only while it is xcore's own (with `part`, the
428
+ * one being added, counted in). → {index, indexText (before), ours}
429
+ */
430
+ async function routeParts(dir, written, part = null) {
431
+ const server = path.join(dir, 'server');
432
+ const index = ['index.js', 'index.mjs'].map(f => path.join(server, f)).find(f => existsSync(f));
433
+ const indexText = index ? await readFile(index, 'utf8') : null;
434
+ const ours = indexText === null || indexText.includes(OURS);
435
+ if (ours) {
436
+ const parts = Object.keys(PARTS).filter(k => k === part || existsSync(path.join(server, PARTS[k].file)));
437
+ await put(index ?? path.join(server, 'index.js'), serverIndex(parts), written, dir);
438
+ }
439
+ return { index, indexText, ours };
440
+ }
441
+
442
+ /** What to say about server/gf-docs.js: how to route it in a server/index.js of the person's own, and a stock game's move. */
443
+ function docsRoute(docs, { ours, indexText, index, wasStock }) {
444
+ if (!docs) return [];
445
+ const out = [];
446
+ if (!ours && !indexText.includes(PARTS.docs.handler))
447
+ out.push(
448
+ `server/${path.basename(index)} is your own: route the page's documents in it. First thing in fetch: \`const db = await handleDocs(request, env); if (db) return db;\` (with \`import { handleDocs } from './gf-docs.js';\`). Until it does, every gf.db call answers 404, and publish refuses the game.`,
449
+ );
450
+ if (wasStock)
451
+ out.push(
452
+ `This game's database was served by xcore's stock server (the page's gf.db, at api/db/). Now it has a server of its own, and server/gf-docs.js answers gf.db there from the same documents (table gf_docs), under the same rules (${CONFIG_FILE} → database.collections).`,
453
+ );
454
+ return out;
455
+ }
456
+
457
+ /**
458
+ * The RPC's origin in backend.json → origins, which is all a game's server may call on xgames.fun: the folder's own
370
459
  * XSOLANA_RPC_URL (.dev.vars, else .env), its origin only (the key is in its path or query). → the step to say, or null.
371
460
  */
372
461
  async function allowRpc(dir, written) {
@@ -406,8 +495,8 @@ export async function addRpc(dir) {
406
495
  ...route,
407
496
  `Get a devnet RPC URL of your own (Helius, Triton or QuickNode; their free plans fit a small game). Under ${CMD} dev, put XSOLANA_RPC_URL=… in the folder's .dev.vars (a hidden file publish never sends; keep it out of git).`,
408
497
  ...(origin ? [origin] : []),
409
- `Publish (${CMD} publish), then give the URL to the game's server: \`${CMD} secret set XSOLANA_RPC_URL\` (it asks for it; games.fun keeps it on the server, never the page, and it reaches the next request with no publish). Until it is set, api/rpc says so and the page keeps using games.fun's shared relay, so nothing breaks.`,
410
- "Why: games.fun's shared relay counts every call toward the game's plan ($10 a million calls, the economy's biggest cost a player); through the game's own RPC a call is one request to its server (about $0.30 a million) and the RPC's own plan.",
498
+ `Publish (${CMD} publish), then give the URL to the game's server: \`${CMD} secret set XSOLANA_RPC_URL\` (it asks for it; xgames.fun keeps it on the server, never the page, and it reaches the next request with no publish). Until it is set, api/rpc says so and the page keeps using xgames.fun's shared relay, so nothing breaks.`,
499
+ "Why: xgames.fun's shared relay counts every call toward the game's plan ($10 a million calls, the economy's biggest cost a player); through the game's own RPC a call is one request to its server (about $0.30 a million) and the RPC's own plan.",
411
500
  "Each call counts toward the game server's requests (on Free 120 a minute from one player and 600 a game): the economy's reads every few seconds fit; RPC.methods at the top of server/rpc.js is what it passes on.",
412
501
  ],
413
502
  };
@@ -458,7 +547,7 @@ export async function seedAddress(seed) {
458
547
  return encodeBase58(new Uint8Array(spki.subarray(-32)));
459
548
  }
460
549
 
461
- /** A new voucher key: {seed (hex), address}. The live key is made this way and sent to games.fun, never kept here. */
550
+ /** A new voucher key: {seed (hex), address}. The live key is made this way and sent to xgames.fun, never kept here. */
462
551
  export async function newVoucherKey() {
463
552
  const seed = randomBytes(32).toString('hex');
464
553
  return { seed, address: await seedAddress(seed) };
@@ -476,6 +565,13 @@ export async function addVouchers(dir) {
476
565
  throw new Error(
477
566
  `${dir} has no economy yet: a voucher is claimed from a grant in it. Make it first (${CMD} loot add ${shownPath(dir)}, then a grant in ${ECONOMY_FILE}), and run this again.`,
478
567
  );
568
+ // a bad test key refuses here, before any file is written
569
+ const vars = path.join(dir, '.dev.vars');
570
+ const varsText = existsSync(vars) ? await readFile(vars, 'utf8') : '';
571
+ if (/^\s*VOUCHER_SEED\s*=/m.test(varsText) && !/^\s*VOUCHER_SEED\s*=\s*["']?[0-9a-fA-F]{64}["']?\s*$/m.test(varsText))
572
+ throw new Error(
573
+ `.dev.vars has a VOUCHER_SEED that is not a 32-byte seed in hex (64 characters): fix it or delete that line, and run this again.`,
574
+ );
479
575
  let from = path.relative(path.join(dir, 'server'), economy).split(path.sep).join('/');
480
576
  if (!from.startsWith('.')) from = `./${from}`;
481
577
  const { signin, written, route } = await addPart(dir, 'vouchers', {
@@ -498,8 +594,8 @@ export async function addVouchers(dir) {
498
594
  address,
499
595
  steps: [
500
596
  ...route,
501
- `Two keys sign vouchers: a test key for ${CMD} dev (its seed in .dev.vars; address ${address}) and the live key, made on games.fun so it is never on this machine. A copy of this folder can sign only what the grant lets the test key sign.`,
502
- `Once published (${CMD} publish): ${CMD} secret set VOUCHER_SEED --new-key makes the live key on the server and prints its address. Put that address in the grant that pays results, in ${where}: "vouchers": ["<the live key's address>"] (up to three keys; the keeper's can stay beside it); then ${CMD} loot check, ${CMD} loot approve, ${CMD} publish.`,
597
+ `Two keys sign vouchers: a test key for ${CMD} dev (its seed in .dev.vars; address ${address}) and the live key, which ${CMD} secret set VOUCHER_SEED --new-key makes and sends to xgames.fun only, never keeping it on this machine. A copy of this folder can sign only what the grant lets the test key sign.`,
598
+ `Once published (${CMD} publish): ${CMD} secret set VOUCHER_SEED --new-key makes the live key, sends it to the game's server on xgames.fun only, and prints its address. Put that address in the grant that pays results, in ${where}: "vouchers": ["<the live key's address>"] (up to three keys; the keeper's can stay beside it); then ${CMD} loot check, ${CMD} loot approve, ${CMD} publish.`,
503
599
  named.length
504
600
  ? `${where} names the test key in ${named.map(k => `"${k}"`).join(', ')}: claims signed under ${CMD} dev pay on devnet. Take it out of the grant before the economy holds anything of value.`
505
601
  : `To claim a voucher signed under ${CMD} dev, add the test key's address too (devnet only; take it out before the economy holds anything of value).`,
@@ -513,19 +609,148 @@ export async function addVouchers(dir) {
513
609
  };
514
610
  }
515
611
 
516
- /** The game's own database (server/database.js) and the page calls for it. → {engine, written, steps} */
612
+ /**
613
+ * The game's own database: a D1 database of its own (gamesfun.json → database; xgames.fun's STORAGE.md §8.2).
614
+ * - A game with a server of its own gets the block, an example first migration (server/migrations/0001_init.sql)
615
+ * and server/gf-db.js (prepared queries, and withPlayer: the signed-in player's id), for its own routes.
616
+ * - A game with no server gets the block with `collections`: xcore's stock server serves the page's gf.db from it.
617
+ * - A game that has server/database.js (the older way, a Durable Object) keeps it, brought up to date.
618
+ * → {engine, written, steps}
619
+ */
517
620
  export async function addDatabase(dir) {
621
+ if (existsSync(path.join(dir, 'server', 'database.js'))) return addDatabaseObject(dir);
622
+ // everything that can refuse is asked before the first file is written
623
+ const { cfgFile, cfg } = await serverConfig(dir, {});
624
+ const before = await readServerConfig(dir);
625
+ const own = before.has && !before.stock;
626
+ if (cfg.database !== undefined && before.database?.problems.length)
627
+ throw new Error(`${CONFIG_FILE} database: ${before.database.problems.join('; ')}; fix it first`);
628
+ if (cfg.database === undefined && (cfg.server?.objects ?? []).some(o => o?.binding === DATABASE_DEFAULTS.binding))
629
+ throw new Error(
630
+ `${CONFIG_FILE} has a Durable Object bound as ${DATABASE_DEFAULTS.binding}: add "database": {"binding": "GAME_DB"} (another name) to ${CONFIG_FILE} first, then run this again`,
631
+ );
632
+ const signin = await addSignin(dir);
633
+ const written = [...signin.written];
634
+ // gamesfun.json read again: adding the sign-in may have written it
635
+ const { cfg: now } = await serverConfig(dir, {});
636
+ if (now.database === undefined) {
637
+ now.database = own
638
+ ? { engine: 'd1', binding: DATABASE_DEFAULTS.binding, migrations: DATABASE_DEFAULTS.migrations }
639
+ : { engine: 'd1', binding: DATABASE_DEFAULTS.binding, collections: structuredClone(DEFAULT_COLLECTIONS) };
640
+ await writeFile(cfgFile, JSON.stringify(now, null, 2) + '\n');
641
+ written.push(`declared the game's database in ${CONFIG_FILE} → database (env.${now.database.binding})`);
642
+ }
643
+ const database = (await readServerConfig(dir)).database;
644
+ const call = { godot: 'await GamesFun.db_add("levels", {...})', unity: 'await GamesFun.DbAdd("levels", json)' };
645
+ const later = [
646
+ `${CMD} dev runs the database on this machine (kept in .gamesfun/dev/d1/, never published), with the same migrations; ${CMD} db query "SELECT …" reads it, and ${CMD} db query … --live the one on xgames.fun.`,
647
+ `${CMD} publish makes the game's database on xgames.fun (one a game, kept between publishes) and applies the new migrations before the new version goes live. ${CMD} db export --live keeps a copy; ${CMD} db restore <time> --live goes back up to 30 days.`,
648
+ ];
649
+ // a stock game may have migrations of its own too: their line endings are kept as they are
650
+ const migrationsFolder = path.join(dir, ...database.migrationsDir.split('/'));
651
+ if (!own && existsSync(migrationsFolder)) await migrationsAttributes(migrationsFolder, written, dir);
652
+ if (!own)
653
+ return {
654
+ engine: signin.engine,
655
+ written,
656
+ steps: [
657
+ ...signin.steps.filter(x => !/^(In the game|From GDScript|From C#):/.test(x)),
658
+ `From the game: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete (documents in collections, any JSON up to 64 KB). This game has no server of its own, so xcore's stock server answers them from its database.`,
659
+ `Each player has a space of their own in every collection, and reads and writes only that. Change it per collection in ${CONFIG_FILE} → database.collections: {"read": "anyone"} lets everyone read one (another player's document by its owner: gf.db.get("levels", id, {owner})); {"write": "server"} is for what players must not set themselves, and needs a server of your own to write it.`,
660
+ 'A list comes a page at a time, newest first: pass the answer\'s after back for the next one (gf.db.list("levels", {after})).',
661
+ ...later,
662
+ ],
663
+ };
664
+ // a server of its own: the example migration (only while there is none) and the helper for its routes
665
+ const folder = migrationsFolder;
666
+ if (!database.migrations.length)
667
+ await put(path.join(folder, '0001_init.sql'), read('server/0001_init.sql'), written, dir);
668
+ await migrationsAttributes(folder, written, dir);
669
+ await put(
670
+ path.join(dir, 'server', 'gf-db.js'),
671
+ read('server/gf-db.js').replace('__BINDING__', database.binding),
672
+ written,
673
+ dir,
674
+ );
675
+ // the page's documents too (gf.db), when the block has collections: server/gf-docs.js, routed in server/index.js
676
+ const docs = await addDocs(dir, written);
677
+ const route = docs ? docsRoute(docs, { ...(await routeParts(dir, written)), wasStock: false }) : [];
678
+ return {
679
+ engine: signin.engine,
680
+ written,
681
+ steps: [
682
+ ...signin.steps.filter(x => !/^(In the game|From GDScript|From C#):/.test(x)),
683
+ ...route,
684
+ ...(docs
685
+ ? [
686
+ `The page's documents (gf.db: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete) are server/gf-docs.js's, in the same database (table gf_docs), under ${CONFIG_FILE} → database.collections; this server's own code reaches them with docsOf(env).`,
687
+ ]
688
+ : [
689
+ `The page's gf.db documents are not served here: to keep them in this database too, add "collections": {"*": {"read": "owner", "write": "owner"}} to ${CONFIG_FILE} → database and run ${CMD} add database again (it writes server/gf-docs.js and routes it).`,
690
+ ]),
691
+ `This game has a server of its own: its code reads and writes the database. import { db, withPlayer } from './gf-db.js'; then in a route: const sub = await withPlayer(request, env) (the signed-in player's id, or null), and db(env).first / all / run / batch with ? for every value. env.${database.binding} is D1 itself.`,
692
+ `Its tables come from ${database.migrationsDir}/ (0001_init.sql is an example: change it now, before it is published). Every later change is a new file, 0002_<what>.sql: once a migration is published it never changes, and a changed one is refused.`,
693
+ 'A rule that must hold however many requests come at once is one statement: UPDATE players SET runs = runs - 1 WHERE id = ? AND runs > 0 RETURNING runs; several that go together, db(env).batch([...]), all or none.',
694
+ ...later,
695
+ ],
696
+ };
697
+ }
698
+
699
+ /** A migration's bytes are its sha256: git must not change their line endings (core.autocrlf on Windows would). */
700
+ export const MIGRATIONS_GITATTRIBUTES =
701
+ "# a migration's bytes are its sha256 (xgames.fun refuses one that changed): no line-ending conversion\n*.sql -text\n";
702
+ async function migrationsAttributes(folder, written, dir) {
703
+ const file = path.join(folder, '.gitattributes');
704
+ if (!existsSync(file)) await put(file, MIGRATIONS_GITATTRIBUTES, written, dir);
705
+ }
706
+
707
+ /**
708
+ * The older database (server/database.js: one Durable Object with SQLite storage, and the route to it), kept for a
709
+ * game that has it: brought up to date, and still working. A new game's database is its own D1 (addDatabase).
710
+ * → {engine, written, steps}
711
+ */
712
+ export async function addDatabaseObject(dir) {
713
+ // what can refuse (gamesfun.json unreadable, its 8 Durable Objects taken) is asked before database.js is touched
714
+ await serverConfig(dir, PARTS.database);
715
+ // an untouched RULES from 0.1.0 is the old default, not the person's choice: it becomes the new one
716
+ const dbFile = path.join(dir, 'server', 'database.js');
717
+ let opened = false,
718
+ upgraded = false;
719
+ if (existsSync(dbFile)) {
720
+ const text = await readFile(dbFile, 'utf8');
721
+ // 0.1.0's database.js: one id a collection, whoever wrote it (no space of each player's own)
722
+ upgraded = !text.includes(OWNER_SPACES);
723
+ if (text.includes(OLD_DEFAULT_RULES)) {
724
+ await writeFile(dbFile, text.replace(OLD_DEFAULT_RULES, settingsBlock(read('server/database.js'), 'RULES').text));
725
+ opened = true;
726
+ }
727
+ }
518
728
  const { signin, written, route } = await addPart(dir, 'database');
519
729
  const call = { godot: 'await GamesFun.db_add("levels", {...})', unity: 'await GamesFun.DbAdd("levels", json)' };
730
+ // the server's own 0.1.0 calls, which mean something else over the new database.js: each said with its change
731
+ const calls = upgraded ? await ownerlessDataCalls(dir) : [];
520
732
  return {
521
733
  engine: signin.engine,
522
734
  written,
735
+ calls,
523
736
  steps: [
524
737
  ...signin.steps.filter(x => !/^(In the game|From GDScript|From C#):/.test(x)),
525
738
  ...route,
526
- `From the game: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete (documents in collections, any JSON up to 64 KB). Anyone reads; a signed-in player writes only their own documents. Change that per collection in RULES at the top of server/database.js, and use write: "server" for anything players must not set themselves (a prize, a verified score).`,
527
- 'Your server code reaches it past those rules with dataOf(env) (import { dataOf } from "./database.js").',
528
- `On this machine the page keeps documents in the browser instead; ${CMD} publish puts the database on games.fun with the game (a Durable Object with SQLite storage, kept between publishes).`,
739
+ ...(calls.length
740
+ ? [
741
+ `server/database.js was 0.1.0's, where a document was found by its id alone, whoever wrote it. Now every player has a space of their own, and dataOf(env) without an owner is the server's: change ${calls.length === 1 ? 'this call' : `these ${calls.length} calls`} before you publish (publish says them again until each names its owner):\n${calls.map(c => ` ${dataCallLine(c)}`).join('\n')}`,
742
+ ]
743
+ : []),
744
+ 'server/database.js is the older database (a Durable Object). It keeps working; a game\'s database is now a D1 of its own (gamesfun.json → database, docs/SERVER.md "A database of its own"), which xcore db can query, export and restore.',
745
+ ...(opened
746
+ ? [
747
+ "RULES in server/database.js was the old default, where anyone read every document: it is now read: 'owner' (each player reads only their own). To keep a collection public, name it: levels: { read: 'anyone', write: 'owner' }.",
748
+ ]
749
+ : []),
750
+ `From the game: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete (documents in collections, any JSON up to 64 KB). Each player has a space of their own in every collection, and reads and writes only that: no player can take or see another's documents. Change that per collection in RULES at the top of server/database.js: read: "anyone" lets everyone read a collection (another player's document by its owner: gf.db.get("levels", id, {owner})), and write: "server" is for anything players must not set themselves (a prize, a verified score).`,
751
+ 'A list comes a page at a time, newest first: pass the answer\'s after back for the next one (gf.db.list("levels", {after})).',
752
+ 'Your server code reaches it past those rules with dataOf(env) (import { dataOf } from "./database.js"): its own documents (owner "server") by default, a player\'s with {owner: sub}.',
753
+ `On this machine the page keeps documents in the browser instead; ${CMD} publish puts the database on xgames.fun with the game (a Durable Object with SQLite storage, kept between publishes).`,
529
754
  "Every call counts toward the game server's requests (on Free 120 a minute from one player and 600 shared by all its players; on Creator 600 and 6,000): read and write when something happens (a level saved, a profile changed), not every frame.",
530
755
  ],
531
756
  };
@@ -537,19 +762,19 @@ export async function addMultiplayer(dir) {
537
762
  const how = {
538
763
  godot: [
539
764
  "From GDScript, Godot's own multiplayer: `var peer = await GamesFun.join_room(code)` then `multiplayer.multiplayer_peer = peer` in place of an ENetMultiplayerPeer (null: GamesFun.room_error says why). The first player in is the server (id 1); RPCs, MultiplayerSpawner and MultiplayerSynchronizer work as over ENet, all reliable and ordered. In the editor and a desktop build the same call makes a local ENet game (Debug → Customize Run Instances to run two).",
540
- 'A code: GamesFun.room_from_link() (the room a shared link opened) or GamesFun.room_code() (a new one); share GamesFun.room_link(code), which opens the game in that room on games.fun and in a post on X.',
765
+ 'A code: GamesFun.room_from_link() (the room a shared link opened) or GamesFun.room_code() (a new one); share GamesFun.room_link(code), which opens the game in that room on xgames.fun and in a post on X.',
541
766
  'Godot\'s ENet does not run in a browser: replace ENetMultiplayerPeer.create_server/create_client with join_room, and keep the rest of the game\'s multiplayer code. A Host button: join_room(GamesFun.room_code(), {"host": true}); a Join button: join_room(code, {"host": false}), which fails as ENet does when no one is there. A variable typed ENetMultiplayerPeer becomes MultiplayerPeer.',
542
767
  "The peer leaves its room when the game lets go of it (multiplayer.multiplayer_peer = null with no variable holding it) or on peer.close(). ROOM.maxPlayers in server/rooms.js is create_server's max_clients, for every room. Every packet is reliable and ordered, unreliable ones too; a frame's packets go to the room as one message.",
543
768
  "When the host leaves, its clients see server_disconnected, as over ENet (Godot's server is never handed on).",
544
769
  ],
545
770
  unity: [
546
771
  "From C#: `var room = await GamesFun.JoinRoom(code)` (null: GamesFun.RoomError says why; JoinRoom(code, name, host: true) opens a new room only, host: false joins one only); room.Id, room.Host, room.IsHost; room.OnMessage, OnBytes, OnJoin, OnLeave, OnHost, OnClose; room.Send(json, to), room.SendBytes(bytes, to), room.Lock(), room.Leave(). The first player in is the host (Id 1); to: a player's id, 0 everyone else, -id everyone but one.",
547
- 'A code: await GamesFun.RoomFromLink() (the room a shared link opened) or GamesFun.RoomCode(); share await GamesFun.RoomLink(code), which opens the game in that room on games.fun and in a post on X.',
772
+ 'A code: await GamesFun.RoomFromLink() (the room a shared link opened) or GamesFun.RoomCode(); share await GamesFun.RoomLink(code), which opens the game in that room on xgames.fun and in a post on X.',
548
773
  "A netcode library's own transport (Netcode for GameObjects, Mirror) needs a server of its own: run game logic on the host (IsHost) and send state with room.Send / SendBytes instead.",
549
774
  ],
550
775
  page: [
551
776
  'In the game: `const { data: room, error } = await GamesFun.rooms.join(code)` (opts {name, host}: host true opens a new room only, false joins one only); room.id, room.host, room.isHost, room.peers; room.on("message" | "bytes" | "join" | "leave" | "host" | "close", …); room.send(data, to), room.sendBytes(bytes, to), room.lock(), room.leave(). The first player in is the host (id 1); to: a player\'s id, 0 everyone else, -id everyone but one.',
552
- 'A code: GamesFun.rooms.fromLink() (the room a shared link opened) or GamesFun.rooms.code(); share GamesFun.rooms.link(code), which opens the game in that room on games.fun and in a post on X.',
777
+ 'A code: GamesFun.rooms.fromLink() (the room a shared link opened) or GamesFun.rooms.code(); share GamesFun.rooms.link(code), which opens the game in that room on xgames.fun and in a post on X.',
553
778
  ],
554
779
  };
555
780
  return {
@@ -560,7 +785,7 @@ export async function addMultiplayer(dir) {
560
785
  ...route,
561
786
  ...how[signin.engine],
562
787
  "A room is a relay: the host's game decides what happens, and the room never reads a message. ROOM at the top of server/rooms.js sets the most players (16), whether only signed-in players may join, and the message limits.",
563
- `On this machine, tabs of the same browser share a room (open the game twice); ${CMD} publish puts the rooms on games.fun with the game. A player's connection counts once toward the game server's requests (on Free 120 a minute from one player and 600 a game; on Creator 600 and 6,000), however many messages it carries.`,
788
+ `On this machine, tabs of the same browser share a room (open the game twice); ${CMD} publish puts the rooms on xgames.fun with the game. A player's connection counts once toward the game server's requests (on Free 120 a minute from one player and 600 a game; on Creator 600 and 6,000), however many messages it carries.`,
564
789
  ],
565
790
  };
566
791
  }
package/lib/browser.mjs CHANGED
@@ -111,8 +111,11 @@ export async function ensureBrowser({ log = console.error, install = true, env =
111
111
  log(
112
112
  "xcore: no Chrome or Edge found (set CHROME_PATH to use another). Downloading Playwright's Chromium once (about 170 MB) into its cache…",
113
113
  );
114
- const cli = createRequire(import.meta.url).resolve('playwright-core/cli.js');
115
- const r = spawnSync(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit', windowsHide: true });
114
+ // its progress goes to stderr: stdout stays the command's own (the JSON of --json)
115
+ const r = spawnSync(process.execPath, [playwrightCli(), 'install', 'chromium'], {
116
+ stdio: ['ignore', 2, 2],
117
+ windowsHide: true,
118
+ });
116
119
  const after = await playwrightChromium();
117
120
  if (r.status !== 0 || !after.installed) {
118
121
  throw new Error(
@@ -124,6 +127,12 @@ export async function ensureBrowser({ log = console.error, install = true, env =
124
127
  return { path: after.path, kind: 'chromium', source: 'playwright (installed now)' };
125
128
  }
126
129
 
130
+ /** playwright-core's own command line (its `install`). Found beside its package.json: the package's exports do not
131
+ * name cli.js, so it cannot be resolved by that name. → its path */
132
+ export function playwrightCli() {
133
+ return path.join(path.dirname(createRequire(import.meta.url).resolve('playwright-core/package.json')), 'cli.js');
134
+ }
135
+
127
136
  /** A headless browser for the page check (HEADED=1 shows it). `chromium`: another Playwright BrowserType (the kit's). */
128
137
  export async function launchBrowser({ chromium = null, headless = process.env.HEADED !== '1' } = {}) {
129
138
  const type = chromium ?? (await import('playwright-core')).chromium;
package/lib/check.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // The page check: a game made anywhere, checked as games.fun hosts it. `xcore check` and publish run it for a brought
1
+ // The page check: a game made anywhere, checked as xgames.fun hosts it. `xcore check` and publish run it for a brought
2
2
  // game (gamesfun.json → page, ./publish/page.mjs: `xcore adopt`); the kit runs it for a copy of one of its starts
3
3
  // that is a page of its own, adding that copy's own two checks (`start`, below).
4
4
  //
@@ -8,7 +8,7 @@
8
8
  // 2 it fits the hosting: what publish would refuse (index.html at the root, .env files, links, secrets,
9
9
  // backend.json, the host's own paths) and what breaks once hosted (limits, paths from the root, scripts, styles,
10
10
  // fonts or frames from other sites): ./publish/adopt.mjs
11
- // 3 the page starts: its published files served under a folder (as games.fun Studio's preview serves them) with the
11
+ // 3 the page starts: its published files served under a folder (as xgames.fun Studio's preview serves them) with the
12
12
  // play host's page policy (./publish/hosting.mjs), in Chrome at a phone's size and a desktop's: no error thrown,
13
13
  // nothing in the console's errors, none of its own files missing, and it shows something
14
14
  // 4 it stays inside its origins: every request the page made is one the hosted page may make (its own files, the
@@ -38,7 +38,7 @@ const VIEWS = [
38
38
  { name: 'desktop', width: 1280, height: 800 },
39
39
  ];
40
40
  const SETTLE_MS = Number(process.env.GF_PAGE_SETTLE_MS ?? 5000);
41
- /** Where the page is served on the check's own origin: under a folder, as games.fun Studio's preview and the kit's dev server serve it. */
41
+ /** Where the page is served on the check's own origin: under a folder, as xgames.fun Studio's preview and the kit's dev server serve it. */
42
42
  export const MOUNT = '/game/';
43
43
  /** Where the check's own server takes Chrome's policy-violation reports (outside MOUNT: never a file of the game). */
44
44
  const REPORTS = '/__gf/csp-report';
@@ -55,17 +55,18 @@ const readJsonSync = file => {
55
55
  * The published files as static files under MOUNT, with the play host's headers (its page policy for these backend
56
56
  * origins, its permissions, nosniff, its types). → {url (the page's), origin, server (Node's), close}
57
57
  * `xcore dev` (./dev/index.mjs) adds: `route(req, res, url)`, tried first (→ true when it answered: the game's server,
58
- * the platform, the RPC); `html(text)`, applied to each .html file served; `port` (default: any free one).
58
+ * the platform, the RPC); `html(text)`, applied to each .html file served; `port` (default: any free one); `files`: the
59
+ * origin of the game's files (its files domain here), which the page may load pictures, sounds and fonts from.
59
60
  */
60
61
  export function servePage(
61
62
  dir,
62
- { backend = [], server = false, route = null, html = null, port = 0, overlay = null } = {},
63
+ { backend = [], server = false, route = null, html = null, port = 0, overlay = null, files = [] } = {},
63
64
  ) {
64
65
  const root = resolve(dir);
65
66
  const over = overlay ? resolve(overlay) : null;
66
67
  // the play host's policy, plus a report address on this check's own server: Chrome reports every violation there,
67
68
  // a Web Worker's too, which no page event shows (a worker gets its policy from its own script's headers)
68
- const base = pageHeaders(backend);
69
+ const base = pageHeaders(backend, null, files);
69
70
  const headers = {
70
71
  ...base,
71
72
  'content-security-policy': `${base['content-security-policy']}; report-uri ${REPORTS}`,
@@ -576,7 +577,7 @@ export async function pageVerify(
576
577
  bad.length === 0,
577
578
  bad.length
578
579
  ? bad.slice(0, 8).join('; ')
579
- : `${pre.built.tree.files.length} files${pre.built.added?.length ? ` (with the ${pre.built.added.length} records games.fun keeps)` : ''}, ${(pre.built.bytes / 1048576).toFixed(1)} MB, nothing publish would refuse (${CMD} adopt lists what is worth a look)`,
580
+ : `${pre.built.tree.files.length} files${pre.built.added?.length ? ` (with the ${pre.built.added.length} records xgames.fun keeps)` : ''}, ${(pre.built.bytes / 1048576).toFixed(1)} MB, nothing publish would refuse (${CMD} adopt lists what is worth a look)`,
580
581
  );
581
582
  } else check(2, 'it fits the hosting', null, 'not read: the build failed');
582
583
  } else for (const c of await base.kept(game)) check(c.n, c.name, c.ok, c.detail);
@@ -584,7 +585,7 @@ export async function pageVerify(
584
585
  if (!built || !existsSync(join(roots.out, 'index.html'))) {
585
586
  const why = !built
586
587
  ? 'not run: the build failed'
587
- : `no index.html in ${relative(game, roots.out).split(sep).join('/') || 'the game'}${roots.build ? ' (build it first, or verify without --no-build)' : ''}`;
588
+ : `no index.html in ${relative(game, roots.out).split(sep).join('/') || 'the game'}${roots.build ? ' (build it first, or check it without --no-build)' : ''}`;
588
589
  check(3, 'the page starts', built ? false : null, why);
589
590
  check(4, 'it stays inside its origins', null, 'not run: the page did not start');
590
591
  } else {
@@ -600,7 +601,7 @@ export async function pageVerify(
600
601
  ...v.errors.map(e => `${v.name}: ${e}`),
601
602
  ...v.missing.map(m =>
602
603
  /^api\//.test(m) && !hasServer
603
- ? `${v.name}: missing ${m}: the page expects a server at api/, and games.fun answers api/ only from the game's own server/ (docs/SERVER.md); otherwise call a backend of your own (${CMD} backend use custom)`
604
+ ? `${v.name}: missing ${m}: the page expects a server at api/, and xgames.fun answers api/ only from the game's own server/ (docs/SERVER.md); otherwise call a backend of your own (${CMD} backend use custom)`
604
605
  : /^(undefined|null|NaN) \(/.test(m)
605
606
  ? `${v.name}: missing ${m}: the page asked for a file named ${m.split(' ')[0]}, a path from a variable that was not set (a field missing from its data, or a load that failed before it)`
606
607
  : `${v.name}: missing ${m}`,
package/lib/cli.mjs CHANGED
@@ -17,18 +17,33 @@ export function parse(args, options, usage) {
17
17
 
18
18
  export class UsageError extends Error {}
19
19
 
20
- /** Run a command body: prints errors plainly (no stack for expected ones) and maps them to exit codes. */
21
- export async function guard(fn) {
20
+ /**
21
+ * Run a command body: prints errors plainly (no stack for expected ones) and maps them to exit codes. `args`: the
22
+ * command's arguments; with --json among them a failure is also said on stdout, as
23
+ * { ok: false, error, code?, state?, next? }, so a script or an agent reads why from the JSON it asked for (`code` is
24
+ * the refusal's word: 'usage', 'login', 'files', 'secrets', 'build'…; `next`, the command that moves it on).
25
+ */
26
+ export async function guard(fn, args = []) {
27
+ const json = args.includes('--json');
22
28
  try {
23
29
  return (await fn()) ?? 0;
24
30
  } catch (e) {
25
- if (e instanceof UsageError) {
26
- console.error(e.message);
27
- return 2;
28
- }
29
- console.error(`xcore: ${e.message}`);
30
- if (process.env.GAMESFUN_DEBUG) console.error(e.stack);
31
- return 1;
31
+ const usage = e instanceof UsageError;
32
+ console.error(usage ? e.message : `xcore: ${e.message}`);
33
+ if (!usage && process.env.GAMESFUN_DEBUG) console.error(e.stack);
34
+ if (json)
35
+ printJson({
36
+ ok: false,
37
+ error: usage ? e.message.split('\n')[0] : e.message,
38
+ ...(usage
39
+ ? { code: 'usage' }
40
+ : typeof e.code === 'string' && !/^E[A-Z]+$/.test(e.code)
41
+ ? { code: e.code }
42
+ : {}),
43
+ ...(typeof e.state === 'string' ? { state: e.state } : {}),
44
+ ...(typeof e.next === 'string' ? { next: e.next } : {}),
45
+ });
46
+ return usage ? 2 : 1;
32
47
  }
33
48
  }
34
49