@looop-games/cli 0.1.10 → 0.1.12
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 +32 -0
- package/bin/looop.mjs +6 -0
- package/lib/create.mjs +10 -2
- package/lib/lint.mjs +125 -0
- package/lib/self-update.mjs +120 -0
- package/lib/test-cmd.mjs +12 -4
- package/lib/update.mjs +21 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,38 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.12] - 2026-07-12
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `looop lint` — check your game against the Looop rules: the mistakes that ship
|
|
21
|
+
a broken game while every test stays green. `looop lint --fix` applies the ones
|
|
22
|
+
that can be fixed mechanically.
|
|
23
|
+
- `looop test` now runs the lint first, before it starts a browser. A hardcoded
|
|
24
|
+
multiplayer host is reported in a second, instead of after a full smoke run
|
|
25
|
+
that passed for the wrong reason.
|
|
26
|
+
|
|
27
|
+
The rules themselves live in the engine (so `looop update` brings you new ones)
|
|
28
|
+
and your game's `eslint.config.js` imports the same file the gate runs — the
|
|
29
|
+
editor and the test can't disagree. Nothing changes for an existing game until
|
|
30
|
+
you install eslint (`npm i -D eslint`); new games get it out of the box.
|
|
31
|
+
|
|
32
|
+
## [0.1.11] - 2026-07-12
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- `looop update` now updates the **`looop` command itself**, not just the engine.
|
|
36
|
+
It used to leave the command at whatever version your game had pinned — so an
|
|
37
|
+
update could hand you an engine whose skills tell you to run a command your
|
|
38
|
+
`looop` was too old to have (`Unknown command: changelog`). It now moves both,
|
|
39
|
+
and says which versions it moved you between.
|
|
40
|
+
|
|
41
|
+
If you are seeing `Unknown command` today, you are on a `looop` from before
|
|
42
|
+
this fix and it cannot update itself. Run this once, and it will keep itself
|
|
43
|
+
current from then on:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm update @looop-games/cli
|
|
47
|
+
```
|
|
48
|
+
|
|
17
49
|
## [0.1.10] - 2026-07-12
|
|
18
50
|
|
|
19
51
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { login, whoami } from '../lib/login.mjs';
|
|
|
8
8
|
import { publish } from '../lib/publish.mjs';
|
|
9
9
|
import { create } from '../lib/create.mjs';
|
|
10
10
|
import { testCmd } from '../lib/test-cmd.mjs';
|
|
11
|
+
import { lintCmd } from '../lib/lint.mjs';
|
|
11
12
|
import { update } from '../lib/update.mjs';
|
|
12
13
|
import { changelog } from '../lib/changelog.mjs';
|
|
13
14
|
import { sendFeedback } from '../lib/feedback.mjs';
|
|
@@ -26,6 +27,7 @@ Usage:
|
|
|
26
27
|
looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
27
28
|
looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
28
29
|
looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
|
|
30
|
+
looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
|
|
29
31
|
looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
30
32
|
looop update Move this game to the latest engine release (re-pins looop.engine)
|
|
31
33
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
@@ -59,6 +61,10 @@ try {
|
|
|
59
61
|
const { ok } = await testCmd();
|
|
60
62
|
process.exit(ok ? 0 : 1);
|
|
61
63
|
}
|
|
64
|
+
case 'lint': {
|
|
65
|
+
const { ok } = await lintCmd({ fix: rest.includes('--fix') });
|
|
66
|
+
process.exit(ok ? 0 : 1);
|
|
67
|
+
}
|
|
62
68
|
case 'changelog':
|
|
63
69
|
await changelog({
|
|
64
70
|
version: rest.find((a) => !a.startsWith('--')),
|
package/lib/create.mjs
CHANGED
|
@@ -67,6 +67,14 @@ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
|
|
|
67
67
|
// what create-time provisioning exists to prevent.
|
|
68
68
|
const PLAYWRIGHT_SPEC = '^1.61.1';
|
|
69
69
|
|
|
70
|
+
// The Looop rules ship as an ESLint config (the scaffold's eslint.config.js,
|
|
71
|
+
// importing the rules from the installed engine), so they squiggle in the
|
|
72
|
+
// creator's editor as the code is written — a far tighter loop than a failing
|
|
73
|
+
// gate, and the whole reason this is ESLint and not a bespoke scanner. That
|
|
74
|
+
// only works if eslint is actually installed, so it is a scaffold
|
|
75
|
+
// devDependency, exactly like playwright. `looop test` also runs it.
|
|
76
|
+
const ESLINT_SPEC = '^10.7.0';
|
|
77
|
+
|
|
70
78
|
// Copy the starter game out of the artifact. One rename: `gitignore` →
|
|
71
79
|
// `.gitignore` (npm pack silently strips .gitignore files from a package, so
|
|
72
80
|
// the artifact stores it dot-less).
|
|
@@ -105,8 +113,8 @@ async function scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, rec
|
|
|
105
113
|
name,
|
|
106
114
|
private: true,
|
|
107
115
|
description: `A Looop game. Play at https://play.looop.games/g/${name}`,
|
|
108
|
-
scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test' },
|
|
109
|
-
devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC },
|
|
116
|
+
scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test', lint: 'looop lint' },
|
|
117
|
+
devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC, eslint: ESLINT_SPEC },
|
|
110
118
|
allowScripts: ALLOW_SCRIPTS,
|
|
111
119
|
},
|
|
112
120
|
null,
|
package/lib/lint.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `looop lint` — the hard rules, made executable.
|
|
2
|
+
//
|
|
3
|
+
// A "Never" that lives only in prose is a suggestion. The rules that have
|
|
4
|
+
// actually shipped broken games are enforced here: in the editor as you type
|
|
5
|
+
// (via the game's eslint.config.js) and in the gate (`looop test` runs this
|
|
6
|
+
// first).
|
|
7
|
+
//
|
|
8
|
+
// The rules themselves live in the ENGINE (`<engine>/eslint/`), not in this
|
|
9
|
+
// package — they are something the creator receives, so they have one authoring
|
|
10
|
+
// home and one release lane, and `looop update` brings a game the new ones. This
|
|
11
|
+
// command resolves them out of the game's installed engine, which is the very
|
|
12
|
+
// same file its eslint.config.js imports. One copy: the gate and the editor
|
|
13
|
+
// cannot disagree.
|
|
14
|
+
//
|
|
15
|
+
// Escape hatch: `// eslint-disable-next-line looop/<rule>` — the one every
|
|
16
|
+
// developer already knows. This is mandatory, not a nicety: creators are not
|
|
17
|
+
// developers, and a false positive must never wall someone out of their own
|
|
18
|
+
// test command with no way through.
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { join, relative } from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import { findProject, resolveEngine } from './project.mjs';
|
|
24
|
+
|
|
25
|
+
const CONFIG_NAMES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'];
|
|
26
|
+
|
|
27
|
+
// Resolve ESLint from the GAME's node_modules — same preflight shape as
|
|
28
|
+
// playwright in test-cmd.mjs. Games scaffolded before lint existed simply don't
|
|
29
|
+
// have it; they skip rather than break.
|
|
30
|
+
async function loadFromProject(projectDir) {
|
|
31
|
+
try {
|
|
32
|
+
const req = createRequire(join(projectDir, 'package.json'));
|
|
33
|
+
const mod = await import(pathToFileURL(req.resolve('eslint')).href);
|
|
34
|
+
return mod.ESLint ?? mod.default?.ESLint ?? null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The shareable config, out of the installed engine artifact.
|
|
41
|
+
async function loadRulesFromEngine(projectDir) {
|
|
42
|
+
let engineDir;
|
|
43
|
+
try {
|
|
44
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
45
|
+
} catch {
|
|
46
|
+
return null; // no engine installed yet — `looop dev` installs it
|
|
47
|
+
}
|
|
48
|
+
const entry = join(engineDir, 'eslint', 'index.js');
|
|
49
|
+
if (!existsSync(entry)) return null; // engine predates the rules
|
|
50
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
51
|
+
return mod.configs ?? mod.default?.configs ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function lintCmd({
|
|
55
|
+
cwd = process.cwd(),
|
|
56
|
+
fix = false,
|
|
57
|
+
log = console.log,
|
|
58
|
+
loadEslint = loadFromProject,
|
|
59
|
+
loadRules = loadRulesFromEngine,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const project = findProject(cwd);
|
|
62
|
+
|
|
63
|
+
const ESLint = await loadEslint(project.dir);
|
|
64
|
+
if (!ESLint) {
|
|
65
|
+
log('⚠️ looop lint: eslint is not installed in this game — skipping the rule checks.');
|
|
66
|
+
log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
|
|
67
|
+
log(' npm i -D eslint');
|
|
68
|
+
return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const configs = await loadRules(project.dir);
|
|
72
|
+
if (!configs) {
|
|
73
|
+
log('⚠️ looop lint: this engine ships no rules yet — skipping.');
|
|
74
|
+
log(' Get the latest engine (it carries them): looop update');
|
|
75
|
+
return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A game that ships its own eslint.config.js owns its rules — we do not
|
|
79
|
+
// override it (and the scaffold's config already spreads ours in). Otherwise
|
|
80
|
+
// lint with the engine's recommended set, so a repo created before lint
|
|
81
|
+
// existed still gets the checks with zero setup.
|
|
82
|
+
const hasOwnConfig = CONFIG_NAMES.some((n) => existsSync(join(project.dir, n)));
|
|
83
|
+
const eslint = new ESLint({
|
|
84
|
+
cwd: project.dir,
|
|
85
|
+
fix,
|
|
86
|
+
...(hasOwnConfig ? {} : { overrideConfigFile: true, baseConfig: configs.recommended }),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const results = await eslint.lintFiles(['.']);
|
|
90
|
+
if (fix) await ESLint.outputFixes(results);
|
|
91
|
+
|
|
92
|
+
let errorCount = 0;
|
|
93
|
+
let warningCount = 0;
|
|
94
|
+
for (const r of results) {
|
|
95
|
+
errorCount += r.errorCount;
|
|
96
|
+
warningCount += r.warningCount;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (errorCount || warningCount) {
|
|
100
|
+
// ESLint's own stylish formatter: the creator's agent (and their editor)
|
|
101
|
+
// already know how to read it.
|
|
102
|
+
const formatter = await eslint.loadFormatter('stylish');
|
|
103
|
+
const text = await formatter.format(results);
|
|
104
|
+
if (text.trim()) log(text.trim());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (errorCount === 0) {
|
|
108
|
+
log(`✅ looop lint: clean (${results.length} file(s))${fix ? ' — fixes applied' : ''}`);
|
|
109
|
+
} else {
|
|
110
|
+
log('');
|
|
111
|
+
log(`❌ looop lint: ${errorCount} error(s) in ${results.filter((r) => r.errorCount).length} file(s)`);
|
|
112
|
+
if (results.some((r) => r.messages.some((m) => m.fix))) {
|
|
113
|
+
log(' Some of these are auto-fixable: `looop lint --fix`');
|
|
114
|
+
}
|
|
115
|
+
log(' A rule is wrong about your code? `// eslint-disable-next-line <rule>` above the line.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: errorCount === 0,
|
|
120
|
+
skipped: false,
|
|
121
|
+
errorCount,
|
|
122
|
+
warningCount,
|
|
123
|
+
files: results.map((r) => relative(project.dir, r.filePath)),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -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/test-cmd.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { createRequire } from 'node:module';
|
|
|
13
13
|
import { join, relative, dirname } from 'node:path';
|
|
14
14
|
import { findProject } from './project.mjs';
|
|
15
15
|
import { dev } from './dev.mjs';
|
|
16
|
+
import { lintCmd } from './lint.mjs';
|
|
16
17
|
import { portsFor, portInUse } from './ports.mjs';
|
|
17
18
|
|
|
18
19
|
const SKIP_DIRS = new Set(['node_modules', 'overrides']);
|
|
@@ -56,17 +57,24 @@ async function freeTestPort(base = 8100) {
|
|
|
56
57
|
throw new Error('no free port triple found for the test dev stack');
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev } = {}) {
|
|
60
|
+
export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd } = {}) {
|
|
60
61
|
const project = findProject(cwd);
|
|
61
62
|
const { unit, smokes } = discoverTestFiles(project.dir);
|
|
62
63
|
|
|
64
|
+
// Lint FIRST — it needs no servers and no browser, and the defects it catches
|
|
65
|
+
// (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
|
|
66
|
+
// exactly the ones that make the suite below pass while testing nothing.
|
|
67
|
+
// Failing here does not skip the tests: the creator should see everything
|
|
68
|
+
// that is wrong in one run, not peel it one gate at a time.
|
|
69
|
+
const lint = await lintFn({ cwd: project.dir, log });
|
|
70
|
+
|
|
63
71
|
if (unit.length + smokes.length === 0) {
|
|
64
72
|
log(`No tests yet in ${project.dir} — nothing to run.`);
|
|
65
73
|
log('(Unit tests are *.test.mjs; browser smokes are *.smoke.mjs. Anything with an assertion is a regression test — keep it.)');
|
|
66
|
-
return { ok:
|
|
74
|
+
return { ok: lint.ok, ran: 0 };
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
let ok =
|
|
77
|
+
let ok = lint.ok;
|
|
70
78
|
|
|
71
79
|
// If looop test itself runs under a node --test parent, the inherited
|
|
72
80
|
// NODE_TEST_CONTEXT makes a nested `node --test` exit 0 even on failure —
|
|
@@ -130,5 +138,5 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
130
138
|
|
|
131
139
|
log('');
|
|
132
140
|
log(ok ? `✅ looop test: all green (${unit.length} unit file(s), ${smokes.length} smoke(s))` : '❌ looop test: failures above');
|
|
133
|
-
return { ok, ran: unit.length + smokes.length };
|
|
141
|
+
return { ok, ran: unit.length + smokes.length, lint };
|
|
134
142
|
}
|
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
|
}
|