@looop-games/cli 0.1.9 → 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 +92 -0
- package/bin/looop.mjs +8 -0
- package/lib/changelog.mjs +171 -0
- package/lib/self-update.mjs +120 -0
- package/lib/update.mjs +49 -2
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Changelog — @looop-games/cli
|
|
2
|
+
|
|
3
|
+
The `looop` command itself. This is the one piece of Looop you install from npm;
|
|
4
|
+
everything else (the component library, the room primitives, your game's skills)
|
|
5
|
+
lives in the engine and has [its own changelog](https://docs.looop.games), which
|
|
6
|
+
you can read with `looop changelog`.
|
|
7
|
+
|
|
8
|
+
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
9
|
+
Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
10
|
+
|
|
11
|
+
> Entries for 0.1.9 and earlier were reconstructed from the release history after
|
|
12
|
+
> the fact, so they are short and may miss detail. Everything from 0.1.10 on is
|
|
13
|
+
> written as the change is made.
|
|
14
|
+
|
|
15
|
+
## [Unreleased]
|
|
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
|
+
|
|
34
|
+
## [0.1.10] - 2026-07-12
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `looop changelog` — what changed in the engine. With no arguments it shows
|
|
38
|
+
every release newer than the version your game is pinned to; `looop changelog
|
|
39
|
+
<version>` shows one release, `--all` shows the whole history.
|
|
40
|
+
- `looop update` now prints the changelog for every version it crosses, with
|
|
41
|
+
**BREAKING** entries called out separately, so you find out what has to change
|
|
42
|
+
in your game at the moment you take the update — not when something breaks.
|
|
43
|
+
|
|
44
|
+
## [0.1.9] - 2026-07-12
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- `looop create` now signs you in and downloads the engine, which carries the
|
|
48
|
+
starter game and your game's skills. The login used to happen at your first
|
|
49
|
+
`looop dev`, one command later.
|
|
50
|
+
- The published package is now just the command itself. Everything you receive as
|
|
51
|
+
a creator — the starter game, the skills, the shared libraries — comes from the
|
|
52
|
+
engine, so the two can no longer drift apart.
|
|
53
|
+
|
|
54
|
+
## [0.1.8] - 2026-07-12
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
- `looop feedback` sends the files a report attaches, so whoever reads it can see
|
|
58
|
+
the code you are talking about.
|
|
59
|
+
|
|
60
|
+
## [0.1.7] - 2026-07-11
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
- `looop update` refreshes your game's skills and `AGENTS.md`, and reports every
|
|
64
|
+
file it touched. A file you edited yourself is kept, not overwritten.
|
|
65
|
+
|
|
66
|
+
## [0.1.6] - 2026-07-11
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
- `looop update` — move a game onto the latest engine release and re-pin it.
|
|
70
|
+
|
|
71
|
+
## [0.1.5] - 2026-07-11
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
- `looop feedback` — send the reports under `notes/feedback/` to the Looop team.
|
|
75
|
+
|
|
76
|
+
## [0.1.4] - 2026-07-11
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
- `looop create` installs cleanly on macOS (`fsevents` is pre-approved along with
|
|
80
|
+
the other install scripts).
|
|
81
|
+
|
|
82
|
+
## [0.1.3] - 2026-07-11
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
- `looop create` works on Windows.
|
|
86
|
+
|
|
87
|
+
## [0.1.2] - 2026-07-10
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
- First public release. `looop create` scaffolds a game, `looop dev` runs it with
|
|
91
|
+
multiplayer, `looop publish` puts it on play.looop.games, `looop login`
|
|
92
|
+
authenticates the machine.
|
package/bin/looop.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { publish } from '../lib/publish.mjs';
|
|
|
9
9
|
import { create } from '../lib/create.mjs';
|
|
10
10
|
import { testCmd } from '../lib/test-cmd.mjs';
|
|
11
11
|
import { update } from '../lib/update.mjs';
|
|
12
|
+
import { changelog } from '../lib/changelog.mjs';
|
|
12
13
|
import { sendFeedback } from '../lib/feedback.mjs';
|
|
13
14
|
import { clearToken } from '../lib/config.mjs';
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ Usage:
|
|
|
25
26
|
looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
26
27
|
looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
27
28
|
looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
|
|
29
|
+
looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
28
30
|
looop update Move this game to the latest engine release (re-pins looop.engine)
|
|
29
31
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
30
32
|
looop feedback Send the unsent reports under notes/feedback/ to the Looop team
|
|
@@ -57,6 +59,12 @@ try {
|
|
|
57
59
|
const { ok } = await testCmd();
|
|
58
60
|
process.exit(ok ? 0 : 1);
|
|
59
61
|
}
|
|
62
|
+
case 'changelog':
|
|
63
|
+
await changelog({
|
|
64
|
+
version: rest.find((a) => !a.startsWith('--')),
|
|
65
|
+
all: rest.includes('--all'),
|
|
66
|
+
});
|
|
67
|
+
break;
|
|
60
68
|
case 'update':
|
|
61
69
|
await update();
|
|
62
70
|
break;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// `looop changelog` — what changed in the engine (project note azlqm2).
|
|
2
|
+
//
|
|
3
|
+
// The gap this closes: `looop update` used to say "Engine updated: 0.1.10 →
|
|
4
|
+
// 0.1.12" and then list the SKILL FILES it rewrote. It said nothing about what
|
|
5
|
+
// changed in the engine itself, so the agent crossed two versions of the shared
|
|
6
|
+
// library blind — it could not know a call it makes is now wrong, or that the
|
|
7
|
+
// thing it was about to hand-roll now exists.
|
|
8
|
+
//
|
|
9
|
+
// Two moments matter, and they are different:
|
|
10
|
+
// BEFORE an update — "what am I about to take?" → the default, vs your pin
|
|
11
|
+
// AFTER an update — "what do I have to change?" → the BREAKING callout
|
|
12
|
+
//
|
|
13
|
+
// Reading is NOT login-gated. The version list is public by design (a version
|
|
14
|
+
// number is not a secret — see builder/functions/api/creator/engine/index.ts);
|
|
15
|
+
// only the DOWNLOAD carries a token. Requiring a login to read what an update
|
|
16
|
+
// would do to you would be exactly backwards.
|
|
17
|
+
import { findProject } from './project.mjs';
|
|
18
|
+
import { readEnginePin } from './engine.mjs';
|
|
19
|
+
import { getApiBase } from './config.mjs';
|
|
20
|
+
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
21
|
+
|
|
22
|
+
const RULE = '─'.repeat(64);
|
|
23
|
+
|
|
24
|
+
// Semver by NUMBER. String order would put 0.1.9 after 0.1.10 and quietly show
|
|
25
|
+
// the wrong set — the kind of bug nobody notices until a release is missing
|
|
26
|
+
// from someone's update.
|
|
27
|
+
export function compareVersions(a, b) {
|
|
28
|
+
const pa = a.split('.').map(Number);
|
|
29
|
+
const pb = b.split('.').map(Number);
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0) ? 1 : -1;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function selectReleases(releases, { pinned, version, all } = {}) {
|
|
37
|
+
const newestFirst = [...releases].sort((a, b) => compareVersions(b.version, a.version));
|
|
38
|
+
|
|
39
|
+
if (version) {
|
|
40
|
+
const one = newestFirst.find((r) => r.version === version);
|
|
41
|
+
if (!one) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`no engine release ${version}. Known: ${newestFirst.map((r) => r.version).join(', ')}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return [one];
|
|
47
|
+
}
|
|
48
|
+
if (all) return newestFirst;
|
|
49
|
+
// No pin means the game has never installed an engine (no `dev` yet). There is
|
|
50
|
+
// no "since" to work from, so show what it would get: the newest.
|
|
51
|
+
if (!pinned) return newestFirst.slice(0, 1);
|
|
52
|
+
return newestFirst.filter((r) => compareVersions(r.version, pinned) > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The actionable half. Everything else in a changelog is informative; a BREAKING
|
|
56
|
+
// entry means code in THIS game has to change, so it gets hoisted out of the
|
|
57
|
+
// prose and repeated on its own — with its migration line, which is the part the
|
|
58
|
+
// reader actually needs.
|
|
59
|
+
//
|
|
60
|
+
// The marker is POSITIONAL: a breaking change is a bullet that STARTS with it.
|
|
61
|
+
// Two false positives got here by loosening that, and both were found by reading
|
|
62
|
+
// real output rather than by any test:
|
|
63
|
+
//
|
|
64
|
+
// 1. matching the bare word case-insensitively flagged the ordinary sentence
|
|
65
|
+
// "when your game turns out to be breaking a Looop rule";
|
|
66
|
+
// 2. matching `**BREAKING**` anywhere on a line flagged a changelog entry that
|
|
67
|
+
// merely DESCRIBED the feature ("...calls out **BREAKING** entries
|
|
68
|
+
// separately...") — prose about breaking changes is not a breaking change.
|
|
69
|
+
//
|
|
70
|
+
// A callout that cries wolf is worse than no callout: it gets ignored, and the
|
|
71
|
+
// next real one is ignored with it.
|
|
72
|
+
// Bold form is case-insensitive (`**BREAKING**`, `**Breaking**` — the markup is
|
|
73
|
+
// the deliberate part). The bare-word form must be ALL CAPS with punctuation, so
|
|
74
|
+
// a bullet that merely opens with the ordinary word ("- breaking the sound
|
|
75
|
+
// barrier: …") can't pass as a marker.
|
|
76
|
+
const BREAKING_BOLD = /^\s*[-*]\s+\*\*breaking\*\*/i;
|
|
77
|
+
const BREAKING_CAPS = /^\s*[-*]\s+BREAKING\b\s*[:—–-]/;
|
|
78
|
+
const BREAKING_BULLET = { test: (l) => BREAKING_BOLD.test(l) || BREAKING_CAPS.test(l) };
|
|
79
|
+
|
|
80
|
+
// True for a line that continues the bullet above it: indented, non-blank, and
|
|
81
|
+
// not itself a new bullet or heading. That's how the migration line rides along.
|
|
82
|
+
const CONTINUATION = /^\s+\S/;
|
|
83
|
+
const NEW_BULLET_OR_HEADING = /^\s*(?:[-*]\s|#)/;
|
|
84
|
+
|
|
85
|
+
export function breakingLines(notes) {
|
|
86
|
+
const lines = (notes ?? '').split('\n');
|
|
87
|
+
const out = [];
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
if (!BREAKING_BULLET.test(lines[i])) continue;
|
|
90
|
+
const block = [lines[i].trim()];
|
|
91
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
92
|
+
const l = lines[j];
|
|
93
|
+
if (!CONTINUATION.test(l) || NEW_BULLET_OR_HEADING.test(l)) break;
|
|
94
|
+
block.push(l.trim());
|
|
95
|
+
i = j;
|
|
96
|
+
}
|
|
97
|
+
out.push(block.join(' '));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderReleases(selected, { pinned, latest } = {}) {
|
|
103
|
+
const out = [];
|
|
104
|
+
if (!selected.length) {
|
|
105
|
+
out.push(`Engine ${pinned ?? '(unpinned)'} — nothing new. This game is up to date with ${latest}.`);
|
|
106
|
+
return out.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const head =
|
|
110
|
+
selected.length === 1
|
|
111
|
+
? `Engine ${selected[0].version}`
|
|
112
|
+
: `${selected.length} engine releases newer than ${pinned}, newest first (latest is ${latest})`;
|
|
113
|
+
out.push(head, '');
|
|
114
|
+
|
|
115
|
+
for (const r of selected) {
|
|
116
|
+
out.push(RULE);
|
|
117
|
+
out.push(`## ${r.version}`);
|
|
118
|
+
out.push('');
|
|
119
|
+
out.push(r.notes?.trim() ? r.notes.trim() : ' (no notes recorded — this release predates the changelog)');
|
|
120
|
+
out.push('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const breaking = selected.flatMap((r) => breakingLines(r.notes).map((l) => ` ${r.version} ${l}`));
|
|
124
|
+
if (breaking.length) {
|
|
125
|
+
out.push(RULE);
|
|
126
|
+
out.push('');
|
|
127
|
+
out.push(
|
|
128
|
+
`⚠ ${breaking.length} BREAKING change${breaking.length === 1 ? '' : 's'} in this range.`,
|
|
129
|
+
' These are the entries that mean code in THIS game has to change — check each one',
|
|
130
|
+
' against the game before you call the update done.',
|
|
131
|
+
'',
|
|
132
|
+
...breaking,
|
|
133
|
+
'',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return out.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function fetchReleases({ apiBase, fetchImpl = fetch }) {
|
|
140
|
+
const res = await fetchImpl(`${apiBase}/api/creator/engine`);
|
|
141
|
+
if (!res.ok) throw new Error(`could not read the engine changelog (HTTP ${res.status})`);
|
|
142
|
+
const body = await res.json();
|
|
143
|
+
// An older platform has no `releases` key. Printing "nothing new" there would
|
|
144
|
+
// be a lie in the most dangerous direction — it reads as "safe to update".
|
|
145
|
+
//
|
|
146
|
+
// Do NOT tell them to upgrade the CLI: the missing half is the PLATFORM's, and
|
|
147
|
+
// a creator cannot deploy it. Advice they can't act on is worse than none.
|
|
148
|
+
if (!Array.isArray(body.releases)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'this Looop platform is not serving the engine changelog yet. Nothing is wrong with your ' +
|
|
151
|
+
'game — try again later, and `npx looop update` still works in the meantime.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return body;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function changelog({
|
|
158
|
+
cwd = process.cwd(),
|
|
159
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
160
|
+
log = console.log,
|
|
161
|
+
fetchImpl = fetch,
|
|
162
|
+
readPin,
|
|
163
|
+
version,
|
|
164
|
+
all = false,
|
|
165
|
+
} = {}) {
|
|
166
|
+
const pinned = readPin ? readPin() : readEnginePin(findProject(cwd).dir);
|
|
167
|
+
const { releases, latest } = await fetchReleases({ apiBase, fetchImpl });
|
|
168
|
+
const selected = selectReleases(releases, { pinned, version, all });
|
|
169
|
+
log(renderReleases(selected, { pinned, latest }));
|
|
170
|
+
return { pinned, latest, selected };
|
|
171
|
+
}
|
|
@@ -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
|
@@ -15,6 +15,8 @@ import { reconcileAgentSurface } from './agent-surface.mjs';
|
|
|
15
15
|
import { getToken, getApiBase } from './config.mjs';
|
|
16
16
|
import { login } from './login.mjs';
|
|
17
17
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
18
|
+
import { compareVersions, renderReleases } from './changelog.mjs';
|
|
19
|
+
import { syncCli, reportCli } from './self-update.mjs';
|
|
18
20
|
|
|
19
21
|
// The report is the point. A creator reading this must be able to answer, with
|
|
20
22
|
// no further digging: what changed, was any of it mine, and what do I do now.
|
|
@@ -56,6 +58,7 @@ export async function update({
|
|
|
56
58
|
loginFn = login,
|
|
57
59
|
ensure = ensureEngine,
|
|
58
60
|
reconcile = reconcileAgentSurface,
|
|
61
|
+
syncCliFn = syncCli,
|
|
59
62
|
} = {}) {
|
|
60
63
|
const project = findProject(cwd);
|
|
61
64
|
const from = readEnginePin(project.dir);
|
|
@@ -72,9 +75,28 @@ export async function update({
|
|
|
72
75
|
});
|
|
73
76
|
if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `looop login` again.');
|
|
74
77
|
if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
|
|
75
|
-
const { latest } = await res.json();
|
|
78
|
+
const { latest, releases } = await res.json();
|
|
76
79
|
if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
|
|
77
80
|
|
|
81
|
+
// What the update is about to change UNDER the game (project note azlqm2).
|
|
82
|
+
// Reporting the skill files we rewrote and nothing about the engine was the
|
|
83
|
+
// whole gap: an agent crossed two versions of the shared library blind.
|
|
84
|
+
//
|
|
85
|
+
// A platform too old to serve `releases` must NOT block the update — it just
|
|
86
|
+
// has to be honest that it cannot say what changed. (`looop changelog`, whose
|
|
87
|
+
// only job is to answer that question, refuses outright instead.)
|
|
88
|
+
// Newest first, to match `looop changelog` and — more importantly — to match
|
|
89
|
+
// what renderReleases' own header SAYS. The endpoint returns them ascending;
|
|
90
|
+
// passing that straight through printed 0.1.13 above 0.1.14 under a heading
|
|
91
|
+
// that read "newest first". Caught by reading a real production run.
|
|
92
|
+
const crossed = Array.isArray(releases)
|
|
93
|
+
? releases
|
|
94
|
+
.filter(
|
|
95
|
+
(r) => compareVersions(r.version, latest) <= 0 && (!from || compareVersions(r.version, from) > 0),
|
|
96
|
+
)
|
|
97
|
+
.sort((a, b) => compareVersions(b.version, a.version))
|
|
98
|
+
: null;
|
|
99
|
+
|
|
78
100
|
let engineDir = null;
|
|
79
101
|
let updated = false;
|
|
80
102
|
|
|
@@ -102,9 +124,34 @@ export async function update({
|
|
|
102
124
|
const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
|
|
103
125
|
if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
|
|
104
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
|
+
|
|
105
145
|
if (updated) {
|
|
146
|
+
log('');
|
|
147
|
+
if (crossed === null) {
|
|
148
|
+
log(' This Looop platform could not tell us what changed in the engine — no changelog');
|
|
149
|
+
log(' is available for the versions you just crossed.');
|
|
150
|
+
} else if (crossed.length) {
|
|
151
|
+
log(renderReleases(crossed, { pinned: from, latest }));
|
|
152
|
+
}
|
|
106
153
|
log('');
|
|
107
154
|
log(' Republish (`npx looop publish`) when you want the live game on it.');
|
|
108
155
|
}
|
|
109
|
-
return { from, to: latest, updated, surface };
|
|
156
|
+
return { from, to: latest, updated, surface, crossed, cli };
|
|
110
157
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@looop-games/cli",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"looop": "bin/looop.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"CHANGELOG.md",
|
|
11
12
|
"bin",
|
|
12
13
|
"lib",
|
|
13
14
|
"!lib/**/*.test.mjs"
|