@looop-games/cli 0.1.25 → 0.1.26

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,55 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.26] - 2026-08-07
18
+
19
+ ### Added
20
+
21
+ - `looop inspect [<id>]` — runs the dev stack and opens the asset inspector on
22
+ it, so you can look at and drive one model or sound at a time. Name an asset
23
+ to open straight onto it; `--no-open` prints the URL instead of launching a
24
+ browser.
25
+
26
+ - `looop dev` now prints the inspector's link when your game declares assets (it
27
+ has an `assets.js`). Not on every boot: a line that always appears is one
28
+ nobody reads by the third time.
29
+
30
+ - `LOOOP_ENGINE_DIR=<path>` runs against an engine **checkout** where it lies,
31
+ installing nothing — `dev`, `test`, `lint` and the automatic model re-bake all
32
+ resolve the same one, so what runs in the browser and what the gate checks
33
+ cannot be two different engines. Only useful if you are working on the engine
34
+ itself: `LOOOP_ENGINE_TARBALL` installs into your project, so trying a
35
+ work-in-progress engine used to mean deleting your game's working engine and
36
+ putting it back afterwards. This leaves your project exactly as it was. Point
37
+ it at an engine bundle (`packages/engine/dist`); a path that is not one fails
38
+ loudly rather than quietly falling back to the installed copy.
39
+
40
+ ### Fixed
41
+
42
+ - **`looop publish` no longer ships files your `.gitignore` excludes.** Build
43
+ output, scratch files and the screenshots a test run leaves behind were being
44
+ uploaded and served from your public game URL. If your `.gitignore` says a path
45
+ is not part of the game, publish now agrees with it. (A game folder that isn't
46
+ a git repo publishes exactly as before.)
47
+
48
+ - `looop inspect --port <n>` opened the inspector on an asset named after the
49
+ port number instead of on your game's first asset.
50
+
51
+ - The dev server no longer injects the game platform layer (the boot gate, the
52
+ loading curtain, the identity menu) into HTML pages that are not your game.
53
+ Only `/games/<slug>/` gets it, which is what happens in production — before
54
+ this, any other page the dev server served came up behind a curtain waiting
55
+ for an identity it had never asked for.
56
+
57
+ ### Changed
58
+
59
+ - The automatic model re-bake that `dev`, `test` and `publish` run now finds
60
+ your models in `assets.js` rather than by scanning `entities/**/entity.json`
61
+ for `.glb` strings — entities name a model by asset id, so there is no path
62
+ left in one to scan. It is also the more complete list: a model your game
63
+ loads from its own code is re-baked now too, where before only a model some
64
+ entity referenced was.
65
+
17
66
  ## [0.1.25] - 2026-08-01
18
67
 
19
68
  ### Fixed
package/bin/looop.mjs CHANGED
@@ -28,6 +28,7 @@ const HELP = `looop — build and run Looop games
28
28
  Usage:
29
29
  looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
30
30
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
31
+ looop inspect [<id>] Browse and drive your models, sounds and assets on their own
31
32
  looop lane <name> Open an isolated copy of the game to experiment in, safely
32
33
  looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
33
34
  looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
@@ -60,6 +61,23 @@ try {
60
61
  // Keep the process alive; servers + children hold the loop open.
61
62
  break;
62
63
  }
64
+ case 'inspect': {
65
+ // The same dev stack `looop dev` runs, opened on the inspector. Not a
66
+ // second server: the inspector is a page under /shared/, which this one
67
+ // already mounts.
68
+ const { inspectorUrl, openBrowser, itemArg } = await import('../lib/inspect.mjs');
69
+ const handle = await dev({ port: flag('port') ? Number(flag('port')) : undefined });
70
+ const target = inspectorUrl(handle.url, { item: itemArg(rest) });
71
+ console.log(`\n 🔎 Inspector → ${target}\n`);
72
+ if (!rest.includes('--no-open')) openBrowser(target);
73
+ const shutdown = () => {
74
+ handle.stop();
75
+ process.exit(0);
76
+ };
77
+ process.on('SIGINT', shutdown);
78
+ process.on('SIGTERM', shutdown);
79
+ break;
80
+ }
63
81
  case 'lane': {
64
82
  const name = rest.find((a) => !a.startsWith('--'));
65
83
  if (!name) throw new Error('Name the lane: looop lane <name> (e.g. looop lane judder)');
package/lib/dev.mjs CHANGED
@@ -20,6 +20,7 @@ import { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
20
20
  import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
21
21
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
22
  import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
23
+ import { hasInspectableAssets, inspectorUrl } from './inspect.mjs';
23
24
 
24
25
  // Resolve the partykit CLI entry from OUR dependencies (the game never
25
26
  // declares partykit; it rides @looop-games/cli). partykit's `exports` map hides
@@ -283,6 +284,12 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
283
284
  log(`✅ ${project.slug} ready. Open:`);
284
285
  log(` ${url}`);
285
286
  if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
287
+ // Only when this game declares assets (inspect.mjs). A line printed on every
288
+ // single boot is a line nobody reads by the third one, and the tool it points
289
+ // at is then invisible exactly when it would have helped.
290
+ if (hasInspectableAssets(project.dir)) {
291
+ log(` 🔍 ${inspectorUrl(url)} (your models, sounds and assets, one at a time)`);
292
+ }
286
293
  log('────────────────────────────────────────────────────────────');
287
294
 
288
295
  return { project, engine, ports, url, stop };
package/lib/engine.mjs CHANGED
@@ -67,8 +67,20 @@ export async function ensureEngine(
67
67
  loginFn = login,
68
68
  installTarball = npmInstallTarball,
69
69
  tarballOverride = process.env.LOOOP_ENGINE_TARBALL,
70
+ dirOverride = process.env.LOOOP_ENGINE_DIR,
70
71
  } = {},
71
72
  ) {
73
+ // Before the pin, before the installed copy, before the network: an explicit
74
+ // checkout is the most specific thing anyone can ask for.
75
+ if (dirOverride) {
76
+ // resolveEngine owns the override for EVERY tool (project.mjs) — this only
77
+ // announces it, so the banner and the lint can never disagree about which
78
+ // engine is in play.
79
+ const e = resolveEngine(projectDir);
80
+ log(`→ engine ${e.version} from LOOOP_ENGINE_DIR (${e.dir}) — nothing installed`);
81
+ return e;
82
+ }
83
+
72
84
  let pin = readEnginePin(projectDir);
73
85
 
74
86
  const installed = installedEngine(projectDir);
@@ -0,0 +1,71 @@
1
+ // `looop inspect` — the dev stack, opened on the asset inspector instead of the
2
+ // game.
3
+ //
4
+ // The inspector is a served page under /shared/, not a separate server: the dev
5
+ // server already mounts the engine's shared tree, so there is nothing new to
6
+ // start and nothing to keep in sync. That also means the inspector still works
7
+ // when the game itself is broken, which is when it is most wanted.
8
+ //
9
+ // It finds the game on its own — the page asks the dev server what it is serving
10
+ // (/__looop/whoami) — so the URL carries no slug.
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ export const INSPECTOR_PATH = '/shared/ui/inspector/';
17
+
18
+ // Built from the dev server's OWN url rather than an assumed localhost:8000: a
19
+ // lane runs on its own port base and 8000 may belong to somebody else's server,
20
+ // and an inspector opened on the wrong port inspects the wrong game — or none.
21
+ export function inspectorUrl(gameUrl, { item } = {}) {
22
+ const base = new URL(gameUrl);
23
+ const q = item ? `?_item=${encodeURIComponent(item)}` : '';
24
+ return `${base.origin}${INSPECTOR_PATH}${q}`;
25
+ }
26
+
27
+ // Does this game have anything for the inspector to show?
28
+ //
29
+ // The whole answer is `assets.js`, because the list IS the declaration — nothing
30
+ // walks the folder looking for files any more. So this is the same question as
31
+ // "did anyone declare anything", and it is asked for the dev banner: a line that
32
+ // appears on every boot is a line nobody reads by the third one, and the tool it
33
+ // points at is then invisible exactly when it would have helped.
34
+ //
35
+ // The file's CONTENTS are not read. A registry that declares nothing, or one
36
+ // that throws on import, is a case the inspector page itself reports far better
37
+ // than a banner line could — and reading it here would mean the dev server
38
+ // evaluating game code to decide how to print a URL.
39
+ export function hasInspectableAssets(projectDir) {
40
+ return !!projectDir && existsSync(join(projectDir, 'assets.js'));
41
+ }
42
+
43
+ // Flags that take a VALUE, so the value is not mistaken for the asset name.
44
+ // `looop inspect --port 8210` is two argv entries, and reading "the first
45
+ // argument that is not a flag" turns 8210 into the thing to open — on the one
46
+ // command whose entire job is opening the right asset.
47
+ const VALUED_FLAGS = new Set(['--port']);
48
+
49
+ export function itemArg(rest = []) {
50
+ for (let i = 0; i < rest.length; i += 1) {
51
+ const a = rest[i];
52
+ if (a.startsWith('--')) {
53
+ if (VALUED_FLAGS.has(a)) i += 1;
54
+ continue;
55
+ }
56
+ return a;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ export function openBrowser(url) {
62
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
63
+ try {
64
+ // Detached and fully ignored: a browser that outlives the CLI, and one that
65
+ // cannot hold the dev stack's stdio open if it decides to write to it.
66
+ spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
@@ -57,9 +57,10 @@
57
57
  // it twice.
58
58
 
59
59
  import { createHash } from 'node:crypto';
60
- import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
60
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
61
61
  import { join, resolve, basename, dirname } from 'node:path';
62
62
  import { pathToFileURL } from 'node:url';
63
+ import { resolveEngine } from './project.mjs';
63
64
 
64
65
  // Version stamped into artifacts. Bump when the bake OUTPUT changes shape or
65
66
  // meaning — it participates in the staleness hash, so old artifacts re-bake.
@@ -805,38 +806,58 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
805
806
  }
806
807
 
807
808
  // ── auto-bake: the staleness sweep dev/test/publish run ──────────────────────
808
- // The game's entity definitions say which models matter: any string field ending
809
- // in `.glb` inside entities/**/entity.json is a model reference. Model paths are
810
- // GAME-ROOT-relative — the same string is the presenter's fetch URL (the page
811
- // sits at the game root) and the room registry key so they resolve against the
812
- // game dir here too. Each referenced GLB whose artifacts are missing or stale
813
- // re-bakes. A bake failure is a real failure surfaced with the model named,
814
- // never swallowed: a stale artifact means the server shoots at a shape the
815
- // player no longer sees.
816
-
817
- export function referencedModels(gameDir) {
809
+ // The game's DECLARATION says which models matter: every `src` in `assets.js`
810
+ // that names a `.glb`. Paths there are GAME-ROOT-relative the same string is
811
+ // the presenter's fetch URL (the page sits at the game root) — so they resolve
812
+ // against the game dir here too. Each declared GLB whose artifacts are missing
813
+ // or stale re-bakes. A bake failure is a real failure, surfaced with the model
814
+ // named and never swallowed: a stale artifact means the server shoots at a shape
815
+ // the player no longer sees.
816
+ //
817
+ // Entity definitions are NOT scanned. A `rig-hitbox.model` is an asset id, so
818
+ // there is no path in an entity.json left to find — and the declaration is the
819
+ // better list anyway, because it also holds models a game loads from its own
820
+ // code, which the entity scan never saw.
821
+ //
822
+ // Read as TEXT rather than imported, by the engine's own reader
823
+ // (`shared/ui/assets/declared.js`) — the same one the entity lint uses, so the
824
+ // two gates cannot disagree about what a game declares. Its blind spot is a
825
+ // computed `src`, which mirrors the one the entity scan had for a computed model
826
+ // reference.
827
+
828
+ // The reader is passed IN rather than imported: it lives in the engine tree
829
+ // (which the CLI resolves at run time — in a creator's repo it sits under
830
+ // node_modules), and handing it over keeps this function a pure filter over a
831
+ // parse somebody else did.
832
+ export function referencedModels(gameDir, readDeclaration) {
833
+ const manifest = join(gameDir, 'assets.js');
834
+ if (!existsSync(manifest) || typeof readDeclaration !== 'function') return [];
835
+ let declared;
836
+ try {
837
+ declared = readDeclaration(readFileSync(manifest, 'utf8'));
838
+ } catch {
839
+ return []; // unreadable assets.js fails its own gate
840
+ }
818
841
  const found = new Set();
819
- const scan = (dir) => {
820
- if (!existsSync(dir)) return;
821
- for (const e of readdirSync(dir)) {
822
- const p = join(dir, e);
823
- const st = statSync(p);
824
- if (st.isDirectory()) { if (e !== 'node_modules') scan(p); continue; }
825
- if (!/entity\.json$/.test(e)) continue;
826
- const walk = (v) => {
827
- if (typeof v === 'string') { if (/\.glb$/i.test(v)) found.add(resolve(gameDir, v)); }
828
- else if (Array.isArray(v)) v.forEach(walk);
829
- else if (v && typeof v === 'object') Object.values(v).forEach(walk);
830
- };
831
- try { walk(JSON.parse(readFileSync(p, 'utf8'))); } catch { /* a broken entity.json fails its own gate */ }
832
- }
833
- };
834
- scan(join(gameDir, 'entities'));
842
+ for (const src of declared.sources.values()) {
843
+ if (/\.glb$/i.test(src)) found.add(resolve(gameDir, src));
844
+ }
835
845
  return [...found].filter(existsSync);
836
846
  }
837
847
 
848
+ // The engine's declaration reader, or null when there is no engine to read with.
849
+ async function declarationReader(gameDir) {
850
+ try {
851
+ const { dir } = resolveEngine(gameDir);
852
+ const mod = await import(pathToFileURL(join(dir, 'shared', 'ui', 'assets', 'declared.js')).href);
853
+ return mod.readDeclaration;
854
+ } catch {
855
+ return null;
856
+ }
857
+ }
858
+
838
859
  export async function ensureBaked({ dir = process.cwd(), log = console.log } = {}) {
839
- const models = referencedModels(dir);
860
+ const models = referencedModels(dir, await declarationReader(dir));
840
861
  const baked = [];
841
862
  for (const glb of models) {
842
863
  if (!isStale(glb)) continue;
package/lib/project.mjs CHANGED
@@ -32,7 +32,38 @@ export function findProject(startDir = process.cwd()) {
32
32
  }
33
33
  }
34
34
 
35
+ // An engine CHECKOUT, used where it lies — the lane for working on the engine
36
+ // itself against a real game. Dev-only, and env-gated rather than a flag so it
37
+ // cannot be left switched on in a repo.
38
+ //
39
+ // Every tool resolves the engine through here, which is the point: the dev
40
+ // server serving one engine while `looop lint` and the auto-bake sweep check a
41
+ // different one is a false green of the worst kind — the browser runs the new
42
+ // code, the gate passes on the old, and nothing on screen says so.
43
+ //
44
+ // It does NOT fall back to the installed copy when the path is wrong. A silent
45
+ // fallback is how the override became invisible in the first place.
46
+ function engineCheckout(dir) {
47
+ if (!existsSync(join(dir, 'package.json')) || !existsSync(join(dir, 'shared'))) {
48
+ throw new Error(
49
+ `LOOOP_ENGINE_DIR=${dir} is not an engine build.\n` +
50
+ 'Expected a package.json and a shared/ directory in it — point at an engine ' +
51
+ "bundle (packages/engine/dist after `node packages/engine/build.mjs`), not at the repo root.",
52
+ );
53
+ }
54
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
55
+ return {
56
+ dir,
57
+ // A checkout carries no cut version; only a release does.
58
+ version: pkg.version ?? 'working tree',
59
+ sharedDir: join(dir, 'shared'),
60
+ roomServerDir: join(dir, 'room-server'),
61
+ };
62
+ }
63
+
35
64
  export function resolveEngine(projectDir) {
65
+ const override = process.env.LOOOP_ENGINE_DIR;
66
+ if (override) return engineCheckout(resolve(override));
36
67
  const dir = join(projectDir, 'node_modules', '@looop-games', 'engine');
37
68
  const pkgPath = join(dir, 'package.json');
38
69
  if (!existsSync(pkgPath)) {
package/lib/publish.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  // /api/publish stays behind the builder's Access wall). The endpoint
11
11
  // requires the creator token; without one it 401s with a login hint.
12
12
  import { createHash } from 'node:crypto';
13
+ import { execFileSync } from 'node:child_process';
13
14
  import { readdirSync, readFileSync, statSync } from 'node:fs';
14
15
  import { join, relative } from 'node:path';
15
16
  import { findProject } from './project.mjs';
@@ -32,6 +33,12 @@ export const PLAY_BASE = 'https://play.looop.games';
32
33
  // Never shipped: tooling, VCS, agent workspace, notes + handbook + agent
33
34
  // instructions (repo knowledge travels with the repo, not the catalog — a
34
35
  // published game must not expose it on a public URL), tests/smokes.
36
+ //
37
+ // This list is the floor, not the whole rule: whatever the game's own
38
+ // .gitignore excludes is dropped as well (gitIgnored below). Name-based lists
39
+ // can only ever cover the names somebody thought of, and the folder that
40
+ // prompted this was `screenshots/` — dev captures a test run had left behind,
41
+ // gitignored by the creator and published to their public URL regardless.
35
42
  const SKIP_DIRS = new Set(['node_modules', 'notes', 'handbook', '.git', '.looop', '.claude', '__pycache__']);
36
43
  const SKIP_FILES = [
37
44
  /^package(-lock)?\.json$/,
@@ -41,8 +48,38 @@ const SKIP_FILES = [
41
48
  /^(AGENTS|CLAUDE|GEMINI)\.md$/,
42
49
  ];
43
50
 
51
+ // What the creator's own .gitignore excludes, out of a list of candidate paths.
52
+ //
53
+ // A gitignored path is the creator saying, in the file they already maintain for
54
+ // exactly this, that it is not part of the game — build output, scratch, and the
55
+ // screenshots a test run left behind. Publishing it puts it on a public URL
56
+ // under their name, which is nobody's intent.
57
+ //
58
+ // One `git check-ignore` call for the whole list rather than one per file, and
59
+ // NUL-delimited so a path with a space or a newline in it survives. A folder
60
+ // that is not a git repo (or a machine with no git) answers nothing and the
61
+ // list goes through untouched — this can only ever REMOVE files, so failing
62
+ // open is the safe direction.
63
+ export function gitIgnored(dir, rels) {
64
+ if (!rels.length) return new Set();
65
+ try {
66
+ const out = execFileSync('git', ['check-ignore', '--stdin', '-z'], {
67
+ cwd: dir,
68
+ input: `${rels.join('\0')}\0`,
69
+ encoding: 'utf8',
70
+ stdio: ['pipe', 'pipe', 'ignore'],
71
+ });
72
+ return new Set(out.split('\0').filter(Boolean));
73
+ } catch (err) {
74
+ // Exit 1 means "nothing on the list is ignored" and is not an error; git
75
+ // still wrote an empty stdout. Anything else (no git, not a repo) lands here
76
+ // too, and the answer is the same: exclude nothing.
77
+ return new Set((err?.stdout ?? '').split('\0').filter(Boolean));
78
+ }
79
+ }
80
+
44
81
  export function collectGameFiles(dir) {
45
- const out = new Map(); // relative path → Buffer
82
+ const found = new Map(); // relative path → absolute path
46
83
  const walk = (d) => {
47
84
  for (const name of readdirSync(d)) {
48
85
  const p = join(d, name);
@@ -52,11 +89,18 @@ export function collectGameFiles(dir) {
52
89
  walk(p);
53
90
  } else {
54
91
  if (name.startsWith('.') || SKIP_FILES.some((re) => re.test(name))) continue;
55
- out.set(rel, readFileSync(p));
92
+ found.set(rel, p);
56
93
  }
57
94
  }
58
95
  };
59
96
  walk(dir);
97
+
98
+ const ignored = gitIgnored(dir, [...found.keys()]);
99
+ const out = new Map(); // relative path → Buffer
100
+ for (const [rel, p] of found) {
101
+ if (ignored.has(rel)) continue;
102
+ out.set(rel, readFileSync(p));
103
+ }
60
104
  return out;
61
105
  }
62
106
 
@@ -153,8 +153,15 @@ export function createStaticServer({
153
153
  // Mirror the production /g/<slug> entry injection — only when the resolved
154
154
  // engine actually ships the platform layer (transition shim, same as
155
155
  // dev_server.py _engine_has_platform).
156
- if (resolveUrl(PLATFORM_URL)) {
157
- const m = /^\/games\/([^/]+)\//.exec(urlPath);
156
+ //
157
+ // Only a GAME entry is injected, which is what production does: /g/<slug> is
158
+ // the only page that gets the platform layer there. Injecting it into every
159
+ // HTML file the dev server happens to serve puts the boot gate, the loading
160
+ // curtain and the M-key menu on top of pages that are not games and cannot
161
+ // satisfy them — the shared inspector page (/shared/ui/inspector/) renders
162
+ // its stage underneath a curtain waiting for an identity it never asked for.
163
+ const m = /^\/games\/([^/]+)\//.exec(urlPath);
164
+ if (m && resolveUrl(PLATFORM_URL)) {
158
165
  // ?as=<name>: a distinct dev identity for this tab. The room dedups
159
166
  // same-account connections even for unverified dev claims, so two tabs
160
167
  // as the constant dev identity evict each other — ?as= is how a human
@@ -164,7 +171,7 @@ export function createStaticServer({
164
171
  const tabIdentity = as
165
172
  ? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
166
173
  : identity;
167
- html = injectHeadTags(html, m ? m[1] : null, tabIdentity ? { identity: tabIdentity } : {});
174
+ html = injectHeadTags(html, m[1], tabIdentity ? { identity: tabIdentity } : {});
168
175
  }
169
176
  if (injectReload) {
170
177
  html = html.includes('</body>') ? html.replace('</body>', RELOAD_CLIENT + '</body>') : html + RELOAD_CLIENT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
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",