@looop-games/cli 0.1.11 → 0.1.13
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 +33 -0
- package/bin/looop.mjs +6 -0
- package/lib/create.mjs +10 -2
- package/lib/lint.mjs +140 -0
- package/lib/test-cmd.mjs +12 -4
- package/lib/update.mjs +44 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,39 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.13] - 2026-07-12
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **`looop update` deleted your engine.** The engine is not an npm package — it
|
|
21
|
+
installs without a record in `package.json`, so *any* `npm install` in your game
|
|
22
|
+
treats it as junk and removes it. `looop update` then updated the `looop` command
|
|
23
|
+
itself with an npm install — **after** putting the engine in place — and wiped it
|
|
24
|
+
out every time. The update said it succeeded; `node_modules/@looop-games/engine`
|
|
25
|
+
was gone. Everything that reads the engine then failed, and re-running
|
|
26
|
+
`looop update` cheerfully said "already up to date" and fixed nothing.
|
|
27
|
+
|
|
28
|
+
The engine is now the last thing installed, and an update that finds it missing
|
|
29
|
+
puts it back (from the local cache — instant, no download). If you hit this, one
|
|
30
|
+
`npx looop update` repairs it.
|
|
31
|
+
- `looop lint` said "this engine ships no rules yet" when the real problem was that
|
|
32
|
+
the engine was not installed at all — sending you to `looop update`, the one
|
|
33
|
+
command that could not help. It now tells you which of the two it is.
|
|
34
|
+
|
|
35
|
+
## [0.1.12] - 2026-07-12
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- `looop lint` — check your game against the Looop rules: the mistakes that ship
|
|
39
|
+
a broken game while every test stays green. `looop lint --fix` applies the ones
|
|
40
|
+
that can be fixed mechanically.
|
|
41
|
+
- `looop test` now runs the lint first, before it starts a browser. A hardcoded
|
|
42
|
+
multiplayer host is reported in a second, instead of after a full smoke run
|
|
43
|
+
that passed for the wrong reason.
|
|
44
|
+
|
|
45
|
+
The rules themselves live in the engine (so `looop update` brings you new ones)
|
|
46
|
+
and your game's `eslint.config.js` imports the same file the gate runs — the
|
|
47
|
+
editor and the test can't disagree. Nothing changes for an existing game until
|
|
48
|
+
you install eslint (`npm i -D eslint`); new games get it out of the box.
|
|
49
|
+
|
|
17
50
|
## [0.1.11] - 2026-07-12
|
|
18
51
|
|
|
19
52
|
### Fixed
|
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,140 @@
|
|
|
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
|
+
//
|
|
42
|
+
// The two ways this can come up empty are DIFFERENT problems with different
|
|
43
|
+
// fixes, and conflating them sends people in a circle: the first version of this
|
|
44
|
+
// said "engine ships no rules — run looop update" when the engine was not
|
|
45
|
+
// installed at all, so `looop update` was the one command that could never help.
|
|
46
|
+
// Say which it is.
|
|
47
|
+
async function loadRulesFromEngine(projectDir) {
|
|
48
|
+
let engineDir;
|
|
49
|
+
try {
|
|
50
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
51
|
+
} catch {
|
|
52
|
+
return { configs: null, why: 'no-engine' };
|
|
53
|
+
}
|
|
54
|
+
const entry = join(engineDir, 'eslint', 'index.js');
|
|
55
|
+
if (!existsSync(entry)) return { configs: null, why: 'engine-too-old' };
|
|
56
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
57
|
+
return { configs: mod.configs ?? mod.default?.configs ?? null, why: 'ok' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function lintCmd({
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
fix = false,
|
|
63
|
+
log = console.log,
|
|
64
|
+
loadEslint = loadFromProject,
|
|
65
|
+
loadRules = loadRulesFromEngine,
|
|
66
|
+
} = {}) {
|
|
67
|
+
const project = findProject(cwd);
|
|
68
|
+
|
|
69
|
+
const ESLint = await loadEslint(project.dir);
|
|
70
|
+
if (!ESLint) {
|
|
71
|
+
log('⚠️ looop lint: eslint is not installed in this game — skipping the rule checks.');
|
|
72
|
+
log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
|
|
73
|
+
log(' npm i -D eslint');
|
|
74
|
+
return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { configs, why } = await loadRules(project.dir);
|
|
78
|
+
if (!configs) {
|
|
79
|
+
if (why === 'no-engine') {
|
|
80
|
+
// The engine is not on disk. Usually it was PRUNED: it installs with
|
|
81
|
+
// `npm install --no-save`, so npm has no record of it and any later
|
|
82
|
+
// `npm install` in this game deletes it. It is not lost — it is cached.
|
|
83
|
+
log('⚠️ looop lint: the Looop engine is not installed in this game — skipping.');
|
|
84
|
+
log(' (A plain `npm install` removes it; it is cached, so putting it back is instant.)');
|
|
85
|
+
log(' Restore it with: looop update');
|
|
86
|
+
} else {
|
|
87
|
+
log('⚠️ looop lint: your engine is older than the Looop rules — skipping.');
|
|
88
|
+
log(' Get an engine that carries them: looop update');
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A game that ships its own eslint.config.js owns its rules — we do not
|
|
94
|
+
// override it (and the scaffold's config already spreads ours in). Otherwise
|
|
95
|
+
// lint with the engine's recommended set, so a repo created before lint
|
|
96
|
+
// existed still gets the checks with zero setup.
|
|
97
|
+
const hasOwnConfig = CONFIG_NAMES.some((n) => existsSync(join(project.dir, n)));
|
|
98
|
+
const eslint = new ESLint({
|
|
99
|
+
cwd: project.dir,
|
|
100
|
+
fix,
|
|
101
|
+
...(hasOwnConfig ? {} : { overrideConfigFile: true, baseConfig: configs.recommended }),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const results = await eslint.lintFiles(['.']);
|
|
105
|
+
if (fix) await ESLint.outputFixes(results);
|
|
106
|
+
|
|
107
|
+
let errorCount = 0;
|
|
108
|
+
let warningCount = 0;
|
|
109
|
+
for (const r of results) {
|
|
110
|
+
errorCount += r.errorCount;
|
|
111
|
+
warningCount += r.warningCount;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (errorCount || warningCount) {
|
|
115
|
+
// ESLint's own stylish formatter: the creator's agent (and their editor)
|
|
116
|
+
// already know how to read it.
|
|
117
|
+
const formatter = await eslint.loadFormatter('stylish');
|
|
118
|
+
const text = await formatter.format(results);
|
|
119
|
+
if (text.trim()) log(text.trim());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (errorCount === 0) {
|
|
123
|
+
log(`✅ looop lint: clean (${results.length} file(s))${fix ? ' — fixes applied' : ''}`);
|
|
124
|
+
} else {
|
|
125
|
+
log('');
|
|
126
|
+
log(`❌ looop lint: ${errorCount} error(s) in ${results.filter((r) => r.errorCount).length} file(s)`);
|
|
127
|
+
if (results.some((r) => r.messages.some((m) => m.fix))) {
|
|
128
|
+
log(' Some of these are auto-fixable: `looop lint --fix`');
|
|
129
|
+
}
|
|
130
|
+
log(' A rule is wrong about your code? `// eslint-disable-next-line <rule>` above the line.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ok: errorCount === 0,
|
|
135
|
+
skipped: false,
|
|
136
|
+
errorCount,
|
|
137
|
+
warningCount,
|
|
138
|
+
files: results.map((r) => relative(project.dir, r.filePath)),
|
|
139
|
+
};
|
|
140
|
+
}
|
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
|
@@ -97,19 +97,52 @@ export async function update({
|
|
|
97
97
|
.sort((a, b) => compareVersions(b.version, a.version))
|
|
98
98
|
: null;
|
|
99
99
|
|
|
100
|
+
// ── The `looop` command FIRST, before the engine lands ─────────────────────
|
|
101
|
+
//
|
|
102
|
+
// Order is load-bearing, and getting it wrong deleted the engine. The engine is
|
|
103
|
+
// not an npm package — it installs with `npm install --no-save`, so npm has no
|
|
104
|
+
// record of it and treats it as extraneous. ANY later `npm install` in the game
|
|
105
|
+
// prunes it. syncCli runs exactly such an install (`--save-dev` the new CLI), so
|
|
106
|
+
// running it AFTER the engine install wiped the engine every single time: the
|
|
107
|
+
// update reported success and left node_modules/@looop-games/engine gone. The
|
|
108
|
+
// next `looop lint` then found no engine at all (caught on a real game).
|
|
109
|
+
//
|
|
110
|
+
// So: do every npm install that this command is going to do BEFORE the engine
|
|
111
|
+
// is put on disk, and let the engine be the last thing to land.
|
|
112
|
+
//
|
|
113
|
+
// Wrapped: syncCli is already fail-soft, and a bug in it must not stop the
|
|
114
|
+
// engine update that is the point of this command.
|
|
115
|
+
let cli;
|
|
116
|
+
try {
|
|
117
|
+
cli = await syncCliFn({ projectDir: project.dir, log });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
cli = { updated: false, error: err };
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
let engineDir = null;
|
|
101
123
|
let updated = false;
|
|
102
124
|
|
|
103
125
|
if (from === latest) {
|
|
104
|
-
log(`✅ Engine ${latest} — already up to date.`);
|
|
105
126
|
// Reconcile anyway. A repo can sit on the latest engine and STILL have an
|
|
106
127
|
// out-of-date surface: one scaffolded before this mechanism existed has
|
|
107
128
|
// never had its skills adopted, and would otherwise wait forever for a
|
|
108
129
|
// release it already has.
|
|
109
130
|
try {
|
|
110
131
|
engineDir = resolveEngine(project.dir).dir;
|
|
132
|
+
log(`✅ Engine ${latest} — already up to date.`);
|
|
111
133
|
} catch {
|
|
112
|
-
|
|
134
|
+
// Pinned to the latest, but NOT on disk. The pin is a claim about what this
|
|
135
|
+
// game runs; node_modules is the truth, and they disagree — because a plain
|
|
136
|
+
// `npm install` (the creator's own, or ours above) prunes the engine, which
|
|
137
|
+
// npm never recorded. "Already up to date" while the engine is missing is a
|
|
138
|
+
// lie that leaves every engine-reading command broken, and re-running update
|
|
139
|
+
// could never fix it. Put it back — from the local cache, so this is fast and
|
|
140
|
+
// works offline.
|
|
141
|
+
log(`Engine ${latest} is pinned but missing from node_modules — reinstalling it.`);
|
|
142
|
+
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
143
|
+
engineDir = engine.dir ?? null;
|
|
144
|
+
log('');
|
|
145
|
+
log(`✅ Engine ${latest} — restored.`);
|
|
113
146
|
}
|
|
114
147
|
} else {
|
|
115
148
|
// Rewrite the pin first; ensureEngine honors it (download → install → pin).
|
|
@@ -125,21 +158,18 @@ export async function update({
|
|
|
125
158
|
if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
|
|
126
159
|
|
|
127
160
|
// The third lane: the `looop` command itself (see self-update.mjs). The skills
|
|
128
|
-
// we
|
|
129
|
-
//
|
|
130
|
-
//
|
|
161
|
+
// we reconciled ship WITH the engine and can name any command they like — a real
|
|
162
|
+
// game ended up on an engine whose skills say `looop changelog` while its CLI was
|
|
163
|
+
// four versions too old to have it. So update moves this too.
|
|
131
164
|
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
cli = await syncCliFn({ projectDir: project.dir, log });
|
|
137
|
-
reportCli(log, cli);
|
|
138
|
-
} catch (err) {
|
|
139
|
-
cli = { updated: false, error: err };
|
|
165
|
+
// It already RAN, at the top: its npm install has to happen before the engine
|
|
166
|
+
// lands or it prunes it. This is only the report.
|
|
167
|
+
if (cli?.error) {
|
|
140
168
|
log('');
|
|
141
|
-
log(` The looop command could not be updated (${
|
|
169
|
+
log(` The looop command could not be updated (${cli.error.message}).`);
|
|
142
170
|
log(' Your engine and skills are up to date. Retry with: npm update @looop-games/cli');
|
|
171
|
+
} else {
|
|
172
|
+
reportCli(log, cli);
|
|
143
173
|
}
|
|
144
174
|
|
|
145
175
|
if (updated) {
|