@looop-games/cli 0.1.3 → 0.1.5

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/bin/looop.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // looop — the Looop game-development CLI (project note cuqfzo).
2
+ // looop — the Looop game-development CLI.
3
3
  //
4
4
  // Runs inside a standalone game folder (its own repo). The agent is the
5
5
  // primary user: keep output plain, actionable, and machine-legible.
@@ -1,5 +1,5 @@
1
- // cuqfzo Phase 2 / Option B — Slice 2: bundle a server-backed game's primitives
2
- // on the CREATOR's machine, with the engine marked external.
1
+ // Bundle a server-backed game's primitives on the CREATOR's machine, with the
2
+ // engine marked external.
3
3
  //
4
4
  // The publish endpoint is a Pages Function — a Worker with no filesystem and no
5
5
  // npm — so it cannot run a bundler. But real server primitives import npm
package/lib/create.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `looop create <name>` — bootstrap a standalone Looop game (cuqfzo Slice 4).
1
+ // `looop create <name>` — bootstrap a standalone Looop game.
2
2
  //
3
3
  // The create-next-app gesture: one command → a complete folder that is its
4
4
  // own repo, where `looop dev` immediately serves a WORKING multiplayer game
@@ -35,7 +35,11 @@ const NAME_OK = /^[a-z0-9][a-z0-9-]{0,40}$/;
35
35
  // esbuild/workerd pins between releases, and a version-pinned approval would
36
36
  // quietly stop covering them on the next bump — the same silent breakage,
37
37
  // later. Caught by our first Windows creator, 2026-07-11.
38
- const ALLOW_SCRIPTS = { esbuild: true, workerd: true };
38
+ // fsevents is macOS-only (an optional dep of the file watchers) — it never
39
+ // installs on Windows, so the entry is simply unused there. Approving it
40
+ // keeps a Mac creator's first install warning-free, and under npm 12 keeps
41
+ // the watcher on native FSEvents instead of silently degrading to polling.
42
+ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
39
43
 
40
44
  // Smokes drive a real browser, so playwright ships in every scaffold's
41
45
  // devDependencies and the Chromium binary is fetched at create time — a
package/lib/dev.mjs CHANGED
@@ -1,5 +1,4 @@
1
- // `looop dev` — the standalone three-service dev stack (decision Q3 in
2
- // cuqfzo), same shape the monorepo's dev.sh proved:
1
+ // `looop dev` — the standalone three-service dev stack:
3
2
  //
4
3
  // static :8000 game folder at /games/<slug>/, /shared/ → engine bundle,
5
4
  // head injection + auto-reload
@@ -37,12 +36,11 @@ export function partykitBin() {
37
36
  }
38
37
  }
39
38
 
40
- // Per-game server parity (cuqfzo Phase 4): a game shipping its own
39
+ // Per-game server parity: a game shipping its own
41
40
  // `partykit.json` runs its OWN server in dev — partykit gets the game folder
42
41
  // as cwd, resolving the game's `main` entry (which imports the room server
43
42
  // from the installed engine). Without one, the bundle's shared room server
44
- // runs, exactly as before. Mirrors the monorepo dev.sh detection so a
45
- // vendor-flipped game behaves identically standalone. A config whose `main`
43
+ // runs, exactly as before. A config whose `main`
46
44
  // doesn't exist is ignored LOUDLY — a dead file must not take dev's
47
45
  // multiplayer down with it.
48
46
  export function partykitCwdFor(project, engine, warn = console.warn) {
package/lib/feedback.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // `looop feedback` — send the reports under notes/feedback/ to the Looop team
2
- // (cuqfzo; the transport behind the template's /feedback skill).
2
+ // (the transport behind the template's /feedback skill).
3
3
  //
4
4
  // Target: /api/creator/feedback — the token-authenticated intake beside
5
5
  // creator/publish. Each unsent notes/feedback/*.md ships VERBATIM as the
package/lib/inject.mjs CHANGED
@@ -1,5 +1,4 @@
1
- // HTML/JS dev-serving transforms — a Node port of the proven logic in
2
- // looop-core tools/game/dev_server.py, kept behaviour-identical:
1
+ // HTML/JS dev-serving transforms:
3
2
  //
4
3
  // * Head injection mirrors the production /g/<slug> serve path
5
4
  // (builder/functions/g/[[path]].ts): window.GAME_SLUG + window.LOOOP_IDENTITY
package/lib/llm-shim.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Local platform-services shim (:8788) — decision Q3b in cuqfzo.
1
+ // Local platform-services shim (:8788).
2
2
  //
3
3
  // Thin auth/CORS plumbing between a game running on localhost and the
4
4
  // platform's services API (LLM today; whatever comes next). It forwards
package/lib/login.mjs CHANGED
@@ -1,5 +1,4 @@
1
- // `looop login` — device-flow authentication (cuqfzo Slice 1, decisions
2
- // Q2/Q6, the `gh auth login` shape):
1
+ // `looop login` — device-flow authentication (the `gh auth login` shape):
3
2
  //
4
3
  // 1. ask the platform for a pairing (device_code for us, user_code for the
5
4
  // human),
package/lib/ports.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Port conventions + takeover, mirroring looop-core tools/game/dev.sh.
1
+ // Port conventions + takeover.
2
2
  //
3
3
  // Canonical ports: static :8000, partykit :1999, services shim :8788. The
4
4
  // engine's browser clients derive the other two from location.port
package/lib/publish.mjs CHANGED
@@ -1,9 +1,9 @@
1
- // `looop publish` — ship the game to play.looop.games (cuqfzo Slice 2, Q1).
1
+ // `looop publish` — ship the game to play.looop.games.
2
2
  //
3
3
  // Server-resolved: we send ONLY the game's own files plus the engine version
4
4
  // the installed bundle declares; the platform resolves /shared/... from its
5
- // release registry. Compare catalog_publish.py, which staged the whole shared
6
- // library client-side — that trust hole is what this replaces.
5
+ // release registry. The client never stages the shared library itself — that
6
+ // trust hole is what this design replaces.
7
7
  //
8
8
  // Target: /api/creator/publish — the token-authenticated creator lane, the
9
9
  // one publish route reachable on the public play host (the legacy
@@ -21,9 +21,8 @@ import { bundlePrimitives, PRIMITIVES_ENTRY } from './bundle-primitives.mjs';
21
21
  export const PLAY_BASE = 'https://play.looop.games';
22
22
 
23
23
  // Never shipped: tooling, VCS, agent workspace, notes + handbook + agent
24
- // instructions (repo knowledge travels with the repo, not the catalog — same
25
- // rule as the monorepo publisher; a published game must not expose it on a
26
- // public URL), tests/smokes.
24
+ // instructions (repo knowledge travels with the repo, not the catalog — a
25
+ // published game must not expose it on a public URL), tests/smokes.
27
26
  const SKIP_DIRS = new Set(['node_modules', 'notes', 'handbook', '.git', '.looop', '.claude', '__pycache__']);
28
27
  const SKIP_FILES = [
29
28
  /^package(-lock)?\.json$/,
@@ -69,7 +68,7 @@ export async function publish({
69
68
  throw new Error('this game has no index.html at its root — publish needs an entry page.');
70
69
  }
71
70
 
72
- // Server-backed game (cuqfzo Phase 2 / Option B, Slice 2): its primitives can
71
+ // Server-backed game: its primitives can
73
72
  // import npm packages + WASM the platform's bundler-less endpoint can't
74
73
  // resolve, so we bundle them HERE (engine marked external) and send the
75
74
  // single self-contained module in place of the raw barrel. The endpoint
@@ -1,6 +1,5 @@
1
- // The standalone dev static server — Node port of looop-core
2
- // tools/game/dev_server.py, serving a single game + the installed engine
3
- // bundle behind the same URL shape production uses:
1
+ // The standalone dev static server — serves a single game + the installed
2
+ // engine bundle behind the same URL shape production uses:
4
3
  //
5
4
  // /games/<slug>/… → the game folder
6
5
  // /shared/… → the engine bundle's shared/ tree
package/lib/test-cmd.mjs CHANGED
@@ -1,5 +1,4 @@
1
- // `looop test` — run the game's own verification gate (cuqfzo creator-harness
2
- // Slice 1; the successor to the monorepo's `gamedev <name> test`).
1
+ // `looop test` — run the game's own verification gate.
3
2
  //
4
3
  // Discovers the game's test files — `*.test.mjs` (unit, run under
5
4
  // `node --test`) and `*.smoke.mjs` (integration, run against a REAL dev
@@ -115,8 +114,8 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
115
114
  env: {
116
115
  ...env,
117
116
  LOOOP_TEST_GAME_URL: handle.url,
118
- // The monorepo's smoke convention exported too, so a game (and
119
- // its smokes) moving out of looop-games ports without edits.
117
+ // A legacy alias for the same URL, exported so smokes written
118
+ // against the older convention keep running unedited.
120
119
  GAMEDEV_TEST_GAME_URL: handle.url,
121
120
  URL: handle.url,
122
121
  },
package/lib/update.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // `looop update` — move this game to the latest engine release (the CLI verb
2
- // the demoted /update skill became, cuqfzo Q6). Asks the platform registry
1
+ // `looop update` — move this game to the latest engine release.
2
+ // Asks the platform registry
3
3
  // for the latest downloadable release, rewrites the `looop.engine` pin, and
4
4
  // lets ensureEngine do what it already does on every dev/publish: download
5
5
  // (cached), install, re-pin. Slice 2 extends this with the skills/agent-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "lib",
13
- "template"
13
+ "template",
14
+ "!lib/**/*.test.mjs"
14
15
  ],
15
16
  "engines": {
16
17
  "node": ">=20.11"
@@ -28,13 +28,19 @@ applies. This table routes only what has no skill:
28
28
  - `npx looop dev` — the full local stack: the game at
29
29
  http://localhost:8000/games/{{name}}/index.html, multiplayer on :1999,
30
30
  platform services (LLM etc.) on :8788. Leave it running; edits hot-reload.
31
+ **On a new machine the first `dev` signs the creator in: a browser window
32
+ opens, they log in and click Approve (~30 seconds, once per machine). Tell
33
+ them it's coming BEFORE you run it** — an unexplained browser popup mid-build
34
+ reads as something going wrong.
31
35
  - `npx looop test` — the per-change gate: runs every `*.test.mjs` (unit, under
32
36
  node) and every `*.smoke.mjs` (against a real dev stack it boots itself on
33
37
  free ports) in this folder.
34
38
  - `npx looop login` — connect this machine to the creator's Looop account
35
39
  (once per machine; the first `looop dev` runs it automatically).
36
40
  - `npx looop publish` — ship to https://play.looop.games/g/{{name}}.
37
- `--slug <alt>` publishes an A/B copy without touching the live game.
41
+ **The first publish permanently claims the name `{{name}}` for the creator's
42
+ account** — from then on only they can update it. `--slug <alt>` publishes an
43
+ A/B copy without touching the live game.
38
44
 
39
45
  ## The rules that are physics (not preference)
40
46
 
@@ -1,95 +0,0 @@
1
- // cuqfzo Phase 2 / Option B — Slice 2: the CLI "bundle primitives, engine
2
- // external" step. A creator's server primitives can import npm packages, WASM,
3
- // and sibling game files; the publish endpoint (a Pages Function) has no
4
- // bundler, so the CLI bundles them into ONE self-contained module here. The
5
- // engine surface a primitive imports (`/shared/ui/room/...`, or the engine
6
- // package) is marked EXTERNAL and rewritten to `./rooms-runtime.js` — the
7
- // pre-bundled engine artifact the endpoint uploads alongside; bundling it in
8
- // would duplicate the room runtime and bypass the cost boundary.
9
- import { test } from 'node:test';
10
- import assert from 'node:assert/strict';
11
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
14
- import { bundlePrimitives } from './bundle-primitives.mjs';
15
-
16
- // A fixture game whose primitives exercise all three seams: an npm dep, a WASM
17
- // module, and an engine import. Returns the project dir (caller cleans up).
18
- function fixture() {
19
- const dir = mkdtempSync(join(tmpdir(), 'looop-bundle-'));
20
- // A fake installed npm package (proves node_modules resolution + inlining).
21
- mkdirSync(join(dir, 'node_modules/tiny-dep'), { recursive: true });
22
- writeFileSync(join(dir, 'node_modules/tiny-dep/package.json'), JSON.stringify({ name: 'tiny-dep', version: '1.0.0', main: 'index.js', type: 'module' }));
23
- writeFileSync(join(dir, 'node_modules/tiny-dep/index.js'), 'export const DEP_MARKER = 41;\n');
24
- // A tiny WASM blob (bytes only — the bundler embeds it; validity is the
25
- // smoke's job). The magic header makes it a recognizable, real .wasm file.
26
- mkdirSync(join(dir, '_local/primitives'), { recursive: true });
27
- writeFileSync(join(dir, '_local/primitives/mod.wasm'), Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]));
28
- // The primitives barrel: engine import (external), npm import (inline),
29
- // sibling wasm import (inline).
30
- writeFileSync(
31
- join(dir, '_local/primitives/index.js'),
32
- [
33
- "import { runEffect } from '/shared/ui/room/primitives/effects.js';",
34
- "import { DEP_MARKER } from 'tiny-dep';",
35
- "import wasmBytes from './mod.wasm';",
36
- 'export const extraPrimitives = {',
37
- " demo: class { constructor() { this.v = DEP_MARKER; this.w = wasmBytes; this.e = runEffect; } },",
38
- '};',
39
- ].join('\n'),
40
- );
41
- return dir;
42
- }
43
-
44
- test('bundles npm deps and WASM INLINE, and leaves the engine import external as ./rooms-runtime.js', async () => {
45
- const dir = fixture();
46
- try {
47
- const { source } = await bundlePrimitives(dir);
48
- assert.equal(typeof source, 'string');
49
-
50
- // npm dep inlined — its value is present, its bare specifier is gone.
51
- assert.match(source, /41/, 'the npm dep value should be inlined');
52
- assert.doesNotMatch(source, /from\s*["']tiny-dep["']/, 'the npm dep must not remain an external import');
53
-
54
- // WASM inlined — no leftover ".wasm" import; the magic bytes appear (base64
55
- // "AGFzbQ" is the standard encoding of \0asm\1\0\0\0).
56
- assert.doesNotMatch(source, /\.wasm["']/, 'the wasm import must be inlined, not external');
57
- assert.match(source, /AGFzbQ/, 'the wasm bytes should be embedded (base64)');
58
-
59
- // Engine import is EXTERNAL and rewritten to the artifact module.
60
- assert.match(source, /from\s*["']\.\/rooms-runtime\.js["']/, 'engine imports must be external → ./rooms-runtime.js');
61
- assert.match(source, /runEffect/, 'the imported engine symbol is preserved');
62
- // The original /shared/... specifier is gone (rewritten).
63
- assert.doesNotMatch(source, /\/shared\/ui\/room/, 'the /shared/... specifier must be rewritten away');
64
- } finally {
65
- rmSync(dir, { recursive: true, force: true });
66
- }
67
- });
68
-
69
- test('the ONLY external import in the bundle is the engine artifact (everything else is self-contained)', async () => {
70
- const dir = fixture();
71
- try {
72
- const { source, externals } = await bundlePrimitives(dir);
73
- // Every `from "..."` in the output must point at the artifact (or a
74
- // cloudflare: builtin) — nothing else may be left unresolved.
75
- const froms = [...source.matchAll(/\bfrom\s*["']([^"']+)["']/g)].map((m) => m[1]);
76
- for (const spec of froms) {
77
- assert.ok(
78
- spec === './rooms-runtime.js' || spec.startsWith('cloudflare:'),
79
- `unexpected external in the primitives bundle: ${spec}`,
80
- );
81
- }
82
- assert.deepEqual([...new Set(externals)].sort(), ['/shared/ui/room/primitives/effects.js']);
83
- } finally {
84
- rmSync(dir, { recursive: true, force: true });
85
- }
86
- });
87
-
88
- test('throws a clear error when the primitives barrel is missing (not a server-backed game)', async () => {
89
- const dir = mkdtempSync(join(tmpdir(), 'looop-bundle-empty-'));
90
- try {
91
- await assert.rejects(() => bundlePrimitives(dir), /primitives/i);
92
- } finally {
93
- rmSync(dir, { recursive: true, force: true });
94
- }
95
- });
@@ -1,37 +0,0 @@
1
- // CLI config lives in ~/.looop/config.json (LOOOP_HOME overrides for tests).
2
- // It stores the creator token minted by `looop login` — the secret never
3
- // passes through the human's hands (device flow, decision Q2/Q6 in cuqfzo).
4
- import { test } from 'node:test';
5
- import assert from 'node:assert/strict';
6
- import { mkdtempSync, rmSync, readFileSync, statSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
- import { join } from 'node:path';
9
- import { readConfig, writeConfig, getToken, setToken, clearToken, configPath } from './config.mjs';
10
-
11
- const home = mkdtempSync(join(tmpdir(), 'looop-home-'));
12
- process.env.LOOOP_HOME = home;
13
-
14
- test('empty config reads as {}', () => {
15
- assert.deepEqual(readConfig(), {});
16
- assert.equal(getToken(), null);
17
- });
18
-
19
- test('setToken persists and getToken reads it back', () => {
20
- setToken('looop_abc123', { apiBase: 'https://play.looop.games' });
21
- assert.equal(getToken(), 'looop_abc123');
22
- const onDisk = JSON.parse(readFileSync(configPath(), 'utf8'));
23
- assert.equal(onDisk.token, 'looop_abc123');
24
- });
25
-
26
- test('token file is not world-readable', () => {
27
- const mode = statSync(configPath()).mode & 0o777;
28
- assert.equal(mode & 0o077, 0, `mode ${mode.toString(8)} leaks to group/other`);
29
- });
30
-
31
- test('clearToken removes the token but keeps other config', () => {
32
- writeConfig({ ...readConfig(), custom: 'x' });
33
- clearToken();
34
- assert.equal(getToken(), null);
35
- assert.equal(readConfig().custom, 'x');
36
- rmSync(home, { recursive: true, force: true });
37
- });