@looop-games/cli 0.1.27 → 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 CHANGED
@@ -14,6 +14,17 @@ 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
+
17
28
  ## [0.1.27] - 2026-08-09
18
29
 
19
30
  ### Added
package/bin/looop.mjs CHANGED
@@ -33,7 +33,7 @@ Usage:
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 Move this game to the latest engine release (re-pins looop.engine)
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
@@ -104,9 +104,15 @@ try {
104
104
  all: rest.includes('--all'),
105
105
  });
106
106
  break;
107
- case 'update':
108
- await update();
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);
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 pa = a.split('.').map(Number);
29
- const pb = b.split('.').map(Number);
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 ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0) ? 1 : -1;
45
+ if ((A.nums[i] ?? 0) !== (B.nums[i] ?? 0)) return (A.nums[i] ?? 0) > (B.nums[i] ?? 0) ? 1 : -1;
32
46
  }
33
- return 0;
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/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
- if (!existsSync(cached)) {
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/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
- if (from === latest) {
147
- // Reconcile anyway. A repo can sit on the latest engine and STILL have an
148
- // out-of-date surface: one scaffolded before this mechanism existed has
149
- // never had its skills adopted, and would otherwise wait forever for a
150
- // release it already has.
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(`✅ Engine ${latest} — already up to date.`);
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 the latest, but NOT on disk. The pin is a claim about what this
156
- // game runs; node_modules is the truth, and they disagree — because a plain
157
- // `npm install` (the creator's own, or ours above) prunes the engine, which
158
- // npm never recorded. "Already up to date" while the engine is missing is a
159
- // lie that leaves every engine-reading command broken, and re-running update
160
- // could never fix it. Put it back — from the local cache, so this is fast and
161
- // works offline.
162
- log(`Engine ${latest} is pinned but missing from node_modules — reinstalling it.`);
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 ${latest} — restored.`);
240
+ log(`✅ Engine ${label} — restored.`);
167
241
  }
168
242
  } else {
169
243
  // Rewrite the pin first; ensureEngine honors it (download → install → pin).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",