@looop-games/cli 0.1.10 → 0.1.11

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,23 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.11] - 2026-07-12
18
+
19
+ ### Fixed
20
+ - `looop update` now updates the **`looop` command itself**, not just the engine.
21
+ It used to leave the command at whatever version your game had pinned — so an
22
+ update could hand you an engine whose skills tell you to run a command your
23
+ `looop` was too old to have (`Unknown command: changelog`). It now moves both,
24
+ and says which versions it moved you between.
25
+
26
+ If you are seeing `Unknown command` today, you are on a `looop` from before
27
+ this fix and it cannot update itself. Run this once, and it will keep itself
28
+ current from then on:
29
+
30
+ ```bash
31
+ npm update @looop-games/cli
32
+ ```
33
+
17
34
  ## [0.1.10] - 2026-07-12
18
35
 
19
36
  ### Added
@@ -0,0 +1,120 @@
1
+ // Keep the `looop` command itself up to date — the third lane.
2
+ //
3
+ // THE BUG THIS EXISTS FOR. A game's skills and its engine ride one artifact, so
4
+ // they can't drift. The `looop` command does not: it's an npm devDependency
5
+ // pinned in the game's own lockfile, and `npx looop` runs THAT copy, not npm's
6
+ // latest. `looop update` used to move only the engine — so a real game (riven)
7
+ // ended up on engine 0.1.14, whose skills tell the agent to run
8
+ // `looop changelog`, while its CLI was still 0.1.6:
9
+ //
10
+ // $ npx looop changelog
11
+ // Unknown command: changelog
12
+ //
13
+ // The agent surface is shipped BY the engine and can name any `looop` command it
14
+ // likes. Nothing kept the CLI in step with it. So `looop update` must mean
15
+ // "update Looop" — engine, skills, AND the command — or the next skill that
16
+ // mentions a new command breaks the same way.
17
+ //
18
+ // FAIL SOFT, ALWAYS. The engine update is the important half and it has already
19
+ // happened by the time we get here. A CLI bump that cannot be done (offline, npm
20
+ // down, no write access) must warn and step aside — never take the command down
21
+ // with it. Nothing in here throws.
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { runNpm } from './npm.mjs';
25
+
26
+ export const CLI_PKG = '@looop-games/cli';
27
+
28
+ // What `npx looop` will run NEXT TIME — the copy on disk in this game, which is
29
+ // not necessarily the copy running right now (`npx @looop-games/cli@x update` in
30
+ // a repo pinned to something older runs the newer one but must still fix the
31
+ // repo).
32
+ export function projectCliVersion(projectDir) {
33
+ try {
34
+ const pkg = JSON.parse(
35
+ readFileSync(join(projectDir, 'node_modules', ...CLI_PKG.split('/'), 'package.json'), 'utf8'),
36
+ );
37
+ return typeof pkg.version === 'string' ? pkg.version : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function isOlder(a, b) {
44
+ const pa = a.split('.').map(Number);
45
+ const pb = b.split('.').map(Number);
46
+ for (let i = 0; i < 3; i++) {
47
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0);
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export async function syncCli({ projectDir, npm = runNpm, log = console.log } = {}) {
53
+ const from = projectCliVersion(projectDir);
54
+
55
+ // Nothing installed: `create` and `dev` own that. There is nothing to bring up
56
+ // to date, and force-installing here would be a surprise.
57
+ if (!from) return { from: null, to: null, updated: false, skipped: 'not-installed' };
58
+
59
+ let latest;
60
+ try {
61
+ const out = npm(['view', CLI_PKG, 'version', '--json'], { cwd: projectDir });
62
+ latest = JSON.parse(out.stdout.toString());
63
+ } catch (err) {
64
+ log('');
65
+ log(` Could not check for a newer looop command (npm did not answer). Your engine is`);
66
+ log(` up to date; the command stayed at ${from}. Try again later, or run:`);
67
+ log(` npm update ${CLI_PKG}`);
68
+ return { from, to: null, updated: false, error: err };
69
+ }
70
+
71
+ // Never downgrade. A local dev build (or a version newer than the registry
72
+ // knows about) is deliberate — leave it alone.
73
+ if (!isOlder(from, latest)) return { from, to: latest, updated: false };
74
+
75
+ try {
76
+ // --save-dev so the DECLARED range moves too. Without it the next plain
77
+ // `npm install` in that repo happily resolves back down to the old pin, and
78
+ // the update silently un-does itself.
79
+ npm(['install', '--save-dev', `${CLI_PKG}@${latest}`], { cwd: projectDir });
80
+ } catch (err) {
81
+ log('');
82
+ log(` The looop command could not be updated to ${latest} (npm install failed).`);
83
+ log(` Your engine is up to date; the command is still ${from}. Run this to retry:`);
84
+ log(` npm install --save-dev ${CLI_PKG}@${latest}`);
85
+ return { from, to: latest, updated: false, error: err };
86
+ }
87
+
88
+ return { from, to: latest, updated: true };
89
+ }
90
+
91
+ // The version of the CLI EXECUTING right now — which is not always the one the
92
+ // project has installed. `npx looop` runs the local install (so they match), but
93
+ // `npx @looop-games/cli@latest update` runs a newer one against an older repo.
94
+ export function runningCliVersion() {
95
+ try {
96
+ return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')).version ?? null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // What `update` prints. Separate so the wording is testable.
103
+ //
104
+ // The caveat — "you're still running the old one" — is TRUE only when the process
105
+ // executing is the copy we just replaced. An earlier version asserted it
106
+ // unconditionally and told a `npx @looop-games/cli@latest update` run that it was
107
+ // "still running 0.1.6", which it plainly was not. Caught by reading the output of
108
+ // a real run against riven. Say it only when it's true.
109
+ export function reportCli(log, cli, { running = runningCliVersion() } = {}) {
110
+ if (!cli?.updated) return;
111
+ log('');
112
+ log(`✅ The looop command updated: ${cli.from} → ${cli.to}`);
113
+ if (running === cli.from) {
114
+ log(` You're still running ${cli.from} in this process — any new commands work from`);
115
+ log(` your next \`npx looop\`.`);
116
+ } else {
117
+ log(` New commands are available from your next \`npx looop\`.`);
118
+ }
119
+ log(` Its own changes are in node_modules/${CLI_PKG}/CHANGELOG.md.`);
120
+ }
package/lib/update.mjs CHANGED
@@ -16,6 +16,7 @@ import { getToken, getApiBase } from './config.mjs';
16
16
  import { login } from './login.mjs';
17
17
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
18
18
  import { compareVersions, renderReleases } from './changelog.mjs';
19
+ import { syncCli, reportCli } from './self-update.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.
@@ -57,6 +58,7 @@ export async function update({
57
58
  loginFn = login,
58
59
  ensure = ensureEngine,
59
60
  reconcile = reconcileAgentSurface,
61
+ syncCliFn = syncCli,
60
62
  } = {}) {
61
63
  const project = findProject(cwd);
62
64
  const from = readEnginePin(project.dir);
@@ -122,6 +124,24 @@ export async function update({
122
124
  const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
123
125
  if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
124
126
 
127
+ // The third lane: the `looop` command itself (see self-update.mjs). The skills
128
+ // we just reconciled ship WITH the engine and can name any command they like —
129
+ // riven ended up on an engine whose skills say `looop changelog` while its CLI
130
+ // was four versions too old to have it. So update moves this too.
131
+ //
132
+ // Wrapped: syncCli is already fail-soft, but a bug in it must not undo an
133
+ // engine update that has already landed on disk.
134
+ let cli;
135
+ try {
136
+ cli = await syncCliFn({ projectDir: project.dir, log });
137
+ reportCli(log, cli);
138
+ } catch (err) {
139
+ cli = { updated: false, error: err };
140
+ log('');
141
+ log(` The looop command could not be updated (${err.message}).`);
142
+ log(' Your engine and skills are up to date. Retry with: npm update @looop-games/cli');
143
+ }
144
+
125
145
  if (updated) {
126
146
  log('');
127
147
  if (crossed === null) {
@@ -133,5 +153,5 @@ export async function update({
133
153
  log('');
134
154
  log(' Republish (`npx looop publish`) when you want the live game on it.');
135
155
  }
136
- return { from, to: latest, updated, surface, crossed };
156
+ return { from, to: latest, updated, surface, crossed, cli };
137
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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",