@joystack/cli 0.1.43 → 0.1.45
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/CHANGELOG.md +31 -0
- package/lib/create.mjs +2 -0
- package/lib/inject.mjs +1 -1
- package/lib/legacy-names.mjs +35 -0
- package/lib/publish-checks.mjs +75 -0
- package/lib/publish-upload.mjs +163 -0
- package/lib/publish.mjs +32 -14
- package/lib/update.mjs +19 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,34 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.45] - 2026-10-09
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `joystack publish` uploads only the files Joystack does not already have,
|
|
22
|
+
one file per request and several at once, then puts the game live. A large game publishes reliably
|
|
23
|
+
instead of failing with HTTP 503; a copy under a new name (a review link, a
|
|
24
|
+
lane's copy) sends only what changed; a publish cut off halfway picks up
|
|
25
|
+
where it stopped; and a passing failure (a dropped connection, the platform
|
|
26
|
+
busy, a rate limit) is retried instead of failing the publish.
|
|
27
|
+
- `joystack publish` checks your game's code against its engine before
|
|
28
|
+
uploading anything: a `/shared/…` path or a `joystack…` import your engine
|
|
29
|
+
does not have stops the publish with the same message as before, without
|
|
30
|
+
waiting for the upload.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- A publish that fails no longer leaves its name claimed, or a multiplayer
|
|
35
|
+
game's server running with no page in front of it.
|
|
36
|
+
|
|
37
|
+
## [0.1.44] - 2026-10-09
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- When `joystack update` moves a game made before the rename, it ends with a
|
|
42
|
+
short checklist for your agent: the links, tests and publishing tokens in
|
|
43
|
+
your game's own files that the update cannot move itself.
|
|
44
|
+
|
|
17
45
|
### Removed
|
|
18
46
|
|
|
19
47
|
- `joystack dev` no longer starts a services proxy on port 8788. Nothing in the
|
|
@@ -22,6 +50,9 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
22
50
|
|
|
23
51
|
### Changed
|
|
24
52
|
|
|
53
|
+
- `joystack create` marks the new game as ES modules (`"type": "module"` in
|
|
54
|
+
package.json), so `joystack publish` no longer prints Node's
|
|
55
|
+
`MODULE_TYPELESS_PACKAGE_JSON` warning.
|
|
25
56
|
- **The CLI is `@joystack/cli`, and its command is `joystack`** (`npx joystack
|
|
26
57
|
dev`, `npx joystack test`, `npx joystack publish`, …). Nothing in your game has
|
|
27
58
|
to change: the previous command name runs the same CLI, so the scripts in your
|
package/lib/create.mjs
CHANGED
|
@@ -112,6 +112,8 @@ async function scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, rec
|
|
|
112
112
|
{
|
|
113
113
|
name,
|
|
114
114
|
private: true,
|
|
115
|
+
// Every file a game is made of is an ES module.
|
|
116
|
+
type: 'module',
|
|
115
117
|
description: `A Joystack game. Play at https://${name}.joystack.games/`,
|
|
116
118
|
// The scaffold is a framework-v2 game (entities + traits + a world root,
|
|
117
119
|
// lowered to the engine runtime on this machine). This flag is what makes
|
package/lib/inject.mjs
CHANGED
|
@@ -34,7 +34,7 @@ function jsJson(value) {
|
|
|
34
34
|
// resolve them to the framework runtime under the (already-mounted) /shared/
|
|
35
35
|
// tree. This set is the import surface (shared/docs/api.md); keep it in sync
|
|
36
36
|
// with V2_BARE_SPECIFIERS in backend/api/src/lib/inject.js.
|
|
37
|
-
const V2_IMPORTS = {
|
|
37
|
+
export const V2_IMPORTS = {
|
|
38
38
|
joystack: '/shared/framework/runtime/joystack.js',
|
|
39
39
|
'joystack/traits': '/shared/framework/runtime/traits.js',
|
|
40
40
|
'joystack/client': '/shared/framework/runtime/client.js',
|
package/lib/legacy-names.mjs
CHANGED
|
@@ -93,6 +93,12 @@ export function canonicalSpecifier(specifier) {
|
|
|
93
93
|
return specifier;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The engine file the root door resolves to in an engine released before the
|
|
98
|
+
* rename, which has no `shared/framework/runtime/joystack.js`.
|
|
99
|
+
*/
|
|
100
|
+
export const LEGACY_ROOT_DOOR_FILE = `shared/framework/runtime/${OLD}.js`;
|
|
101
|
+
|
|
96
102
|
/** A browser import map extended with the old name of every joystack door it maps. */
|
|
97
103
|
export function withLegacyDoors(imports) {
|
|
98
104
|
const out = { ...imports };
|
|
@@ -244,3 +250,32 @@ export const LEGACY_MANAGED_MARKERS = Object.freeze({
|
|
|
244
250
|
start: `<!-- ${OLD}:managed:start -->`,
|
|
245
251
|
end: `<!-- ${OLD}:managed:end -->`,
|
|
246
252
|
});
|
|
253
|
+
|
|
254
|
+
// Whether this update moved a game made under the old name: the CLI dependency
|
|
255
|
+
// was switched over, or the old update skill was retired. `cli` and `surface`
|
|
256
|
+
// are what update.mjs got from syncCli and the agent-surface reconcile.
|
|
257
|
+
export function movedFromLegacy(cli, surface) {
|
|
258
|
+
return Boolean(cli?.switched) || (surface?.removed ?? []).some((f) => /(^|\/)skills\/update-looop(\/|$)/.test(f));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// What `joystack update` prints after such a move, once. It is addressed to
|
|
262
|
+
// the creator's agent, which reads the update's output: the update moved what
|
|
263
|
+
// is Joystack's, and these are what the game itself may still say about Looop.
|
|
264
|
+
export const MOVED_FROM_LEGACY_NOTE = [
|
|
265
|
+
'This game just moved from Looop to Joystack. The update moved what is',
|
|
266
|
+
"Joystack's; check the game's own files for what it could not move, and",
|
|
267
|
+
'fix what you find before calling the update done:',
|
|
268
|
+
' • Links to play.looop.games/g/<slug>: the game lives at',
|
|
269
|
+
' https://<slug>.joystack.games/ from its first `npx joystack publish`.',
|
|
270
|
+
' The Looop copy stays as it was.',
|
|
271
|
+
' • Tests or scripts matching `[looop]`, `looop:` keys or `.looop/`: the',
|
|
272
|
+
' engine now writes `[joystack]`, `joystack:` and `.joystack/`.',
|
|
273
|
+
' • Automation that publishes (GitHub Actions, scripts) with a Looop token:',
|
|
274
|
+
' Joystack does not accept it. Make one for the job by signing in with an',
|
|
275
|
+
' empty folder (mktemp -d) as the home: `JOYSTACK_HOME=<folder> npx joystack login`.',
|
|
276
|
+
' With the creator\'s OK, store it without ever displaying it:',
|
|
277
|
+
' node -e "process.stdout.write(require(\'<folder>/config.json\').token)" | gh secret set <NAME>',
|
|
278
|
+
' Without gh, pipe the same into pbcopy (Mac) and walk them through',
|
|
279
|
+
' GitHub → Settings → Secrets and variables → Actions → <NAME> → Update.',
|
|
280
|
+
' Then delete the folder.',
|
|
281
|
+
];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The checks of a game's code against its engine, run before a publish uploads
|
|
2
|
+
// anything: every `/shared/…` path the game's code names, and every door it
|
|
3
|
+
// imports (`joystack`, `joystack/client`, `three`, …), must exist in the
|
|
4
|
+
// engine the game is pinned to. Without them the published page fails to load
|
|
5
|
+
// a module.
|
|
6
|
+
//
|
|
7
|
+
// The publish server runs the same checks on the single-request route
|
|
8
|
+
// (backend/api/src/routes/publish.js, codeError). The file-by-file route does
|
|
9
|
+
// not read the game's files back, so the CLI runs them here, with the same
|
|
10
|
+
// rules and the same messages.
|
|
11
|
+
|
|
12
|
+
import { readdirSync } from 'node:fs';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
import { V2_IMPORTS } from './inject.mjs';
|
|
15
|
+
import { LEGACY_ROOT_DOOR_FILE, withLegacyDoors } from './legacy-names.mjs';
|
|
16
|
+
|
|
17
|
+
const SHARED_REF_RE = /["'(=]\s*\/(shared\/[A-Za-z0-9_@./-]+)/g;
|
|
18
|
+
const CODE_FILE_RE = /\.(js|mjs|html|css)$/i;
|
|
19
|
+
const ROOT_DOOR = 'joystack';
|
|
20
|
+
|
|
21
|
+
function decodeText(bytes) {
|
|
22
|
+
try {
|
|
23
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Throws when the game's code names an engine file or door its engine does not
|
|
31
|
+
* ship. `files` maps each path to its bytes; `engine` is the installed engine
|
|
32
|
+
* ({ version, sharedDir }).
|
|
33
|
+
*/
|
|
34
|
+
export function checkGameCode(files, engine) {
|
|
35
|
+
// The engine's files by exact path, as the platform's release lists them: a
|
|
36
|
+
// folder, a `..` path or a different letter case is not a file it serves.
|
|
37
|
+
let entries = [];
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(engine.sharedDir, { recursive: true, withFileTypes: true });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code !== 'ENOENT') throw err;
|
|
42
|
+
}
|
|
43
|
+
const shipped = new Set(
|
|
44
|
+
entries.filter((e) => e.isFile()).map((e) => `shared/${relative(engine.sharedDir, join(e.parentPath ?? e.path, e.name)).split('\\').join('/')}`),
|
|
45
|
+
);
|
|
46
|
+
const ships = (path) => shipped.has(path);
|
|
47
|
+
const texts = new Map();
|
|
48
|
+
for (const [path, bytes] of files) texts.set(path, decodeText(bytes));
|
|
49
|
+
|
|
50
|
+
for (const [path, text] of texts) {
|
|
51
|
+
if (text == null || !CODE_FILE_RE.test(path)) continue;
|
|
52
|
+
for (const m of text.matchAll(SHARED_REF_RE)) {
|
|
53
|
+
if (!ships(m[1])) throw new Error(`game references /${m[1]}, which engine ${engine.version} does not ship`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Whether each door's engine file ships, under the door's current name and
|
|
58
|
+
// its old one. An engine from before the rename has the root door under its
|
|
59
|
+
// old file name.
|
|
60
|
+
const available = {};
|
|
61
|
+
for (const [door, url] of Object.entries(V2_IMPORTS)) {
|
|
62
|
+
available[door] = ships(url.replace(/^\//, '')) || (door === ROOT_DOOR && ships(LEGACY_ROOT_DOOR_FILE));
|
|
63
|
+
}
|
|
64
|
+
const doors = withLegacyDoors(available);
|
|
65
|
+
const used = new Set([ROOT_DOOR, `${ROOT_DOOR}/client`]);
|
|
66
|
+
for (const text of texts.values()) {
|
|
67
|
+
if (!text) continue;
|
|
68
|
+
for (const door of Object.keys(doors)) {
|
|
69
|
+
if (text.includes(`'${door}'`) || text.includes(`"${door}"`)) used.add(door);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const door of used) {
|
|
73
|
+
if (!doors[door]) throw new Error(`engine ${engine.version} does not ship the \`${door}\` import`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The file-by-file publish: ask which files the platform lacks, upload those
|
|
2
|
+
// one per request, then ask it to put the game live.
|
|
3
|
+
//
|
|
4
|
+
// POST /api/creator/publish/missing fingerprints, CHECK_BATCH at a time
|
|
5
|
+
// PUT /api/creator/publish/file/<sha256> one file each, UPLOAD_CONCURRENCY at once
|
|
6
|
+
// POST /api/creator/publish/finish the publish's meta
|
|
7
|
+
//
|
|
8
|
+
// The platform stores files by fingerprint and shares them across every game,
|
|
9
|
+
// so a publish to a new name (a review copy, a lane's copy) uploads only what no
|
|
10
|
+
// earlier publish stored, and a publish cut off halfway resumes where it
|
|
11
|
+
// stopped. Each request is small, so none runs into the platform's
|
|
12
|
+
// per-request limits. A request refused for a passing reason (a rate limit, a
|
|
13
|
+
// network error, the platform busy or restarting) is retried on its own with a
|
|
14
|
+
// growing wait; anything else fails the publish with the platform's message.
|
|
15
|
+
|
|
16
|
+
// Fingerprints asked about per request. The platform answers up to 250
|
|
17
|
+
// (MAX_CHECK_BATCH in backend/api/src/routes/publish.js), but each request
|
|
18
|
+
// looks them up in storage only six at a time, each lookup a round trip of
|
|
19
|
+
// hundreds of milliseconds from far away; small batches in parallel requests
|
|
20
|
+
// answer a large game in a few seconds.
|
|
21
|
+
export const CHECK_BATCH = 25;
|
|
22
|
+
// Each upload request spends most of its time waiting on round trips between
|
|
23
|
+
// the platform and its database and storage, about 1.4 s from far away, so
|
|
24
|
+
// many overlap. Measured from Mauritius on 120 small files: 29 s at 6 at once,
|
|
25
|
+
// 8 s at 24, 5 s at 48.
|
|
26
|
+
export const UPLOAD_CONCURRENCY = 32;
|
|
27
|
+
// Batches of the "which are missing" question in flight at once.
|
|
28
|
+
export const CHECK_CONCURRENCY = 32;
|
|
29
|
+
const ATTEMPTS = 6;
|
|
30
|
+
const UPLOAD_TIMEOUT_MS = 5 * 60_000;
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 2 * 60_000;
|
|
32
|
+
// The platform answers 503 for a failure that may pass (storage busy, a
|
|
33
|
+
// publish that failed and changed nothing); Cloudflare's edge answers 503 when
|
|
34
|
+
// a request ran out of CPU, and 52x when it could not reach the platform. A 500
|
|
35
|
+
// is retried only when it is the platform's unexpected-error answer: its other
|
|
36
|
+
// 500s name a fault in its own setup, which a retry does not fix. A 502 means
|
|
37
|
+
// a game's server failed to deploy.
|
|
38
|
+
const RETRYABLE = new Set([408, 429, 503, 504, 520, 521, 522, 523, 524]);
|
|
39
|
+
const UNEXPECTED_ERROR = '{"error":"internal error"}';
|
|
40
|
+
|
|
41
|
+
export class LegacyPlatform extends Error {}
|
|
42
|
+
|
|
43
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Uploads what the platform lacks and finishes the publish. `files` maps each
|
|
47
|
+
* path to its bytes, `meta.files` each path to its SHA-256. Returns the
|
|
48
|
+
* platform's answer ({ slug, title, … }) with `uploaded` and `alreadyStored`
|
|
49
|
+
* counts. Throws LegacyPlatform when the platform predates these routes.
|
|
50
|
+
*/
|
|
51
|
+
export async function publishFileByFile({ apiBase, token, files, meta, log = console.log, fetchImpl = fetch, sleep = defaultSleep }) {
|
|
52
|
+
const auth = token ? { Authorization: `Bearer ${token}` } : {};
|
|
53
|
+
|
|
54
|
+
// One request, retried while the reason passes. Returns the response of the
|
|
55
|
+
// first attempt that is not retryable.
|
|
56
|
+
const request = async (what, url, init, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
|
57
|
+
for (let attempt = 1; ; attempt++) {
|
|
58
|
+
let res = null;
|
|
59
|
+
let error = null;
|
|
60
|
+
try {
|
|
61
|
+
res = await fetchImpl(url, { ...init, headers: { ...auth, ...init.headers }, signal: AbortSignal.timeout(timeoutMs) });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
error = err;
|
|
64
|
+
}
|
|
65
|
+
if (res?.status === 500) {
|
|
66
|
+
const body = await res.text();
|
|
67
|
+
if (body.trim() !== UNEXPECTED_ERROR) return new Response(body, { status: 500 });
|
|
68
|
+
res = new Response(body, { status: 500 });
|
|
69
|
+
} else if (res && !RETRYABLE.has(res.status)) return res;
|
|
70
|
+
if (attempt === ATTEMPTS) {
|
|
71
|
+
if (res) return res;
|
|
72
|
+
throw new Error(`${what} failed after ${ATTEMPTS} attempts: ${error?.cause?.message ?? error?.message ?? error}`);
|
|
73
|
+
}
|
|
74
|
+
const retryAfter = Number(res?.headers.get('retry-after'));
|
|
75
|
+
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** (attempt - 1);
|
|
76
|
+
if (res?.status === 429) log(` The platform asked to slow down; continuing in ${Math.round(wait / 1000)} s…`);
|
|
77
|
+
await res?.body?.cancel().catch(() => {});
|
|
78
|
+
await sleep(wait);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const failed = async (what, res) => {
|
|
82
|
+
const detail = await res.text().catch(() => '');
|
|
83
|
+
return new Error(`${what} failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const byHash = new Map();
|
|
87
|
+
for (const [path, bytes] of files) byHash.set(meta.files[path], bytes);
|
|
88
|
+
const hashes = [...byHash.keys()];
|
|
89
|
+
|
|
90
|
+
const batches = [];
|
|
91
|
+
for (let i = 0; i < hashes.length; i += CHECK_BATCH) batches.push(hashes.slice(i, i + CHECK_BATCH));
|
|
92
|
+
const missing = [];
|
|
93
|
+
const ask = async (batch) => {
|
|
94
|
+
const res = await request('checking which files the platform has', `${apiBase}/api/creator/publish/missing`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ files: batch }),
|
|
98
|
+
});
|
|
99
|
+
if (res.status === 404) throw new LegacyPlatform('the platform has no file-by-file publish');
|
|
100
|
+
if (!res.ok) throw await failed('publish', res);
|
|
101
|
+
missing.push(...(await res.json()).missing);
|
|
102
|
+
};
|
|
103
|
+
// The first batch alone, so a platform without these routes is found before
|
|
104
|
+
// anything else is sent.
|
|
105
|
+
await ask(batches[0]);
|
|
106
|
+
const rest = batches.slice(1);
|
|
107
|
+
await Promise.all(Array.from({ length: Math.min(CHECK_CONCURRENCY, rest.length) }, async () => {
|
|
108
|
+
for (let batch = rest.shift(); batch; batch = rest.shift()) await ask(batch);
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const bytesOf = (hash) => byHash.get(hash).byteLength;
|
|
112
|
+
const total = missing.reduce((n, h) => n + bytesOf(h), 0);
|
|
113
|
+
const mb = (n) => (n / 1024 / 1024).toFixed(1);
|
|
114
|
+
log(
|
|
115
|
+
missing.length
|
|
116
|
+
? `Uploading ${missing.length} of ${hashes.length} files (${mb(total)} MB); the platform already has the rest.`
|
|
117
|
+
: `The platform already has all ${hashes.length} files; nothing to upload.`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
let done = 0;
|
|
121
|
+
let sent = 0;
|
|
122
|
+
let nextReport = 0.25;
|
|
123
|
+
const queue = [...missing];
|
|
124
|
+
let stopped = false;
|
|
125
|
+
const worker = async () => {
|
|
126
|
+
for (let hash = queue.shift(); hash && !stopped; hash = queue.shift()) {
|
|
127
|
+
let res;
|
|
128
|
+
try {
|
|
129
|
+
res = await request(
|
|
130
|
+
'uploading a file',
|
|
131
|
+
`${apiBase}/api/creator/publish/file/${hash}`,
|
|
132
|
+
{ method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: byHash.get(hash) },
|
|
133
|
+
UPLOAD_TIMEOUT_MS,
|
|
134
|
+
);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
stopped = true;
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
stopped = true;
|
|
141
|
+
const path = Object.keys(meta.files).find((p) => meta.files[p] === hash);
|
|
142
|
+
throw await failed(`uploading ${path}`, res);
|
|
143
|
+
}
|
|
144
|
+
await res.body?.cancel().catch(() => {});
|
|
145
|
+
done++;
|
|
146
|
+
sent += bytesOf(hash);
|
|
147
|
+
// Progress on a long upload, at each quarter.
|
|
148
|
+
if (missing.length >= 40 && done / missing.length >= nextReport && done < missing.length) {
|
|
149
|
+
log(` ${done} of ${missing.length} files uploaded (${mb(sent)} of ${mb(total)} MB)…`);
|
|
150
|
+
while (done / missing.length >= nextReport) nextReport += 0.25;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, missing.length) }, worker));
|
|
155
|
+
|
|
156
|
+
const res = await request('publish', `${apiBase}/api/creator/publish/finish`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'content-type': 'application/json' },
|
|
159
|
+
body: JSON.stringify(meta),
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) throw await failed('publish', res);
|
|
162
|
+
return { ...(await res.json()), uploaded: missing.length, alreadyStored: hashes.length - missing.length };
|
|
163
|
+
}
|
package/lib/publish.mjs
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// release registry. The client never stages the shared library itself — that
|
|
6
6
|
// trust hole is what this design replaces.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// one
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The upload is file by file (publish-upload.mjs): the platform says which
|
|
9
|
+
// files it lacks, those go up one per request, and a last request puts the
|
|
10
|
+
// game live. A platform without those routes gets the single request
|
|
11
|
+
// /api/creator/publish with every file. Both need the creator token; without
|
|
12
|
+
// one the platform answers 401 with a login hint.
|
|
12
13
|
import { createHash } from 'node:crypto';
|
|
13
14
|
import { findProject } from './project.mjs';
|
|
14
15
|
import { isLane, laneParentSlug } from './lane.mjs';
|
|
@@ -32,6 +33,8 @@ export const playUrlFor = (slug) => `https://${slug}.joystack.games/`;
|
|
|
32
33
|
export { collectGameFiles, gitIgnored } from './game-files.mjs';
|
|
33
34
|
import { collectGameFiles } from './game-files.mjs';
|
|
34
35
|
import { gameBuild } from './build-id.mjs';
|
|
36
|
+
import { checkGameCode } from './publish-checks.mjs';
|
|
37
|
+
import { LegacyPlatform, publishFileByFile } from './publish-upload.mjs';
|
|
35
38
|
|
|
36
39
|
export async function publish({
|
|
37
40
|
cwd = process.cwd(),
|
|
@@ -154,15 +157,33 @@ export async function publish({
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}) };
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
for (const [path, bytes] of files) meta.files[path] = createHash('sha256').update(bytes).digest('hex');
|
|
161
|
+
|
|
162
|
+
// The platform only publishes framework-v2 games, and checks their code
|
|
163
|
+
// against the engine only on the single-request route; these are the same
|
|
164
|
+
// checks, before anything is uploaded.
|
|
165
|
+
if (isFrameworkV2(project.dir)) checkGameCode(files, engine);
|
|
163
166
|
|
|
164
167
|
log(`Publishing ${targetSlug} (${files.size} files, engine ${engine.version}, build ${build.id}) → ${apiBase}…`);
|
|
165
168
|
const token = getToken();
|
|
169
|
+
let out;
|
|
170
|
+
try {
|
|
171
|
+
out = await publishFileByFile({ apiBase, token, files, meta, log });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (!(err instanceof LegacyPlatform)) throw err;
|
|
174
|
+
out = await publishInOneRequest({ apiBase, token, files, meta });
|
|
175
|
+
}
|
|
176
|
+
const url = playUrlFor(out.slug);
|
|
177
|
+
log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
|
|
178
|
+
return { slug: out.slug, url };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The single-request publish, for a platform without the file-by-file routes:
|
|
182
|
+
// every file in one multipart request.
|
|
183
|
+
async function publishInOneRequest({ apiBase, token, files, meta }) {
|
|
184
|
+
const form = new FormData();
|
|
185
|
+
for (const [path, bytes] of files) form.append(path, new Blob([bytes]), path);
|
|
186
|
+
form.append('meta', JSON.stringify(meta));
|
|
166
187
|
const res = await fetch(`${apiBase}/api/creator/publish`, {
|
|
167
188
|
method: 'POST',
|
|
168
189
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
@@ -172,8 +193,5 @@ export async function publish({
|
|
|
172
193
|
const detail = await res.text().catch(() => '');
|
|
173
194
|
throw new Error(`publish failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
174
195
|
}
|
|
175
|
-
|
|
176
|
-
const url = playUrlFor(out.slug);
|
|
177
|
-
log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
|
|
178
|
-
return { slug: out.slug, url };
|
|
196
|
+
return res.json();
|
|
179
197
|
}
|
package/lib/update.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { login } from './login.mjs';
|
|
|
16
16
|
import { compareVersions, renderReleases } from './changelog.mjs';
|
|
17
17
|
import { syncCli, reportCli } from './self-update.mjs';
|
|
18
18
|
import { fetchStatus, landedIn } from './feedback-sync.mjs';
|
|
19
|
+
import { MOVED_FROM_LEGACY_NOTE, movedFromLegacy } from './legacy-names.mjs';
|
|
19
20
|
|
|
20
21
|
// The report is the point. A creator reading this must be able to answer, with
|
|
21
22
|
// no further digging: what changed, was any of it mine, and what do I do now.
|
|
@@ -69,6 +70,20 @@ function report(log, engineVersion, surface) {
|
|
|
69
70
|
log(" changes you didn't make. Review them and save them with your next commit.");
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// A game made before the rename moves over in this update. What is Joystack's
|
|
74
|
+
// has moved; what the game itself says (its links, its tests, a token its
|
|
75
|
+
// automation publishes with) has not, and the note lists it for the agent
|
|
76
|
+
// reading this output. Neither condition holds on a later update, so it is
|
|
77
|
+
// printed this once only.
|
|
78
|
+
function noteMove(log, cli, surface) {
|
|
79
|
+
const moved = movedFromLegacy(cli, surface);
|
|
80
|
+
if (moved) {
|
|
81
|
+
log('');
|
|
82
|
+
for (const line of MOVED_FROM_LEGACY_NOTE) log(` ${line}`);
|
|
83
|
+
}
|
|
84
|
+
return moved;
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
export async function update({
|
|
73
88
|
cwd = process.cwd(),
|
|
74
89
|
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
@@ -146,7 +161,8 @@ export async function update({
|
|
|
146
161
|
log(' This is a pre-release candidate — its changelog and any migration');
|
|
147
162
|
log(' notes ship inside the engine (read the handbook / engine docs), not');
|
|
148
163
|
log(' via `joystack changelog`. Republish (`npx joystack publish`) when ready.');
|
|
149
|
-
|
|
164
|
+
const movedIn = noteMove(log, cli, surface);
|
|
165
|
+
return { from, to: engine.version, updated: from !== engine.version, surface, crossed: null, cli, candidate: true, movedIn };
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
const res = await fetchImpl(`${apiBase}/api/creator/engine`, {
|
|
@@ -295,5 +311,6 @@ export async function update({
|
|
|
295
311
|
log('');
|
|
296
312
|
log(' Republish (`npx joystack publish`) when you want the live game on it.');
|
|
297
313
|
}
|
|
298
|
-
|
|
314
|
+
const movedIn = noteMove(log, cli, surface);
|
|
315
|
+
return { from, to: latest, updated, surface, crossed, cli, landed, movedIn };
|
|
299
316
|
}
|
package/package.json
CHANGED