@looop-games/cli 0.1.33 → 0.1.35

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,19 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.35] - 2026-09-02
18
+
19
+ ### Added
20
+
21
+ - Reports landing in `.looop/agent-inbox/` are stamped with `engine`, the engine version `looop dev` was serving when the report was made — so a report read later, after an update, still says which engine produced the numbers.
22
+
23
+ ## [0.1.34] - 2026-09-02
24
+
25
+ ### Fixed
26
+
27
+ - `looop test` on Windows no longer discovers and runs the engine's own scaffold smokes from `node_modules` (or anything under `overrides/` and dot-folders). The gate could never go green there because those smokes always failed; it now runs only your game's tests, as on macOS and Linux. A test filter typed as `sub/depth` also matches on Windows now.
28
+ - `looop dev`, `looop test` and `looop publish` on Windows no longer fail with `Could not resolve "./entitiessaucer.js"` when a game has entity kinds: the generated room module now writes import paths with forward slashes whichever engine version produced them.
29
+
17
30
  ## [0.1.33] - 2026-08-27
18
31
 
19
32
  ### Added
@@ -69,9 +69,13 @@ const sha = (buf) => createHash('sha256').update(buf).digest('hex');
69
69
 
70
70
  // Where each artifact path lands in the game repo. `skills/**` is the whole
71
71
  // surface today; AGENTS.md is spliced rather than written whole.
72
+ // Targets are manifest keys, not host paths: forward slashes on every platform,
73
+ // because they are recorded in .looop/agent-surface.json, compared with
74
+ // `startsWith(`${dest}/`)`, and reported to the creator. `join(projectDir, dest)`
75
+ // turns one into a host path wherever the filesystem is touched.
72
76
  function target(rel) {
73
77
  if (rel === 'AGENTS.md') return 'AGENTS.md';
74
- if (rel.startsWith('skills/')) return join('.claude', rel);
78
+ if (rel.startsWith('skills/')) return `.claude/${rel}`;
75
79
  return null; // unknown surface entry from a newer engine — ignore, don't guess
76
80
  }
77
81
 
@@ -128,7 +132,7 @@ function contentMatchesRecord(abs, dest, placed) {
128
132
  if (!entries.length) return true; // an empty folder holds nothing to lose
129
133
  return entries.every((e) => {
130
134
  const file = join(e.parentPath, e.name);
131
- const rel = join(dest, relative(abs, file));
135
+ const rel = `${dest}/${relative(abs, file).split('\\').join('/')}`;
132
136
  return placed[rel] !== undefined && sha(readFileSync(file)) === placed[rel];
133
137
  });
134
138
  }
package/lib/dev.mjs CHANGED
@@ -146,6 +146,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
146
146
  slug: project.slug,
147
147
  projectDir: project.dir, // answers /__looop/whoami — see ports.mjs
148
148
  log, // agent-inbox report lines ride the dev logger
149
+ engineVersion: engine.version ?? null, // stamped on agent-inbox reports
149
150
  mounts: [
150
151
  { url: `/games/${project.slug}/`, dir: project.dir },
151
152
  { url: '/shared/', dir: generatedOverridesDir },
@@ -155,7 +155,19 @@ export function renderEntityComponents(components) {
155
155
  // generated entry to wrap. Kept as one function so the kind-aliasing, the
156
156
  // `./world.js` root convention, and the assemble→lower call can never drift
157
157
  // between the dev and publish builds.
158
- function renderV2Preamble(skeleton) {
158
+ // A relative path with forward slashes whichever host produced it.
159
+ const posixPath = (p) => p.split('\\').join('/');
160
+
161
+ function renderV2Preamble(raw) {
162
+ // Kind modules are game-relative paths the engine relativized on the
163
+ // creator's machine. An engine whose framework build predates the
164
+ // forward-slash fix emits them with the host separator, so on Windows they
165
+ // arrive as `entities\saucer.js`. Dropped raw into a string literal, `\s` is
166
+ // an escape sequence and esbuild looks for `./entitiessaucer.js`. The CLI and
167
+ // the engine ship separately, so a fixed CLI on an unfixed engine is a real
168
+ // pairing; import specifiers and the URLs the client's kind loader builds
169
+ // from the embedded skeleton are always forward-slash, so normalize both.
170
+ const skeleton = { ...raw, kinds: raw.kinds.map((k) => ({ ...k, module: posixPath(k.module) })) };
159
171
  const imports = skeleton.kinds.map(
160
172
  (k, i) => `import { ${k.name} as K${i} } from './${k.module}';`,
161
173
  );
@@ -113,6 +113,11 @@ export function createStaticServer({
113
113
  // import map + globalThis.__LOOOP_SKELETON__ so startGame() can boot. Null for
114
114
  // a v1 game (no bare `looop` import, no skeleton).
115
115
  skeleton = null,
116
+ // The engine version this server serves, stamped onto every agent-inbox
117
+ // file as `engine`. A report is read off disk later against whatever the
118
+ // game is pinned to THEN; the server is the only side that knows what it
119
+ // was when the report was made (the page has no version of its own).
120
+ engineVersion = null,
116
121
  }) {
117
122
  // The injected skeleton is LIVE, not frozen at startup: a v2 hot-reload
118
123
  // rebuilds the room with a fresh skeleton, and `setSkeleton` below pushes that
@@ -304,6 +309,7 @@ export function createStaticServer({
304
309
  mkdirSync(inboxDir, { recursive: true });
305
310
  const selfIgnore = join(inboxDir, '.gitignore');
306
311
  if (!existsSync(selfIgnore)) writeFileSync(selfIgnore, '*\n');
312
+ if (engineVersion) envelope.engine = engineVersion;
307
313
  const d = new Date();
308
314
  const p = (n, w = 2) => String(n).padStart(w, '0');
309
315
  const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}-${p(d.getMilliseconds(), 3)}`;
package/lib/test-cmd.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { spawn } from 'node:child_process';
11
11
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { createRequire } from 'node:module';
13
- import { join, relative, dirname } from 'node:path';
13
+ import nodePath, { join, relative, dirname } from 'node:path';
14
14
  import { pathToFileURL } from 'node:url';
15
15
  import { findProject, resolveEngine } from './project.mjs';
16
16
  import { dev } from './dev.mjs';
@@ -19,16 +19,31 @@ import { portsFor, portInUse } from './ports.mjs';
19
19
 
20
20
  const SKIP_DIRS = new Set(['node_modules', 'overrides']);
21
21
 
22
- export function discoverTestFiles(dir) {
22
+ // Whether a game-relative path lies under a directory discovery must ignore:
23
+ // dependencies (the engine ships its own scaffold smokes under node_modules),
24
+ // engine-override copies, and dot-directories. `path.relative()` returns
25
+ // backslash-separated paths on Windows, so the split accepts both separators —
26
+ // splitting on '/' alone yields one giant segment there and skips nothing.
27
+ export function isSkippedPath(rel) {
28
+ return rel.split(/[\\/]/).some((p) => SKIP_DIRS.has(p) || p.startsWith('.'));
29
+ }
30
+
31
+ // The game-relative path with forward slashes on every platform, so a filter
32
+ // the creator types as `sub/depth` matches on Windows too. The path module is
33
+ // injectable so the Windows form can be produced on any host.
34
+ export const posixRelative = (dir, file, p = nodePath) => p.relative(dir, file).split('\\').join('/');
35
+
36
+ // The path module is injectable for the same reason: with `path.win32` the
37
+ // real readdir → join → relative → skip chain runs with Windows semantics
38
+ // against real files, on any host.
39
+ export function discoverTestFiles(dir, { path: p = nodePath } = {}) {
23
40
  const unit = [];
24
41
  const smokes = [];
25
42
  const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
26
43
  for (const entry of entries) {
27
44
  if (!entry.isFile()) continue;
28
- const path = join(entry.parentPath, entry.name);
29
- const rel = relative(dir, path);
30
- const parts = rel.split('/');
31
- if (parts.some((p) => SKIP_DIRS.has(p) || p.startsWith('.'))) continue;
45
+ const path = p.join(entry.parentPath, entry.name);
46
+ if (isSkippedPath(p.relative(dir, path))) continue;
32
47
  if (entry.name.endsWith('.test.mjs')) unit.push(path);
33
48
  else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
34
49
  }
@@ -98,7 +113,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
98
113
  // milestone close and before publish (qa.md T3). With no pattern, everything
99
114
  // runs, exactly as before.
100
115
  const scoped = patterns.length > 0;
101
- const matches = (f) => patterns.some((p) => relative(project.dir, f).includes(p));
116
+ const matches = (f) => patterns.some((p) => posixRelative(project.dir, f).includes(p));
102
117
  const unit = scoped ? all.unit.filter(matches) : all.unit;
103
118
  const smokes = scoped ? all.smokes.filter(matches) : all.smokes;
104
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
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",