@looop-games/cli 0.1.32 → 0.1.34

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,27 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.34] - 2026-09-02
18
+
19
+ ### Fixed
20
+
21
+ - `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.
22
+ - `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.
23
+
24
+ ## [0.1.33] - 2026-08-27
25
+
26
+ ### Added
27
+ - **The dev server now carries an agent inbox** — `POST /__looop/agent-inbox`.
28
+ Dev tools in the running game (starting with the Performance tool's "Send to
29
+ agent" button) can ship a JSON report out of the browser; it lands as one
30
+ file under your game's `.looop/agent-inbox/` and prints one line in the
31
+ `looop dev` terminal. That folder is where your agent looks when you send it
32
+ something from inside the game — it ignores itself in git, so reports never
33
+ clutter `git status`. Only pages served by your own dev server can write to
34
+ it (cross-origin posts from other websites are refused), and single reports
35
+ are capped at 2 MB. There's no cleanup/rotation yet — reports are small and
36
+ yours to delete.
37
+
17
38
  ## [0.1.32] - 2026-08-23
18
39
 
19
40
  ### Fixed
package/lib/dev.mjs CHANGED
@@ -145,6 +145,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
145
145
  const staticServer = createStaticServer({
146
146
  slug: project.slug,
147
147
  projectDir: project.dir, // answers /__looop/whoami — see ports.mjs
148
+ log, // agent-inbox report lines ride the dev logger
148
149
  mounts: [
149
150
  { url: `/games/${project.slug}/`, dir: project.dir },
150
151
  { 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
  );
@@ -10,7 +10,7 @@
10
10
  // overrides dir mounts at /shared/ ahead of the bundle.
11
11
  import http from 'node:http';
12
12
  import { EventEmitter } from 'node:events';
13
- import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
13
+ import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
14
14
  import { extname, join, normalize, sep } from 'node:path';
15
15
  import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL, TOOLBOX_URL } from './inject.mjs';
16
16
  import { WHOAMI_PATH } from './ports.mjs';
@@ -34,6 +34,12 @@ const RELOAD_CLIENT = `<script>
34
34
 
35
35
  const WATCH_EXTS = new Set(['.html', '.css', '.js', '.mjs', '.json', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp']);
36
36
 
37
+ // The toolbox → agent report channel (see handleAgentInbox below). The path is
38
+ // a contract with the toolbox shell's ctx.toAgent(); the cap keeps a runaway
39
+ // payload from filling the disk — reports are small JSON summaries.
40
+ export const AGENT_INBOX_PATH = '/__looop/agent-inbox';
41
+ const AGENT_INBOX_MAX_BYTES = 2 * 1024 * 1024;
42
+
37
43
  const MIME = {
38
44
  '.html': 'text/html; charset=utf-8',
39
45
  '.js': 'text/javascript; charset=utf-8',
@@ -102,6 +108,7 @@ export function createStaticServer({
102
108
  injectReload = true,
103
109
  watchIntervalMs = 400,
104
110
  identity,
111
+ log = console.log,
105
112
  // A framework-v2 game's lowered skeleton — injected into the entry HTML as an
106
113
  // import map + globalThis.__LOOOP_SKELETON__ so startGame() can boot. Null for
107
114
  // a v1 game (no bare `looop` import, no skeleton).
@@ -234,10 +241,91 @@ export function createStaticServer({
234
241
  });
235
242
  }
236
243
 
244
+ // The agent inbox — the write half of the toolbox → agent channel (project
245
+ // note nf7zwa). A dev tool in the running game POSTs an envelope
246
+ // ({ tool, at, payload }, stamped by the toolbox shell); it lands as ONE file
247
+ // in <projectDir>/.looop/agent-inbox/ and prints one summary line. The file
248
+ // is the load-bearing sink: the agent working on this game usually does NOT
249
+ // hold the dev-server terminal, so stdout alone would be invisible to it.
250
+ // The mailbox dir ignores itself (a `.gitignore` of `*` inside it), so
251
+ // reports never show up in the creator's `git status` whatever repo shape
252
+ // the game has.
253
+ function handleAgentInbox(req, res) {
254
+ if (req.method !== 'POST') {
255
+ res.writeHead(405, { 'Content-Type': 'text/plain', Allow: 'POST' });
256
+ return res.end('POST only');
257
+ }
258
+ // Only the page this server itself served may write. A hostile website in
259
+ // the creator's browser can fire a no-preflight POST at localhost (a CORS
260
+ // "simple" content-type skips the preflight, and CORS only gates reading
261
+ // the response, never the server-side write) — and these files are later
262
+ // read by an AI agent as trusted tool reports, so a foreign page must not
263
+ // be able to author them. Browsers send Origin on every POST; the real
264
+ // client is served from this server, so its Origin equals our host.
265
+ // Non-browser callers (curl, node) send no Origin and pass — same trust
266
+ // level as anything else already running on the creator's machine/LAN.
267
+ const origin = req.headers.origin;
268
+ if (origin && origin !== `http://${req.headers.host}`) {
269
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
270
+ return res.end('cross-origin post rejected');
271
+ }
272
+ const chunks = [];
273
+ let size = 0;
274
+ let overflowed = false;
275
+ req.on('data', (chunk) => {
276
+ if (overflowed) return; // draining the rest so the client can read the 413
277
+ size += chunk.length;
278
+ if (size > AGENT_INBOX_MAX_BYTES) {
279
+ overflowed = true;
280
+ chunks.length = 0;
281
+ res.writeHead(413, { 'Content-Type': 'text/plain', Connection: 'close' });
282
+ res.end(`report too large (max ${AGENT_INBOX_MAX_BYTES} bytes)`);
283
+ return;
284
+ }
285
+ chunks.push(chunk);
286
+ });
287
+ req.on('end', () => {
288
+ if (overflowed) return;
289
+ let envelope;
290
+ try {
291
+ envelope = JSON.parse(Buffer.concat(chunks).toString('utf8'));
292
+ } catch {
293
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
294
+ return res.end('body must be JSON');
295
+ }
296
+ // The tool id names the file — it must be a plain slug, never a path.
297
+ const tool = envelope?.tool;
298
+ if (typeof tool !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/i.test(tool)) {
299
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
300
+ return res.end('envelope needs a tool id ([a-z0-9-])');
301
+ }
302
+ try {
303
+ const inboxDir = join(projectDir, '.looop', 'agent-inbox');
304
+ mkdirSync(inboxDir, { recursive: true });
305
+ const selfIgnore = join(inboxDir, '.gitignore');
306
+ if (!existsSync(selfIgnore)) writeFileSync(selfIgnore, '*\n');
307
+ const d = new Date();
308
+ const p = (n, w = 2) => String(n).padStart(w, '0');
309
+ const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}-${p(d.getMilliseconds(), 3)}`;
310
+ let file = join(inboxDir, `${stamp}-${tool}.json`);
311
+ for (let n = 2; existsSync(file); n += 1) file = join(inboxDir, `${stamp}-${tool}-${n}.json`);
312
+ writeFileSync(file, JSON.stringify(envelope, null, 2) + '\n');
313
+ log(`agent-inbox: ${tool} (${(size / 1024).toFixed(1)} KB) → ${file}`);
314
+ sendBody(res, 200, JSON.stringify({ ok: true, file }), 'application/json');
315
+ } catch (err) {
316
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
317
+ res.end(`agent-inbox write failed: ${err?.message ?? err}`);
318
+ }
319
+ });
320
+ }
321
+
237
322
  const server = http.createServer((req, res) => {
238
323
  const reqUrl = new URL(req.url, 'http://x');
239
324
  const urlPath = reqUrl.pathname;
240
325
  if (urlPath === '/__reload') return handleSse(res);
326
+ // Without a projectDir there is nowhere to land a report — the route
327
+ // simply doesn't exist (falls through to 404), same as any unknown path.
328
+ if (urlPath === AGENT_INBOX_PATH && projectDir) return handleAgentInbox(req, res);
241
329
  // Ownership probe (ports.mjs): "whose dev server is this?". It is what lets
242
330
  // another `looop dev` — a lane, another game — tell OUR stale server (kill
243
331
  // and reclaim) from a live one that belongs to somebody else (step around,
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.32",
3
+ "version": "0.1.34",
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",