@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.
- package/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/publish/index.mjs
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
// `xcore publish`: a game to
|
|
1
|
+
// `xcore publish`: a game to xgames.fun, at a link anyone can play (xgames.fun hosts it; the person needs no host of
|
|
2
2
|
// their own). A game brought from anywhere (`xcore adopt`) or made with the kit (@xgamesfun/kit, which passes its own
|
|
3
3
|
// check): the command, the kit's MCP tool and its studio's Publish tab all come here.
|
|
4
4
|
//
|
|
5
5
|
// 1. signed in? ./client.mjs (xcore login)
|
|
6
6
|
// 2. the files ./tree.mjs: refuses .env files and links; leaves out evidence/ and hidden files
|
|
7
|
-
// 3. no secrets ./scan.mjs (
|
|
7
|
+
// 3. no secrets ./scan.mjs (xgames.fun runs the same scan again on its side)
|
|
8
8
|
// no reference names ./names.mjs: a file that names a game this one was made to look or play
|
|
9
9
|
// like refuses it (spec.json goes up as a copy without them; the person's records keep them)
|
|
10
10
|
// 4. its backend backend.json → origins (`xcore backend connect`): the only other sites the game may
|
|
11
|
-
// talk to once published;
|
|
11
|
+
// talk to once published; xgames.fun's play host opens that game's CSP to exactly those
|
|
12
12
|
// 5. its server code server/ (the gf-server-v1 contract, ../server/contract.mjs): bundled into one file and
|
|
13
13
|
// checked (it builds, no code from text, no Node built-in a Worker lacks, no secrets, its
|
|
14
14
|
// Durable Objects exported, its size); a problem refuses the publish. Sent only when
|
|
15
|
-
//
|
|
15
|
+
// xgames.fun says it takes server code (GET /api/kit/me → server.contracts has gf-server-v1);
|
|
16
16
|
// otherwise the files go up alone, and the person is told. server/ itself is never a
|
|
17
17
|
// public file.
|
|
18
18
|
// 6. the checks ./gate.mjs: a check on these files; a fail publishes a private draft only. A brought game:
|
|
19
19
|
// the page check (../check.mjs). A game the kit made: the kit's six gates, its `check`; one
|
|
20
20
|
// without it is refused here, never checked by less
|
|
21
|
-
// 7. upload only the files (and the server bundle)
|
|
21
|
+
// 7. upload only the files (and the server bundle) xgames.fun does not have yet
|
|
22
22
|
// 8. the version + link unlisted; an update is the next version at the same link
|
|
23
23
|
// A brought game (gamesfun.json → page, ./page.mjs; `xcore adopt`) is built first when it has a build, and its `out`
|
|
24
24
|
// folder is what goes up (with its server/ bundle, as for any game); the files publish adds live in its `static` folder.
|
|
25
25
|
// Signed in by `xcore login`, or in a GitHub Actions job by the job's own OIDC token (./oidc.mjs; actions/publish).
|
|
26
|
-
// The link is remembered in <game>/.gamesfun/publish.json (public facts only: which
|
|
26
|
+
// The link is remembered in <game>/.gamesfun/publish.json (public facts only: which xgames.fun, the game's id, its link),
|
|
27
27
|
// so the next publish of this folder updates the same game.
|
|
28
28
|
import { existsSync } from 'node:fs';
|
|
29
29
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
@@ -40,16 +40,17 @@ import {
|
|
|
40
40
|
} from './scan.mjs';
|
|
41
41
|
import { findNames, namesMessage } from './names.mjs';
|
|
42
42
|
import { gate } from './gate.mjs';
|
|
43
|
-
import { call, ownOrigins, studioOrigin } from './client.mjs';
|
|
43
|
+
import { call, ownOrigins, signInState, studioOrigin } from './client.mjs';
|
|
44
44
|
import { shown as shownDir } from '../games.mjs';
|
|
45
|
-
import { publishCredential } from './oidc.mjs';
|
|
45
|
+
import { publishCredential, requireCredential } from './oidc.mjs';
|
|
46
46
|
import { bundleServer } from '../server/bundle.mjs';
|
|
47
|
-
import { publishField, studioAccepts } from '../server/contract.mjs';
|
|
47
|
+
import { CONFIG_FILE, migrationsChanged, publishField, studioAccepts, studioFeatures } from '../server/contract.mjs';
|
|
48
48
|
import { economyFile, installPage, installServerCopy } from '../economy.mjs';
|
|
49
49
|
import { rpcWords, serverRpc, solanaRpc } from '../solana-rpc.mjs';
|
|
50
50
|
import { shadowedPath } from './hosting.mjs';
|
|
51
51
|
import { buildFailure, pageRoots, runBuild } from './page.mjs';
|
|
52
52
|
import { CMD } from '../command.mjs';
|
|
53
|
+
import { stateFolder } from '../state-folder.mjs';
|
|
53
54
|
|
|
54
55
|
export const linkFile = dir => path.join(dir, '.gamesfun', 'publish.json');
|
|
55
56
|
export async function readLinks(dir) {
|
|
@@ -62,13 +63,14 @@ export async function readLinks(dir) {
|
|
|
62
63
|
async function writeLink(dir, origin, record) {
|
|
63
64
|
const all = await readLinks(dir);
|
|
64
65
|
all[origin] = record;
|
|
66
|
+
await stateFolder(dir);
|
|
65
67
|
await mkdir(path.dirname(linkFile(dir)), { recursive: true });
|
|
66
68
|
await writeFile(linkFile(dir), JSON.stringify(all, null, 2) + '\n');
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
const mb = n => `${(n / 1048576).toFixed(1)} MB`;
|
|
70
72
|
const PACKAGE = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
71
|
-
/** Who checked a game, in the report
|
|
73
|
+
/** Who checked a game, in the report xgames.fun keeps with its version: this package, unless the kit says it was. */
|
|
72
74
|
export const LABEL = `${PACKAGE.name} ${PACKAGE.version}`;
|
|
73
75
|
/** A game the kit made (its record, gf-three.json): its check is the kit's six gates, never this package's page check. */
|
|
74
76
|
export const madeByKit = dir => existsSync(path.join(dir, 'gf-three.json'));
|
|
@@ -101,6 +103,41 @@ export class PublishRefusal extends Error {
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
/**
|
|
107
|
+
* xgames.fun's refusal of the game's database migrations (STORAGE.md §8.4, §8.10), as words the person can act on:
|
|
108
|
+
* 409 migration-changed, 409 migration-busy (another publish is applying them), 422 migration-failed (the SQL failed,
|
|
109
|
+
* or a file was left partly applied: its why says how to undo it), 502 database-unavailable (Cloudflare, not the SQL). Any other answer goes on as it came.
|
|
110
|
+
*/
|
|
111
|
+
async function migrationWords(pending) {
|
|
112
|
+
try {
|
|
113
|
+
return await pending;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const a = e.answer ?? {};
|
|
116
|
+
const name = typeof a.name === 'string' ? a.name : 'a migration';
|
|
117
|
+
if (e.code === 'migration-changed')
|
|
118
|
+
throw new PublishRefusal(
|
|
119
|
+
`Nothing was published: ${name} changed after xgames.fun applied it to the game's database. A migration is never changed once applied: put ${name} back as it was, and make the change in a new migration after the last one.`,
|
|
120
|
+
{ code: e.code, name, status: e.status },
|
|
121
|
+
);
|
|
122
|
+
if (e.code === 'database-unavailable')
|
|
123
|
+
throw new PublishRefusal(
|
|
124
|
+
"Nothing new went live: xgames.fun could not reach the game's database (Cloudflare did not answer, or was busy). Nothing is wrong with the migrations, and the version before keeps playing: publish again in a minute.",
|
|
125
|
+
{ code: e.code, status: e.status },
|
|
126
|
+
);
|
|
127
|
+
if (e.code === 'migration-busy')
|
|
128
|
+
throw new PublishRefusal(
|
|
129
|
+
'Nothing was published: another publish of this game is applying its database migrations on xgames.fun right now. Try again in a minute.',
|
|
130
|
+
{ code: e.code, status: e.status },
|
|
131
|
+
);
|
|
132
|
+
if (e.code === 'migration-failed')
|
|
133
|
+
throw new PublishRefusal(
|
|
134
|
+
`Nothing new went live: ${name} failed on the game's database on xgames.fun${a.why ? ` (${String(a.why).replace(/\.$/, '')})` : ''}. The version before keeps playing. Fix ${name} (try it under ${CMD} dev, which applies the same migrations) and publish again.`,
|
|
135
|
+
{ code: e.code, name, why: a.why ?? null, status: e.status },
|
|
136
|
+
);
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
104
141
|
/** Step 5, offline: the game's server code as it would go up. → null (no server/) or the bundle's facts. */
|
|
105
142
|
export async function serverCheck(dir) {
|
|
106
143
|
let b;
|
|
@@ -152,6 +189,14 @@ export function checkSummary(p) {
|
|
|
152
189
|
compatibilityFlags: s.compatibilityFlags ?? [],
|
|
153
190
|
files: (s.sources ?? []).map(f => f.path),
|
|
154
191
|
warnings: s.warnings ?? [],
|
|
192
|
+
stock: !!s.stock,
|
|
193
|
+
database: s.database?.has
|
|
194
|
+
? {
|
|
195
|
+
binding: s.database.binding,
|
|
196
|
+
migrations: s.database.migrations.map(m => m.path),
|
|
197
|
+
...(s.stock ? { collections: s.database.collections } : {}),
|
|
198
|
+
}
|
|
199
|
+
: null,
|
|
155
200
|
}
|
|
156
201
|
: null,
|
|
157
202
|
};
|
|
@@ -250,12 +295,9 @@ export async function publishGame(
|
|
|
250
295
|
{ code: 'kit' },
|
|
251
296
|
);
|
|
252
297
|
check ??= pageCheck;
|
|
253
|
-
const cred = await
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
`Not signed in to games.fun (${origin}). Sign in first: ${CMD} login (in GitHub Actions: permissions id-token: write; see the README's "Publish" section)`,
|
|
257
|
-
{ code: 'login' },
|
|
258
|
-
);
|
|
298
|
+
const cred = await requireCredential(origin, { env, fetchImpl }).catch(e => {
|
|
299
|
+
throw new PublishRefusal(e.message, { code: 'login', state: e.state, next: e.next });
|
|
300
|
+
});
|
|
259
301
|
if (cred.kind !== 'login') progress(`Signed in as ${cred.label} (GitHub's OIDC token; no key is stored anywhere).`);
|
|
260
302
|
// every call signs with the credential's current token (a GitHub token is asked for again before it runs out)
|
|
261
303
|
const api = async (route, opts = {}) => call(origin, route, { ...opts, ...(await cred.auth()), fetchImpl });
|
|
@@ -295,24 +337,54 @@ export async function publishGame(
|
|
|
295
337
|
});
|
|
296
338
|
if (pre.secrets) throw new PublishRefusal(pre.secrets, { code: 'secrets', hits: pre.hits });
|
|
297
339
|
if (pre.names) throw new PublishRefusal(pre.names, { code: 'names', hits: pre.named });
|
|
340
|
+
// the page's documents have rules (database.collections) and nobody to answer them: every gf.db call would 404
|
|
341
|
+
if (pre.server?.docsUnrouted)
|
|
342
|
+
throw new PublishRefusal(
|
|
343
|
+
`Nothing was published: ${CONFIG_FILE} → database.collections sets the rules of the page's documents (gf.db, at api/db/), and this game's own server does not route api/db/, so every gf.db call would answer 404. ${CMD} add database writes server/gf-docs.js; route it first thing in your server's fetch: const db = await handleDocs(request, env); if (db) return db; (import { handleDocs } from './gf-docs.js'). Or take "collections" out of ${CONFIG_FILE} if the page does not use gf.db.`,
|
|
344
|
+
{ code: 'db-route' },
|
|
345
|
+
);
|
|
298
346
|
const takes = pre.server?.has ? studioAccepts(me) : null;
|
|
347
|
+
const database = pre.server?.database?.has ? pre.server.database : null;
|
|
348
|
+
if (database) {
|
|
349
|
+
// a database needs xgames.fun to make one (STORAGE.md §8.1): the game is not published without it
|
|
350
|
+
if (!takes.ok)
|
|
351
|
+
throw new PublishRefusal(
|
|
352
|
+
`Nothing was published: this game has a database (${CONFIG_FILE} → database), which runs on the game's server, and ${takes.why}. Publish it again once it does.`,
|
|
353
|
+
{ code: 'feature', feature: 'server' },
|
|
354
|
+
);
|
|
355
|
+
if (!studioFeatures(me).has('d1'))
|
|
356
|
+
throw new PublishRefusal(
|
|
357
|
+
`Nothing was published: this game has a database (${CONFIG_FILE} → database), and this xgames.fun does not make databases for games yet (GET /api/kit/me does not list "d1"). Publish it again once it does, or take "database" out of ${CONFIG_FILE} to publish without one.`,
|
|
358
|
+
{ code: 'feature', feature: 'd1' },
|
|
359
|
+
);
|
|
360
|
+
// append only: what this folder put on this xgames.fun before still comes first, byte for byte
|
|
361
|
+
const before = newGame ? [] : ((await readLinks(dir))[origin]?.migrations ?? []);
|
|
362
|
+
const changed = migrationsChanged(database.migrations, before);
|
|
363
|
+
if (changed)
|
|
364
|
+
throw new PublishRefusal(`Nothing was published: ${changed.why}.`, { code: changed.code, name: changed.name });
|
|
365
|
+
}
|
|
366
|
+
if (takes?.ok && pre.server.usesFiles && !studioFeatures(me).has('files'))
|
|
367
|
+
throw new PublishRefusal(
|
|
368
|
+
`Nothing was published: this game's server uses its project files (env.GF.files), and this xgames.fun does not keep project files yet (GET /api/kit/me does not list "files"). Publish it again once it does.`,
|
|
369
|
+
{ code: 'feature', feature: 'files' },
|
|
370
|
+
);
|
|
299
371
|
if (takes && !takes.ok)
|
|
300
372
|
progress(
|
|
301
|
-
`This game has server code (server/), and ${takes.why}: only its files are published now, without its server. Its /api/ calls will not answer until
|
|
373
|
+
`This game has server code (server/), and ${takes.why}: only its files are published now, without its server. Its /api/ calls will not answer until xgames.fun takes server code; publish again then.`,
|
|
302
374
|
);
|
|
303
375
|
if (takes?.ok && pre.server.bytes > takes.maxBytes)
|
|
304
|
-
throw new PublishRefusal(`The server bundle is ${mb(pre.server.bytes)};
|
|
376
|
+
throw new PublishRefusal(`The server bundle is ${mb(pre.server.bytes)}; xgames.fun takes ${mb(takes.maxBytes)}.`, {
|
|
305
377
|
code: 'size',
|
|
306
378
|
});
|
|
307
379
|
for (const w of pre.server?.warnings ?? []) progress(`Server code: ${w}`);
|
|
308
380
|
// a page's Solana calls go to the game's own RPC when its server has one (xcore add rpc: server/rpc.js at api/rpc,
|
|
309
|
-
// the URL a secret of the server), else to /loot/v1/rpc on the play host,
|
|
381
|
+
// the URL a secret of the server), else to /loot/v1/rpc on the play host, xgames.fun's shared relay, which counts
|
|
310
382
|
// every call toward the game's plan
|
|
311
383
|
if (economyFile(roots.out) || (roots.overlay && economyFile(roots.overlay))) {
|
|
312
384
|
const ownRpc = pre.server?.has && existsSync(path.join(dir, 'server', 'rpc.js'));
|
|
313
385
|
if (ownRpc) {
|
|
314
386
|
progress(
|
|
315
|
-
`Its economy's Solana calls go to the game's own RPC (server/rpc.js, api/rpc) once its server has XSOLANA_RPC_URL: ${CMD} secret set XSOLANA_RPC_URL (once; ${CMD} secret list shows whether it is set). Until then they use
|
|
387
|
+
`Its economy's Solana calls go to the game's own RPC (server/rpc.js, api/rpc) once its server has XSOLANA_RPC_URL: ${CMD} secret set XSOLANA_RPC_URL (once; ${CMD} secret list shows whether it is set). Until then they use xgames.fun's shared relay.`,
|
|
316
388
|
);
|
|
317
389
|
// the server may call only backend.json's origins: the RPC's must be one
|
|
318
390
|
const local = serverRpc(dir);
|
|
@@ -327,7 +399,7 @@ export async function publishGame(
|
|
|
327
399
|
} else {
|
|
328
400
|
const rpc = solanaRpc(env, rpcFile ? { file: rpcFile } : undefined);
|
|
329
401
|
progress(
|
|
330
|
-
`Its economy's Solana calls go through
|
|
402
|
+
`Its economy's Solana calls go through xgames.fun's shared relay once published, and each counts toward the game's plan ($10 a million; on Free's $1 a month, ~140 player-hours of an economy that reads every 5 s). Bring the game's own devnet RPC: ${CMD} add rpc${rpc.shared ? '' : ` (${rpcWords(rpc)} is used on this machine only)`}.`,
|
|
331
403
|
);
|
|
332
404
|
}
|
|
333
405
|
}
|
|
@@ -394,7 +466,7 @@ export async function publishGame(
|
|
|
394
466
|
);
|
|
395
467
|
if (!report.pass && !draft)
|
|
396
468
|
progress(
|
|
397
|
-
`Its checks did not pass (${report.failed.map(c => `${c.n} ${c.name}`).join('; ') || 'the check did not run'}), so it goes up as a private draft: a version in your
|
|
469
|
+
`Its checks did not pass (${report.failed.map(c => `${c.n} ${c.name}`).join('; ') || 'the check did not run'}), so it goes up as a private draft: a version in your xgames.fun Studio, with no public link.`,
|
|
398
470
|
);
|
|
399
471
|
|
|
400
472
|
const links = await readLinks(dir),
|
|
@@ -408,21 +480,30 @@ export async function publishGame(
|
|
|
408
480
|
if (server) {
|
|
409
481
|
built.sources.set(server.sha256, { data: Buffer.from(server.code) });
|
|
410
482
|
for (const w of server.wasm ?? []) built.sources.set(w.sha256, { data: Buffer.from(w.bytes) }); // its wasm modules, the same way
|
|
483
|
+
for (const m of database?.migrations ?? []) built.sources.set(m.sha256, { data: m.data }); // and its migrations
|
|
411
484
|
progress(
|
|
412
|
-
|
|
485
|
+
server.stock
|
|
486
|
+
? `With xcore's stock server (${(server.bytes / 1024).toFixed(1)} KB), which serves the page's api/db/ from the game's database.`
|
|
487
|
+
: `With its server code: ${server.sources.length} file${server.sources.length === 1 ? '' : 's'} of server/ as one bundle (${(server.bytes / 1024).toFixed(1)} KB)${server.objects.length ? `, Durable Objects ${server.objects.map(o => o.class).join(', ')}` : ''}.`,
|
|
413
488
|
);
|
|
489
|
+
if (database)
|
|
490
|
+
progress(
|
|
491
|
+
`With its database (env.${database.binding}): ${database.migrations.length ? `${database.migrations.length} migration${database.migrations.length === 1 ? '' : 's'} (${database.migrations.map(m => m.name).join(', ')}); xgames.fun applies the new ones before the new version goes live` : 'no migrations'}.`,
|
|
492
|
+
);
|
|
414
493
|
}
|
|
415
|
-
const begun = await
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
494
|
+
const begun = await migrationWords(
|
|
495
|
+
api('/api/kit/publish', {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
body: {
|
|
498
|
+
title: name,
|
|
499
|
+
...(known?.projectId ? { projectId: known.projectId } : {}),
|
|
500
|
+
tree: built.tree,
|
|
501
|
+
verify: report,
|
|
502
|
+
draft: draft === true,
|
|
503
|
+
...(server ? { server: publishField(server) } : {}),
|
|
504
|
+
},
|
|
505
|
+
}),
|
|
506
|
+
);
|
|
426
507
|
|
|
427
508
|
// the game exists from here on: remember it, so publishing again (after a failure below) updates this game, never
|
|
428
509
|
// making another
|
|
@@ -464,19 +545,20 @@ export async function publishGame(
|
|
|
464
545
|
}
|
|
465
546
|
};
|
|
466
547
|
progress(
|
|
467
|
-
`Uploading ${begun.missing.length} new file${begun.missing.length === 1 ? '' : 's'} (${mb(begun.newBytes)}); ${alreadyThere}
|
|
548
|
+
`Uploading ${begun.missing.length} new file${begun.missing.length === 1 ? '' : 's'} (${mb(begun.newBytes)}); ${alreadyThere} xgames.fun already has.`,
|
|
468
549
|
);
|
|
469
550
|
await Promise.all(Array.from({ length: Math.min(4, begun.missing.length) }, worker));
|
|
470
551
|
|
|
471
552
|
let done;
|
|
472
553
|
try {
|
|
473
|
-
done = await api(`/api/kit/publish/${begun.id}`, { method: 'POST', body: { action: 'complete' } });
|
|
554
|
+
done = await migrationWords(api(`/api/kit/publish/${begun.id}`, { method: 'POST', body: { action: 'complete' } }));
|
|
474
555
|
} catch (e) {
|
|
475
|
-
|
|
556
|
+
if (e instanceof PublishRefusal) throw e;
|
|
557
|
+
// xgames.fun can finish the publish after it stops answering (a large game's last step outlasts the request)
|
|
476
558
|
if ([502, 503, 504].includes(e.status))
|
|
477
559
|
throw Object.assign(
|
|
478
560
|
new Error(
|
|
479
|
-
`
|
|
561
|
+
`xgames.fun did not answer in time (${e.status}) while it finished the publish, which may have gone through anyway. Run \`${CMD} publish ${shownDir(dir)} --status\` before publishing again; publishing again updates this same game, it does not make a new one.`,
|
|
480
562
|
),
|
|
481
563
|
{ status: e.status, code: 'timeout' },
|
|
482
564
|
);
|
|
@@ -507,6 +589,8 @@ export async function publishGame(
|
|
|
507
589
|
bytes: server.bytes,
|
|
508
590
|
sha256: server.sha256,
|
|
509
591
|
objects: server.objects.map(o => o.class),
|
|
592
|
+
...(server.stock ? { stock: true } : {}),
|
|
593
|
+
...(database ? { database: { binding: database.binding, migrations: database.migrations.length } } : {}),
|
|
510
594
|
...(done.server ? { games: done.server } : {}),
|
|
511
595
|
}
|
|
512
596
|
: { sent: false, why: takes?.why ?? 'not sent' }
|
|
@@ -521,16 +605,46 @@ export async function publishGame(
|
|
|
521
605
|
gameUrl: result.gameUrl ?? known?.gameUrl ?? null,
|
|
522
606
|
lastVersion: done.version?.number ?? null,
|
|
523
607
|
draft: done.draft,
|
|
608
|
+
// the migrations xgames.fun applied (a version that went up live has had them applied): the next publish
|
|
609
|
+
// checks they come first, unchanged
|
|
610
|
+
...(database && server
|
|
611
|
+
? done.draft
|
|
612
|
+
? known?.migrations
|
|
613
|
+
? { migrations: known.migrations }
|
|
614
|
+
: {}
|
|
615
|
+
: { migrations: database.migrations.map(m => ({ name: m.name, sha256: m.sha256 })) }
|
|
616
|
+
: known?.migrations
|
|
617
|
+
? { migrations: known.migrations }
|
|
618
|
+
: {}),
|
|
524
619
|
at: new Date().toISOString(),
|
|
525
620
|
});
|
|
526
621
|
return result;
|
|
527
622
|
}
|
|
528
623
|
|
|
529
|
-
/**
|
|
624
|
+
/**
|
|
625
|
+
* Where a folder's game stands on one xgames.fun, from its remembered link (.gamesfun/publish.json):
|
|
626
|
+
* never --publish begins--> started --a version goes up--> public | draft
|
|
627
|
+
* public --a version whose checks failed--> draft-over-public (the earlier version stays live) --publish--> public
|
|
628
|
+
* `started`: xgames.fun made the game but no version finished going up (an interrupted first publish); publishing
|
|
629
|
+
* again goes on with the same game.
|
|
630
|
+
*/
|
|
631
|
+
export function linkState(known) {
|
|
632
|
+
if (!known?.projectId) return 'never';
|
|
633
|
+
if (known.lastVersion == null) return 'started';
|
|
634
|
+
if (known.draft) return known.url ? 'draft-over-public' : 'draft';
|
|
635
|
+
return 'public';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The game's remembered link and what xgames.fun says about it now (`xcore publish --status`). */
|
|
530
639
|
export async function publishStatus(dir, { origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {}) {
|
|
531
640
|
const links = await readLinks(dir),
|
|
532
641
|
known = links[origin] ?? null;
|
|
533
|
-
|
|
642
|
+
let oidcError = null;
|
|
643
|
+
const cred = await publishCredential(origin, { env, fetchImpl }).catch(e => {
|
|
644
|
+
oidcError = e.message;
|
|
645
|
+
return null;
|
|
646
|
+
});
|
|
647
|
+
const { token: _token, words: _words, ...signIn } = await signInState(origin, { env });
|
|
534
648
|
let live = null;
|
|
535
649
|
if (known?.projectId && cred) {
|
|
536
650
|
try {
|
|
@@ -546,6 +660,14 @@ export async function publishStatus(dir, { origin = studioOrigin(), env = proces
|
|
|
546
660
|
? { email: cred.email, expiresAt: cred.expiresAt, store: cred.store }
|
|
547
661
|
: { email: null, via: cred.kind, as: cred.label }
|
|
548
662
|
: null,
|
|
663
|
+
// the saved sign-in's state ('signed-in' | 'signed-out' | 'expired' | 'unreadable') and what to run next;
|
|
664
|
+
// `oidcError`: a GitHub Actions job whose OIDC token could not be had
|
|
665
|
+
signIn: {
|
|
666
|
+
...signIn,
|
|
667
|
+
...(cred && cred.kind !== 'login' ? { via: cred.kind } : {}),
|
|
668
|
+
...(oidcError ? { oidcError } : {}),
|
|
669
|
+
},
|
|
670
|
+
link: linkState(known),
|
|
549
671
|
known,
|
|
550
672
|
live,
|
|
551
673
|
};
|
package/lib/publish/oidc.mjs
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
// A workflow with `permissions: id-token: write` gets two variables from GitHub: ACTIONS_ID_TOKEN_REQUEST_URL and
|
|
4
4
|
// ACTIONS_ID_TOKEN_REQUEST_TOKEN. With them the kit asks GitHub for a short-lived JWT (issuer
|
|
5
5
|
// token.actions.githubusercontent.com, audience https://xgames.fun, the repository and ref as claims) and sends it to
|
|
6
|
-
//
|
|
6
|
+
// xgames.fun's kit publish routes as `Authorization: Bearer <JWT>` with `x-gamesfun-credential: github-oidc`. xgames.fun
|
|
7
7
|
// checks the issuer, the audience and the repository + branch against the link its owner made once in Studio ("this
|
|
8
8
|
// repo and branch publish this game"), so nothing secret is stored in the repository or in GitHub.
|
|
9
9
|
//
|
|
10
|
-
// Used only when no `xcore login` is saved for that
|
|
10
|
+
// Used only when no `xcore login` is saved for that xgames.fun: a person's own sign-in always wins on their machine.
|
|
11
11
|
// The token lasts a few minutes, so it is asked for again before each call once it is four minutes old (a publish
|
|
12
|
-
// with verify runs longer than one token lives). GAMESFUN_OIDC_AUDIENCE changes the audience (
|
|
13
|
-
import {
|
|
12
|
+
// with verify runs longer than one token lives). GAMESFUN_OIDC_AUDIENCE changes the audience (an xgames.fun of your own).
|
|
13
|
+
import { signInState } from './client.mjs';
|
|
14
|
+
import { CMD } from '../command.mjs';
|
|
14
15
|
|
|
15
16
|
export const OIDC_AUDIENCE = 'https://xgames.fun';
|
|
16
17
|
export const OIDC_HEADER = 'x-gamesfun-credential';
|
|
@@ -71,7 +72,10 @@ export function claimsOf(jwt) {
|
|
|
71
72
|
* { kind: 'login' | 'github-oidc', label, email, auth(): Promise<{ token, headers }> }.
|
|
72
73
|
*/
|
|
73
74
|
export async function publishCredential(origin, { env = process.env, fetchImpl = fetch } = {}) {
|
|
74
|
-
|
|
75
|
+
// only a sign-in that is still good wins: an expired or unreadable one gives way to the job's OIDC token
|
|
76
|
+
const st = await signInState(origin, { env });
|
|
77
|
+
const login =
|
|
78
|
+
st.state === 'signed-in' ? { token: st.token, email: st.email, expiresAt: st.expiresAt, store: st.store } : null;
|
|
75
79
|
if (login)
|
|
76
80
|
return {
|
|
77
81
|
kind: 'login',
|
|
@@ -106,3 +110,20 @@ export async function publishCredential(origin, { env = process.env, fetchImpl =
|
|
|
106
110
|
auth,
|
|
107
111
|
};
|
|
108
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The credential, or an Error saying why there is none, in the sign-in state's own words ("ended on …", "cannot be
|
|
116
|
+
* read in this session"), with the command that fixes it. `.code` is 'login', `.state` the sign-in state.
|
|
117
|
+
*/
|
|
118
|
+
export async function requireCredential(origin, { env = process.env, fetchImpl = fetch } = {}) {
|
|
119
|
+
const cred = await publishCredential(origin, { env, fetchImpl });
|
|
120
|
+
if (cred) return cred;
|
|
121
|
+
const st = await signInState(origin, { env });
|
|
122
|
+
const state = st.state === 'signed-in' ? 'signed-out' : st.state;
|
|
123
|
+
throw Object.assign(
|
|
124
|
+
new Error(
|
|
125
|
+
`${st.state === 'signed-out' ? `Not signed in to xgames.fun (${origin}).` : st.words} Run: ${CMD} login (in GitHub Actions: permissions id-token: write; see the README's "Publish" section)`,
|
|
126
|
+
),
|
|
127
|
+
{ code: 'login', state, next: `${CMD} login` },
|
|
128
|
+
);
|
|
129
|
+
}
|
package/lib/publish/page.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// output; index.html at its root is the game's entry
|
|
11
11
|
// static the folder the build copies into `out` as it is (Vite, Next.js, Astro: public/), where the kit puts the
|
|
12
12
|
// files it adds (backend.json, xsolana.json, gf/loot/); "." for plain files; omitted when the build has none
|
|
13
|
-
// title optional: the game's name on
|
|
13
|
+
// title optional: the game's name on xgames.fun (else index.html's <title>, else package.json's name)
|
|
14
14
|
//
|
|
15
15
|
// Only these four keys; every path is relative, with forward slashes, and stays inside the game's folder.
|
|
16
16
|
import { execFile, spawn } from 'node:child_process';
|
package/lib/publish/plans.mjs
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
// `xcore plan`'s calls to
|
|
1
|
+
// `xcore plan`'s calls to xgames.fun. A plan is a game's backend tier: Free by default (strict), or
|
|
2
2
|
// Creator when its owner puts it in one of their paid game slots (priced per game slot a month; a slot moves between
|
|
3
|
-
// games at any time without changing the bill), or Scale, which
|
|
4
|
-
//
|
|
3
|
+
// games at any time without changing the bill), or Scale, which xgames.fun's operator grants.
|
|
4
|
+
// Every game sits in a slot: 2 free ones for everyone (their games on Free), any more are paid (their games on Creator).
|
|
5
|
+
// GET /api/kit/plan → {ok, slots, used, billing, capCents, monthCents, includedCents, games,
|
|
6
|
+
// gameSlots?: {free: {slots, used}, paid: {slots, used}, granted, open}}
|
|
7
|
+
// (slots/used: the paid slots; gameSlots: from xgames.fun 2026-11-08)
|
|
5
8
|
// GET /api/kit/games/<gameId>/plan → {ok, game, tier, capped, comp, limits, monthCents, includedCents, slots, used}
|
|
6
9
|
// POST /api/kit/games/<gameId>/plan {"active": bool} → the same, and changed
|
|
7
10
|
// The game is the folder's own (its .gamesfun/publish.json for this Studio); the credential is publish's.
|
|
8
11
|
import { readLinks } from './index.mjs';
|
|
9
12
|
import { call, studioOrigin } from './client.mjs';
|
|
10
|
-
import {
|
|
13
|
+
import { requireCredential } from './oidc.mjs';
|
|
11
14
|
import { CMD } from '../command.mjs';
|
|
12
15
|
|
|
13
16
|
async function signedIn(origin, { env, fetchImpl }) {
|
|
14
|
-
|
|
15
|
-
if (!cred) throw new Error(`not signed in to ${origin}: run ${CMD} login first`);
|
|
16
|
-
return cred.auth();
|
|
17
|
+
return (await requireCredential(origin, { env, fetchImpl })).auth();
|
|
17
18
|
}
|
|
18
19
|
async function gameId(dir, origin) {
|
|
19
20
|
const known = (await readLinks(dir))[origin];
|
|
@@ -53,7 +54,7 @@ export function describeGame(r) {
|
|
|
53
54
|
const lines = [];
|
|
54
55
|
const name = r.game?.title || r.game?.slug || 'This game';
|
|
55
56
|
lines.push(
|
|
56
|
-
`${name}: ${tierName(r.tier)}${r.comp ? ' (granted by
|
|
57
|
+
`${name}: ${tierName(r.tier)}${r.comp ? ' (granted by xgames.fun)' : ''}${r.capped ? ', capped for the rest of the month' : ''}`,
|
|
57
58
|
);
|
|
58
59
|
const included = r.includedCents == null ? '' : ` of ${dollars(r.includedCents)} included`;
|
|
59
60
|
lines.push(` this month: ${dollars(r.monthCents)}${included}`);
|
|
@@ -71,7 +72,7 @@ export function describeGame(r) {
|
|
|
71
72
|
lines.push(
|
|
72
73
|
' capped: its server answers slowly (60 requests a minute) until the month ends, or until it gets more room',
|
|
73
74
|
);
|
|
74
|
-
if (r.slots !== undefined) lines.push(`
|
|
75
|
+
if (r.slots !== undefined) lines.push(` paid slots: ${r.used ?? 0} of ${r.slots} in use`);
|
|
75
76
|
return lines.join('\n');
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -80,12 +81,18 @@ export function describeAccount(r) {
|
|
|
80
81
|
const open = r.payments?.on || r.billing;
|
|
81
82
|
const until = r.periodEnd ? `, paid through ${new Date(r.periodEnd).toISOString().slice(0, 10)}` : '';
|
|
82
83
|
const how = r.payments?.on
|
|
83
|
-
? ` (buy or renew on
|
|
84
|
+
? ` (buy or renew on xgames.fun: your console → Plans, in ${(r.payments.currencies ?? ['SOL', 'USDC']).join(' or ')}${r.payments.cluster === 'devnet' ? ', devnet test payments' : ''})`
|
|
84
85
|
: open
|
|
85
86
|
? ''
|
|
86
|
-
: ' (buying slots is not open on this
|
|
87
|
+
: ' (buying slots is not open on this xgames.fun yet)';
|
|
88
|
+
const s = r.gameSlots;
|
|
87
89
|
const lines = [
|
|
88
|
-
|
|
90
|
+
...(s
|
|
91
|
+
? [
|
|
92
|
+
`Free slots: ${s.free.used} of ${s.free.slots} in use (their games run on Free)${s.open === 0 ? '; a new game needs one: archive a game or move one to a paid slot' : ''}`,
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
`${s ? 'Paid slots' : 'Game slots'}: ${r.used ?? 0} of ${r.slots ?? 0} in use${s ? ' (their games run on Creator)' : ''}${until}${how}`,
|
|
89
96
|
`This month: ${dollars(r.monthCents)}${r.includedCents == null ? '' : ` of ${dollars(r.includedCents)} included`}${r.creditCents ? `; usage credit ${dollars(r.creditCents)}` : ''}${r.capCents ? `; spending cap ${dollars(r.capCents)}` : ''}`,
|
|
90
97
|
];
|
|
91
98
|
for (const g of r.games ?? [])
|