@looop-games/cli 0.1.26 → 0.1.28
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 +28 -0
- package/bin/looop.mjs +16 -10
- package/lib/{inspect.mjs → assets-page.mjs} +11 -11
- package/lib/changelog.mjs +23 -4
- package/lib/dev.mjs +3 -3
- package/lib/engine.mjs +5 -1
- package/lib/inject.mjs +15 -1
- package/lib/static-server.mjs +7 -3
- package/lib/update.mjs +89 -15
- 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.28] - 2026-08-18
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `looop update --rc <version>` takes a specific **release candidate** — a
|
|
21
|
+
pre-release engine (e.g. `0.2.0-rc.1`) you've been handed to test before it
|
|
22
|
+
becomes an official release. It installs that exact version and re-pins your
|
|
23
|
+
game to it. Candidates are unlisted: a plain `looop update` never picks one
|
|
24
|
+
up, and they don't appear in `looop changelog`. A candidate can be re-cut
|
|
25
|
+
under the same name while it's iterated on, so `--rc` always re-downloads it
|
|
26
|
+
rather than trusting a cached copy.
|
|
27
|
+
|
|
28
|
+
## [0.1.27] - 2026-08-09
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- `looop dev` now injects the **dev toolbox** into every game it serves — the
|
|
32
|
+
looop chip / `Ctrl/Cmd+Shift+L` overlay with the Inspector, your tweaks, and
|
|
33
|
+
a link to the Assets page (see the engine changelog for what is inside). The
|
|
34
|
+
tag is added only when your pinned engine actually ships the toolbox, so an
|
|
35
|
+
older engine just keeps working until you `looop update`. `?_toolbox=off`
|
|
36
|
+
hides it for a session. Published games are untouched — this exists only on
|
|
37
|
+
your dev server.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- **BREAKING** `looop inspect` is now `looop assets`.
|
|
41
|
+
Migration: same command, same arguments — `looop assets [<id>]` opens the
|
|
42
|
+
dev stack on the Assets page (the renamed asset inspector). The old verb is
|
|
43
|
+
gone; "Inspector" now names the toolbox's live-world tool instead.
|
|
44
|
+
|
|
17
45
|
## [0.1.26] - 2026-08-07
|
|
18
46
|
|
|
19
47
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -28,12 +28,12 @@ const HELP = `looop — build and run Looop games
|
|
|
28
28
|
Usage:
|
|
29
29
|
looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
30
30
|
looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
31
|
-
looop
|
|
31
|
+
looop assets [<id>] Browse and drive your models, sounds and assets on their own
|
|
32
32
|
looop lane <name> Open an isolated copy of the game to experiment in, safely
|
|
33
33
|
looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
|
|
34
34
|
looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
|
|
35
35
|
looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
36
|
-
looop update
|
|
36
|
+
looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
|
|
37
37
|
looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
|
|
38
38
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
39
39
|
looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
|
|
@@ -61,14 +61,14 @@ try {
|
|
|
61
61
|
// Keep the process alive; servers + children hold the loop open.
|
|
62
62
|
break;
|
|
63
63
|
}
|
|
64
|
-
case '
|
|
65
|
-
// The same dev stack `looop dev` runs, opened on the
|
|
66
|
-
// second server: the
|
|
64
|
+
case 'assets': {
|
|
65
|
+
// The same dev stack `looop dev` runs, opened on the assets page. Not a
|
|
66
|
+
// second server: the assets page is a page under /shared/, which this one
|
|
67
67
|
// already mounts.
|
|
68
|
-
const {
|
|
68
|
+
const { assetsPageUrl, openBrowser, itemArg } = await import('../lib/assets-page.mjs');
|
|
69
69
|
const handle = await dev({ port: flag('port') ? Number(flag('port')) : undefined });
|
|
70
|
-
const target =
|
|
71
|
-
console.log(`\n 🔎
|
|
70
|
+
const target = assetsPageUrl(handle.url, { item: itemArg(rest) });
|
|
71
|
+
console.log(`\n 🔎 Assets → ${target}\n`);
|
|
72
72
|
if (!rest.includes('--no-open')) openBrowser(target);
|
|
73
73
|
const shutdown = () => {
|
|
74
74
|
handle.stop();
|
|
@@ -104,9 +104,15 @@ try {
|
|
|
104
104
|
all: rest.includes('--all'),
|
|
105
105
|
});
|
|
106
106
|
break;
|
|
107
|
-
case 'update':
|
|
108
|
-
|
|
107
|
+
case 'update': {
|
|
108
|
+
// `--rc <version>` takes an exact release candidate by reference (a
|
|
109
|
+
// pre-release you were handed); no flag → the normal newest-stable update.
|
|
110
|
+
const rcIdx = rest.indexOf('--rc');
|
|
111
|
+
const target = rcIdx >= 0 ? rest[rcIdx + 1] : null;
|
|
112
|
+
if (rcIdx >= 0 && !target) throw new Error('`--rc` needs a version, e.g. `looop update --rc 0.2.0-rc.1`');
|
|
113
|
+
await update({ target });
|
|
109
114
|
break;
|
|
115
|
+
}
|
|
110
116
|
case 'model': {
|
|
111
117
|
// `model` is the accessor for 3D-model tooling; `bake` is its first verb.
|
|
112
118
|
// Baking is normally automatic (dev/test/publish re-bake changed models);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// `looop
|
|
1
|
+
// `looop assets` — the dev stack, opened on the Assets page instead of the
|
|
2
2
|
// game.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// the assets page is a served page under /shared/, not a separate server: the dev
|
|
5
5
|
// server already mounts the engine's shared tree, so there is nothing new to
|
|
6
|
-
// start and nothing to keep in sync. That also means the
|
|
6
|
+
// start and nothing to keep in sync. That also means the assets page still works
|
|
7
7
|
// when the game itself is broken, which is when it is most wanted.
|
|
8
8
|
//
|
|
9
9
|
// It finds the game on its own — the page asks the dev server what it is serving
|
|
@@ -13,18 +13,18 @@ import { spawn } from 'node:child_process';
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
|
-
export const
|
|
16
|
+
export const ASSETS_PAGE_PATH = '/shared/ui/assets/';
|
|
17
17
|
|
|
18
18
|
// Built from the dev server's OWN url rather than an assumed localhost:8000: a
|
|
19
19
|
// lane runs on its own port base and 8000 may belong to somebody else's server,
|
|
20
|
-
// and an
|
|
21
|
-
export function
|
|
20
|
+
// and an assets page opened on the wrong port inspects the wrong game — or none.
|
|
21
|
+
export function assetsPageUrl(gameUrl, { item } = {}) {
|
|
22
22
|
const base = new URL(gameUrl);
|
|
23
23
|
const q = item ? `?_item=${encodeURIComponent(item)}` : '';
|
|
24
|
-
return `${base.origin}${
|
|
24
|
+
return `${base.origin}${ASSETS_PAGE_PATH}${q}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Does this game have anything for the
|
|
27
|
+
// Does this game have anything for the assets page to show?
|
|
28
28
|
//
|
|
29
29
|
// The whole answer is `assets.js`, because the list IS the declaration — nothing
|
|
30
30
|
// walks the folder looking for files any more. So this is the same question as
|
|
@@ -33,15 +33,15 @@ export function inspectorUrl(gameUrl, { item } = {}) {
|
|
|
33
33
|
// points at is then invisible exactly when it would have helped.
|
|
34
34
|
//
|
|
35
35
|
// The file's CONTENTS are not read. A registry that declares nothing, or one
|
|
36
|
-
// that throws on import, is a case the
|
|
36
|
+
// that throws on import, is a case the assets page itself reports far better
|
|
37
37
|
// than a banner line could — and reading it here would mean the dev server
|
|
38
38
|
// evaluating game code to decide how to print a URL.
|
|
39
|
-
export function
|
|
39
|
+
export function hasDeclaredAssets(projectDir) {
|
|
40
40
|
return !!projectDir && existsSync(join(projectDir, 'assets.js'));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// Flags that take a VALUE, so the value is not mistaken for the asset name.
|
|
44
|
-
// `looop
|
|
44
|
+
// `looop assets --port 8210` is two argv entries, and reading "the first
|
|
45
45
|
// argument that is not a flag" turns 8210 into the thing to open — on the one
|
|
46
46
|
// command whose entire job is opening the right asset.
|
|
47
47
|
const VALUED_FLAGS = new Set(['--port']);
|
package/lib/changelog.mjs
CHANGED
|
@@ -24,13 +24,32 @@ const RULE = '─'.repeat(64);
|
|
|
24
24
|
// Semver by NUMBER. String order would put 0.1.9 after 0.1.10 and quietly show
|
|
25
25
|
// the wrong set — the kind of bug nobody notices until a release is missing
|
|
26
26
|
// from someone's update.
|
|
27
|
+
//
|
|
28
|
+
// Release candidates carry a pre-release suffix (`0.2.0-rc.1`); a game pinned to
|
|
29
|
+
// one still asks this to compare versions (e.g. `looop changelog` filters by the
|
|
30
|
+
// pin). Standard semver precedence: a pre-release ranks just BELOW its release
|
|
31
|
+
// (`0.2.0-rc.1` < `0.2.0`), and two pre-releases of the same core order by their
|
|
32
|
+
// suffix. Without this the raw `split('.').map(Number)` produced NaN and silently
|
|
33
|
+
// mis-ranked every suffixed version.
|
|
27
34
|
export function compareVersions(a, b) {
|
|
28
|
-
const
|
|
29
|
-
|
|
35
|
+
const parse = (v) => {
|
|
36
|
+
const s = String(v);
|
|
37
|
+
const dash = s.indexOf('-');
|
|
38
|
+
const core = dash < 0 ? s : s.slice(0, dash);
|
|
39
|
+
const pre = dash < 0 ? '' : s.slice(dash + 1);
|
|
40
|
+
return { nums: core.split('.').map(Number), pre };
|
|
41
|
+
};
|
|
42
|
+
const A = parse(a);
|
|
43
|
+
const B = parse(b);
|
|
30
44
|
for (let i = 0; i < 3; i++) {
|
|
31
|
-
if ((
|
|
45
|
+
if ((A.nums[i] ?? 0) !== (B.nums[i] ?? 0)) return (A.nums[i] ?? 0) > (B.nums[i] ?? 0) ? 1 : -1;
|
|
32
46
|
}
|
|
33
|
-
|
|
47
|
+
// Same numeric core: a release outranks its pre-releases; two pre-releases
|
|
48
|
+
// order lexically by suffix (a stable, total order — the label is opaque).
|
|
49
|
+
if (A.pre === B.pre) return 0;
|
|
50
|
+
if (!A.pre) return 1;
|
|
51
|
+
if (!B.pre) return -1;
|
|
52
|
+
return A.pre < B.pre ? -1 : 1;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
export function selectReleases(releases, { pinned, version, all } = {}) {
|
package/lib/dev.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
|
|
|
20
20
|
import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
|
|
21
21
|
import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
|
|
22
22
|
import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
|
|
23
|
-
import {
|
|
23
|
+
import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
|
|
24
24
|
|
|
25
25
|
// Resolve the partykit CLI entry from OUR dependencies (the game never
|
|
26
26
|
// declares partykit; it rides @looop-games/cli). partykit's `exports` map hides
|
|
@@ -287,8 +287,8 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
287
287
|
// Only when this game declares assets (inspect.mjs). A line printed on every
|
|
288
288
|
// single boot is a line nobody reads by the third one, and the tool it points
|
|
289
289
|
// at is then invisible exactly when it would have helped.
|
|
290
|
-
if (
|
|
291
|
-
log(` 🔍 ${
|
|
290
|
+
if (hasDeclaredAssets(project.dir)) {
|
|
291
|
+
log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
|
|
292
292
|
}
|
|
293
293
|
log('────────────────────────────────────────────────────────────');
|
|
294
294
|
|
package/lib/engine.mjs
CHANGED
|
@@ -122,7 +122,11 @@ export async function ensureEngine(
|
|
|
122
122
|
|
|
123
123
|
const cache = cacheDir();
|
|
124
124
|
const cached = join(cache, `engine-${pin}.tgz`);
|
|
125
|
-
|
|
125
|
+
// A release candidate (a `-suffix` pin) is MUTABLE — the same label can be
|
|
126
|
+
// re-cut with new bytes — so its cache entry can be stale. Always re-download
|
|
127
|
+
// a candidate; the content-addressed stable releases are safe to cache forever.
|
|
128
|
+
const isCandidate = pin.includes('-');
|
|
129
|
+
if (!existsSync(cached) || isCandidate) {
|
|
126
130
|
log(`Downloading engine ${pin} from ${apiBase}…`);
|
|
127
131
|
const dlUrl = `${apiBase}/api/creator/engine/${pin}`;
|
|
128
132
|
let res = await fetchImpl(dlUrl, { headers: auth });
|
package/lib/inject.mjs
CHANGED
|
@@ -14,6 +14,11 @@ import { dirname } from 'node:path';
|
|
|
14
14
|
export const DEV_IDENTITY = { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' };
|
|
15
15
|
|
|
16
16
|
export const PLATFORM_URL = '/shared/platform/platform.js';
|
|
17
|
+
// The dev toolbox — injected ONLY here, never by the production serve path
|
|
18
|
+
// (builder/functions/_shared/serve-identity.ts). That separation is the gate
|
|
19
|
+
// that keeps a dev surface off published pages: the production injector never
|
|
20
|
+
// names this module, so there is nothing to strip and nothing to trust.
|
|
21
|
+
export const TOOLBOX_URL = '/shared/ui/toolbox/toolbox.js';
|
|
17
22
|
|
|
18
23
|
// JSON safe for an inline <script>: `<` → < so a value containing
|
|
19
24
|
// "</script>" can't terminate the script early.
|
|
@@ -21,13 +26,22 @@ function jsJson(value) {
|
|
|
21
26
|
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL } = {}) {
|
|
29
|
+
export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL } = {}) {
|
|
25
30
|
const parts = [];
|
|
26
31
|
if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
|
|
27
32
|
parts.push(`<script>window.LOOOP_IDENTITY = ${jsJson(identity)};</script>`);
|
|
28
33
|
parts.push(
|
|
29
34
|
`<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
|
|
30
35
|
);
|
|
36
|
+
// Dev-only, opt-in per request: the server sets `toolbox` only when the
|
|
37
|
+
// resolved engine bundle carries the module (inject-if-present), so an older
|
|
38
|
+
// pinned engine under a newer CLI never gets a 404ing tag. Behind the
|
|
39
|
+
// platform tag — the boot gate's error handlers come up first.
|
|
40
|
+
if (toolbox) {
|
|
41
|
+
parts.push(
|
|
42
|
+
`<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
31
45
|
const inject = '\n ' + parts.join('\n ');
|
|
32
46
|
const m = /<head[^>]*>/i.exec(html);
|
|
33
47
|
if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
|
package/lib/static-server.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import http from 'node:http';
|
|
|
12
12
|
import { EventEmitter } from 'node:events';
|
|
13
13
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
14
14
|
import { extname, join, normalize, sep } from 'node:path';
|
|
15
|
-
import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL } from './inject.mjs';
|
|
15
|
+
import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL, TOOLBOX_URL } from './inject.mjs';
|
|
16
16
|
import { WHOAMI_PATH } from './ports.mjs';
|
|
17
17
|
|
|
18
18
|
const RELOAD_CLIENT = `<script>
|
|
@@ -158,7 +158,7 @@ export function createStaticServer({
|
|
|
158
158
|
// the only page that gets the platform layer there. Injecting it into every
|
|
159
159
|
// HTML file the dev server happens to serve puts the boot gate, the loading
|
|
160
160
|
// curtain and the M-key menu on top of pages that are not games and cannot
|
|
161
|
-
// satisfy them — the shared inspector page (/shared/ui/
|
|
161
|
+
// satisfy them — the shared inspector page (/shared/ui/assets/) renders
|
|
162
162
|
// its stage underneath a curtain waiting for an identity it never asked for.
|
|
163
163
|
const m = /^\/games\/([^/]+)\//.exec(urlPath);
|
|
164
164
|
if (m && resolveUrl(PLATFORM_URL)) {
|
|
@@ -171,7 +171,11 @@ export function createStaticServer({
|
|
|
171
171
|
const tabIdentity = as
|
|
172
172
|
? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
|
|
173
173
|
: identity;
|
|
174
|
-
|
|
174
|
+
// The toolbox rides only when the resolved engine carries it — an older
|
|
175
|
+
// pinned engine gets no 404ing tag (it gains the toolbox at `looop
|
|
176
|
+
// update`). Per-request, so a bundle swap mid-session is honoured.
|
|
177
|
+
const toolbox = !!resolveUrl(TOOLBOX_URL);
|
|
178
|
+
html = injectHeadTags(html, m[1], { ...(tabIdentity ? { identity: tabIdentity } : {}), toolbox });
|
|
175
179
|
}
|
|
176
180
|
if (injectReload) {
|
|
177
181
|
html = html.includes('</body>') ? html.replace('</body>', RELOAD_CLIENT + '</body>') : html + RELOAD_CLIENT;
|
package/lib/update.mjs
CHANGED
|
@@ -80,6 +80,8 @@ export async function update({
|
|
|
80
80
|
ensure = ensureEngine,
|
|
81
81
|
reconcile = reconcileAgentSurface,
|
|
82
82
|
syncCliFn = syncCli,
|
|
83
|
+
// `looop update --rc <version>` — take an EXACT release candidate by reference.
|
|
84
|
+
target = null,
|
|
83
85
|
} = {}) {
|
|
84
86
|
const project = findProject(cwd);
|
|
85
87
|
const from = readEnginePin(project.dir);
|
|
@@ -91,6 +93,64 @@ export async function update({
|
|
|
91
93
|
if (!getToken()) throw new Error('login did not produce a token — run `looop login` and retry.');
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// ── Release-candidate lane ────────────────────────────────────────────────
|
|
97
|
+
// A candidate (`--rc 0.2.0-rc.1`) is UNLISTED: it never appears in `latest` or
|
|
98
|
+
// `looop changelog`, so this lane bypasses the newest-stable comparison and
|
|
99
|
+
// installs the named version directly. Its changelog and any migration notes
|
|
100
|
+
// ship INSIDE the engine tarball (the docs the agent reads), not through the
|
|
101
|
+
// version-crossing callout the stable lane prints. syncCli runs FIRST for the
|
|
102
|
+
// same reason it does below — its npm install would prune the engine if it ran
|
|
103
|
+
// after the engine landed.
|
|
104
|
+
if (target) {
|
|
105
|
+
// Validate the version shape BEFORE anything mutates the repo. syncCli's npm
|
|
106
|
+
// install below prunes the engine, so a typo caught only after that point
|
|
107
|
+
// would leave the game engine-less on a bad pin. This mirrors the platform's
|
|
108
|
+
// ENGINE_VERSION_WITH_PRERELEASE_OK; a well-formed but unknown/removed
|
|
109
|
+
// candidate is still caught below by restoring the pin on a failed download.
|
|
110
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(target)) {
|
|
111
|
+
throw new Error(`\`--rc\` needs a valid engine version like 0.2.0-rc.1 — got "${target}".`);
|
|
112
|
+
}
|
|
113
|
+
let cli;
|
|
114
|
+
try {
|
|
115
|
+
cli = await syncCliFn({ projectDir: project.dir, log });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
cli = { updated: false, error: err };
|
|
118
|
+
}
|
|
119
|
+
writeEnginePin(project.dir, target);
|
|
120
|
+
let engine;
|
|
121
|
+
try {
|
|
122
|
+
engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// The candidate didn't download (a typo that still parsed, or one that was
|
|
125
|
+
// since removed). Restore the previous pin so the game isn't left pointing
|
|
126
|
+
// at a version that doesn't exist — the engine may have been pruned by the
|
|
127
|
+
// CLI install above, but is recoverable by a normal `looop dev`/`update` on
|
|
128
|
+
// the restored pin. A game with no prior pin keeps none.
|
|
129
|
+
if (from) writeEnginePin(project.dir, from);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
const engineDir = engine.dir ?? null;
|
|
133
|
+
log('');
|
|
134
|
+
log(`✅ Engine candidate installed: ${from ?? '(none)'} → ${engine.version}`);
|
|
135
|
+
|
|
136
|
+
const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
|
|
137
|
+
if (!surface.skipped) report(log, surface.engineVersion ?? target, surface);
|
|
138
|
+
|
|
139
|
+
if (cli?.error) {
|
|
140
|
+
log('');
|
|
141
|
+
log(` The looop command could not be updated (${cli.error.message}).`);
|
|
142
|
+
log(' Retry with: npm update @looop-games/cli');
|
|
143
|
+
} else {
|
|
144
|
+
reportCli(log, cli);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
log('');
|
|
148
|
+
log(' This is a pre-release candidate — its changelog and any migration');
|
|
149
|
+
log(' notes ship inside the engine (read the handbook / engine docs), not');
|
|
150
|
+
log(' via `looop changelog`. Republish (`npx looop publish`) when ready.');
|
|
151
|
+
return { from, to: engine.version, updated: from !== engine.version, surface, crossed: null, cli, candidate: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
94
154
|
const res = await fetchImpl(`${apiBase}/api/creator/engine`, {
|
|
95
155
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
96
156
|
});
|
|
@@ -143,27 +203,41 @@ export async function update({
|
|
|
143
203
|
let engineDir = null;
|
|
144
204
|
let updated = false;
|
|
145
205
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
206
|
+
// Forward-only: `looop update` moves a game to `latest` only when `latest` is
|
|
207
|
+
// actually NEWER than the pin. It never moves a game that is already on latest
|
|
208
|
+
// OR ahead of it — the latter is real now that release candidates exist: a
|
|
209
|
+
// testbed pinned to `0.2.0-rc.3` while the newest stable is `0.1.10` must not
|
|
210
|
+
// be silently DOWNGRADED to 0.1.10 (which would break every 0.2.0 API it uses)
|
|
211
|
+
// just because the plain `update` was run out of habit. `--rc` is the lane that
|
|
212
|
+
// moves onto a candidate; the stable lane only ever moves forward.
|
|
213
|
+
const cmp = from ? compareVersions(from, latest) : -1;
|
|
214
|
+
if (cmp >= 0) {
|
|
215
|
+
// On latest, or ahead of it on a pre-release. Reconcile the surface anyway —
|
|
216
|
+
// a repo can sit on the right engine with a STILL out-of-date surface (one
|
|
217
|
+
// scaffolded before this mechanism existed never had its skills adopted) —
|
|
218
|
+
// but never rewrite the pin.
|
|
219
|
+
const label = from ?? latest;
|
|
220
|
+
const ahead = cmp > 0;
|
|
151
221
|
try {
|
|
152
222
|
engineDir = resolveEngine(project.dir).dir;
|
|
153
|
-
log(
|
|
223
|
+
log(
|
|
224
|
+
ahead
|
|
225
|
+
? `✅ Engine ${label} — a pre-release ahead of the latest release (${latest}); not downgrading.`
|
|
226
|
+
: `✅ Engine ${label} — already up to date.`,
|
|
227
|
+
);
|
|
154
228
|
} catch {
|
|
155
|
-
// Pinned to
|
|
156
|
-
// game runs; node_modules is the truth, and they disagree — because a
|
|
157
|
-
// `npm install` (the creator's own, or ours above) prunes the engine,
|
|
158
|
-
// npm never recorded. "Already up to date" while the engine is missing
|
|
159
|
-
// lie that leaves every engine-reading command broken, and re-running
|
|
160
|
-
// could never fix it. Put it back — from the local cache, so this is
|
|
161
|
-
// works offline.
|
|
162
|
-
log(`Engine ${
|
|
229
|
+
// Pinned to this version, but NOT on disk. The pin is a claim about what
|
|
230
|
+
// this game runs; node_modules is the truth, and they disagree — because a
|
|
231
|
+
// plain `npm install` (the creator's own, or ours above) prunes the engine,
|
|
232
|
+
// which npm never recorded. "Already up to date" while the engine is missing
|
|
233
|
+
// is a lie that leaves every engine-reading command broken, and re-running
|
|
234
|
+
// update could never fix it. Put it back — from the local cache, so this is
|
|
235
|
+
// fast and works offline.
|
|
236
|
+
log(`Engine ${label} is pinned but missing from node_modules — reinstalling it.`);
|
|
163
237
|
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
164
238
|
engineDir = engine.dir ?? null;
|
|
165
239
|
log('');
|
|
166
|
-
log(`✅ Engine ${
|
|
240
|
+
log(`✅ Engine ${label} — restored.`);
|
|
167
241
|
}
|
|
168
242
|
} else {
|
|
169
243
|
// Rewrite the pin first; ensureEngine honors it (download → install → pin).
|