@looop-games/cli 0.1.2 → 0.1.4

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
@@ -7,6 +7,9 @@ import { dev } from '../lib/dev.mjs';
7
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
+ import { testCmd } from '../lib/test-cmd.mjs';
11
+ import { update } from '../lib/update.mjs';
12
+ import { sendFeedback } from '../lib/feedback.mjs';
10
13
  import { clearToken } from '../lib/config.mjs';
11
14
 
12
15
  const [, , cmd, ...rest] = process.argv;
@@ -21,7 +24,10 @@ const HELP = `looop — build and run Looop games
21
24
  Usage:
22
25
  looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
23
26
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
27
+ looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
28
+ looop update Move this game to the latest engine release (re-pins looop.engine)
24
29
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
30
+ looop feedback Send the unsent reports under notes/feedback/ to the Looop team
25
31
  looop login Authenticate this machine as your Looop player account
26
32
  looop logout Forget the stored token
27
33
  looop whoami Show who this machine publishes as
@@ -47,9 +53,19 @@ try {
47
53
  case 'create':
48
54
  await create({ name: rest.find((a) => !a.startsWith('--')) });
49
55
  break;
56
+ case 'test': {
57
+ const { ok } = await testCmd();
58
+ process.exit(ok ? 0 : 1);
59
+ }
60
+ case 'update':
61
+ await update();
62
+ break;
50
63
  case 'publish':
51
64
  await publish({ slug: flag('slug') });
52
65
  break;
66
+ case 'feedback':
67
+ await sendFeedback();
68
+ break;
53
69
  case 'login':
54
70
  await login();
55
71
  break;
@@ -0,0 +1,77 @@
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.
3
+ //
4
+ // The publish endpoint is a Pages Function — a Worker with no filesystem and no
5
+ // npm — so it cannot run a bundler. But real server primitives import npm
6
+ // packages, WASM, and sibling game files that only a bundler with node_modules
7
+ // on disk can resolve. So the CLI bundles `_local/primitives/index.js` into ONE
8
+ // self-contained ESM module here (npm + WASM + local files inlined), and the
9
+ // endpoint assembles the worker from it + our generated entry + the release's
10
+ // pre-bundled engine runtime.
11
+ //
12
+ // The ENGINE seam (the dev convention): a primitive imports engine server-side
13
+ // helpers via `/shared/ui/room/...` (or the `@looop-games/engine` package). We
14
+ // mark those EXTERNAL and rewrite them to `./rooms-runtime.js` — the engine
15
+ // artifact the endpoint uploads alongside. Bundling the engine IN would
16
+ // duplicate the room runtime and, worse, let a primitive smuggle its own room
17
+ // class past the setInterval/park cost boundary. Keeping it external means the
18
+ // worker's room code is always ours.
19
+ import { existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ // The primitives barrel — same path the endpoint detects a server-backed game
23
+ // by (SERVER_PRIMITIVES_PATH in game-server-deploy.ts).
24
+ export const PRIMITIVES_ENTRY = '_local/primitives/index.js';
25
+
26
+ // The module name the generated worker entry imports the runtime from; every
27
+ // engine import in the primitives bundle is rewritten to this.
28
+ export const ENGINE_ARTIFACT_MODULE = './rooms-runtime.js';
29
+
30
+ export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
31
+ const entry = join(projectDir, PRIMITIVES_ENTRY);
32
+ if (!existsSync(entry)) {
33
+ throw new Error(
34
+ `no server primitives to bundle — ${PRIMITIVES_ENTRY} not found (this is not a server-backed game)`,
35
+ );
36
+ }
37
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
38
+
39
+ // Every engine specifier the primitives import, captured for reporting.
40
+ const externals = [];
41
+ const toArtifact = (args) => {
42
+ externals.push(args.path);
43
+ return { path: ENGINE_ARTIFACT_MODULE, external: true };
44
+ };
45
+
46
+ const enginePlugin = {
47
+ name: 'looop-engine-external',
48
+ setup(build) {
49
+ // `/shared/ui/room/...` — the dev alias the room server + primitives use.
50
+ build.onResolve({ filter: /^\/shared\// }, toArtifact);
51
+ // The engine package specifier, if a primitive imports it by name.
52
+ build.onResolve({ filter: /^@looop-games\/engine(\/|$)/ }, toArtifact);
53
+ // Workers-runtime builtins are provided at execution — never bundled.
54
+ build.onResolve({ filter: /^cloudflare:/ }, (args) => ({ path: args.path, external: true }));
55
+ },
56
+ };
57
+
58
+ const result = await esbuild.build({
59
+ entryPoints: [entry],
60
+ bundle: true,
61
+ write: false,
62
+ format: 'esm',
63
+ // workerd is browser-shaped; the engine artifact is built the same way.
64
+ platform: 'browser',
65
+ target: 'esnext',
66
+ legalComments: 'none',
67
+ // Prefer worker builds of packages that ship a `workerd`/`worker` export
68
+ // condition (e.g. the quickjs WASM variant wordsmith uses).
69
+ conditions: ['workerd', 'worker'],
70
+ // Inline WASM as bytes so the bundle stays ONE self-contained module — no
71
+ // extra CompiledWasm part to thread through the 3-module upload.
72
+ loader: { '.wasm': 'binary' },
73
+ plugins: [enginePlugin],
74
+ });
75
+
76
+ return { source: result.outputFiles[0].text, externals };
77
+ }
@@ -0,0 +1,95 @@
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
+ });
package/lib/create.mjs CHANGED
@@ -4,91 +4,85 @@
4
4
  // own repo, where `looop dev` immediately serves a WORKING multiplayer game
5
5
  // (the platform is not optional — the template joins a room on line one, no
6
6
  // "add multiplayer later" tier). The folder name is the slug.
7
+ //
8
+ // The scaffold lives as REAL FILES in ../template/ (creator-harness Slice 1)
9
+ // — edit the template there, not string literals here. `create` copies it
10
+ // with a {{name}} substitution pass. One rename on copy, an npm-pack
11
+ // artifact: `gitignore` → `.gitignore` (npm silently strips .gitignore
12
+ // files from packages). package.json is generated below instead of shipped
13
+ // in the template (nested package.json files confuse npm's packer, and it
14
+ // carries the dynamic cliSpec anyway).
7
15
  import { execFileSync } from 'node:child_process';
8
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
- import { join } from 'node:path';
10
- import { writeAgentFiles } from './agent-files.mjs';
11
-
12
- const NAME_OK = /^[a-z0-9][a-z0-9-]{0,40}$/;
13
-
14
- const html = (name) => `<!doctype html>
15
- <html>
16
- <head>
17
- <meta charset="utf-8">
18
- <meta name="viewport" content="width=device-width, initial-scale=1">
19
- <title>${name}</title>
20
- <style>
21
- html, body { margin: 0; height: 100%; background: #0f1220; overflow: hidden; }
22
- canvas { display: block; width: 100vw; height: 100vh; }
23
- </style>
24
- </head>
25
- <body>
26
- <canvas id="game"></canvas>
27
- <script type="module" src="game.js"></script>
28
- </body>
29
- </html>
30
- `;
16
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, symlinkSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import { join, relative, dirname } from 'node:path';
19
+ import { getApiBase } from './config.mjs';
20
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
21
+ import { runNpm } from './npm.mjs';
31
22
 
32
- const gameJs = () => `// A tiny multiplayer plaza — walk around, see everyone else.
33
- // This is a REAL Looop game: it already syncs across browsers and works
34
- // published. Reshape it into your game; the room + identity plumbing stays.
35
- import { createRoom, getIdentity } from '/shared/ui/room/client.js';
23
+ export const TEMPLATE_DIR = join(import.meta.dirname, '..', 'template');
36
24
 
37
- const me = getIdentity(); // injected by the platform (fails closed if absent)
38
- const canvas = document.getElementById('game');
39
- const ctx = canvas.getContext('2d');
40
- const size = () => { canvas.width = innerWidth; canvas.height = innerHeight; };
41
- addEventListener('resize', size); size();
42
-
43
- const self = { name: me.name, color: me.color, x: Math.random() * 400 - 200, y: Math.random() * 400 - 200 };
44
- // One room per game: the slug (injected by the serve path) is the room id.
45
- const room = createRoom({ room: window.GAME_SLUG ?? 'dev', self });
25
+ const NAME_OK = /^[a-z0-9][a-z0-9-]{0,40}$/;
46
26
 
47
- let others = new Map();
48
- room.onPlayers = (players) => { others = new Map(players); };
49
- // Handy for smokes: how many players does this client see (self included)?
50
- window.__looopPlayers = () => others.size + 1;
27
+ // npm is making install scripts opt-in: npm 11 warns that a package's
28
+ // postinstall is "not covered by allowScripts", npm 12 SKIPS it by default.
29
+ // esbuild and workerd (both under partykit) fetch their native binaries in a
30
+ // postinstall skipped, `looop dev` cannot start and nothing says why. The
31
+ // scaffold therefore ships its own approval, so a stranger's first install is
32
+ // clean on a strict npm without them ever running `npm approve-scripts`.
33
+ //
34
+ // Deliberately UNPINNED (name-only, not `esbuild@0.21.5`): partykit moves its
35
+ // esbuild/workerd pins between releases, and a version-pinned approval would
36
+ // quietly stop covering them on the next bump — the same silent breakage,
37
+ // later. Caught by our first Windows creator, 2026-07-11.
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 };
51
43
 
52
- const keys = new Set();
53
- addEventListener('keydown', (e) => keys.add(e.key.toLowerCase()));
54
- addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
44
+ // Smokes drive a real browser, so playwright ships in every scaffold's
45
+ // devDependencies and the Chromium binary is fetched at create time — a
46
+ // surprise "npm i -D playwright" + browser download mid-build is exactly
47
+ // what create-time provisioning exists to prevent.
48
+ const PLAYWRIGHT_SPEC = '^1.61.1';
55
49
 
56
- const SPEED = 220; // px/s tune the feel!
57
- let last = performance.now();
58
- function tick(now) {
59
- const dt = Math.min((now - last) / 1000, 0.05); last = now;
60
- const dx = (keys.has('d') || keys.has('arrowright')) - (keys.has('a') || keys.has('arrowleft'));
61
- const dy = (keys.has('s') || keys.has('arrowdown')) - (keys.has('w') || keys.has('arrowup'));
62
- if (dx || dy) {
63
- const n = Math.hypot(dx, dy);
64
- self.x += (dx / n) * SPEED * dt;
65
- self.y += (dy / n) * SPEED * dt;
66
- room.update({ x: self.x, y: self.y });
50
+ // Resolve the engine pin AT CREATE TIME so the initial commit — the
51
+ // milestone-1 revert baseline — already carries `looop.engine`. Without
52
+ // this, first `looop dev` writes the pin AFTER the baseline: the fresh repo
53
+ // starts dirty, and a milestone-1 reject (`git reset --hard`) deletes the
54
+ // pin, so the next dev re-resolves "latest" and can silently change engine
55
+ // versions under a revert. The version LIST endpoint is public (a version
56
+ // number is not a secret); the engine BYTES stay login-gated at first dev.
57
+ async function resolveEnginePin(apiBase, log) {
58
+ try {
59
+ const res = await fetch(`${apiBase}/api/creator/engine`);
60
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
61
+ const { latest } = await res.json();
62
+ if (!latest) throw new Error('no downloadable releases');
63
+ return latest;
64
+ } catch (err) {
65
+ log(`⚠️ Could not resolve the engine version (${err.message}) — the first \`looop dev\` will pin it instead.`);
66
+ return null;
67
67
  }
68
+ }
68
69
 
69
- ctx.fillStyle = '#0f1220';
70
- ctx.fillRect(0, 0, canvas.width, canvas.height);
71
- const cx = canvas.width / 2, cy = canvas.height / 2;
72
- const drawAvatar = (p) => {
73
- ctx.fillStyle = p.color ?? '#888';
74
- ctx.fillRect(cx + (p.x ?? 0) - self.x - 14, cy + (p.y ?? 0) - self.y - 14, 28, 28);
75
- ctx.fillStyle = '#e7e9f4';
76
- ctx.font = '12px system-ui';
77
- ctx.textAlign = 'center';
78
- ctx.fillText(p.name ?? '?', cx + (p.x ?? 0) - self.x, cy + (p.y ?? 0) - self.y - 22);
79
- };
80
- for (const p of others.values()) drawAvatar(p);
81
- drawAvatar(self);
82
- requestAnimationFrame(tick);
70
+ function copyTemplate(dir, name) {
71
+ const entries = readdirSync(TEMPLATE_DIR, { withFileTypes: true, recursive: true }).filter((e) => e.isFile());
72
+ for (const entry of entries) {
73
+ const rel = relative(TEMPLATE_DIR, join(entry.parentPath, entry.name));
74
+ const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel);
75
+ mkdirSync(dirname(dest), { recursive: true });
76
+ writeFileSync(dest, readFileSync(join(TEMPLATE_DIR, rel), 'utf8').replaceAll('{{name}}', name));
77
+ }
83
78
  }
84
- requestAnimationFrame(tick);
85
- `;
86
79
 
87
80
  export async function create({
88
81
  name,
89
82
  cwd = process.cwd(),
90
83
  install = true,
91
84
  cliSpec = process.env.LOOOP_CREATE_CLI_SPEC || '^0.1.0',
85
+ apiBase = getApiBase(DEFAULT_API_BASE),
92
86
  log = console.log,
93
87
  } = {}) {
94
88
  if (!name || !NAME_OK.test(name)) {
@@ -100,15 +94,22 @@ export async function create({
100
94
  if (existsSync(dir)) throw new Error(`${dir} already exists — pick another name or remove it first.`);
101
95
 
102
96
  mkdirSync(dir, { recursive: true });
103
- writeFileSync(join(dir, 'index.html'), html(name));
104
- writeFileSync(join(dir, 'game.js'), gameJs());
105
- // The agent surface for every vendor (AGENTS.md canonical, CLAUDE.md /
106
- // GEMINI.md pointers, the looop skill) see agent-files.mjs.
107
- writeAgentFiles(dir, name);
108
- writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.looop/\n');
97
+ copyTemplate(dir, name);
98
+ // Non-Claude agent CLIs (Codex, Gemini/Antigravity, Cursor, opencode)
99
+ // discover project skills at .agents/skills/ alias it to the same files.
100
+ // Created here, not shipped in the template: npm pack can't carry symlinks.
101
+ try {
102
+ mkdirSync(join(dir, '.agents'), { recursive: true });
103
+ symlinkSync(join('..', '.claude', 'skills'), join(dir, '.agents', 'skills'), 'dir');
104
+ } catch {
105
+ // e.g. Windows without symlink rights — Claude Code (and any tool that
106
+ // reads .claude/skills/ directly) still works.
107
+ }
109
108
  // The engine is deliberately NOT a dependency (Q4 revision): it installs
110
- // from the platform's login-gated registry on first `looop dev`, which then
111
- // writes the `looop.engine` version pin here.
109
+ // from the platform's login-gated registry on first `looop dev`. The
110
+ // VERSION is pinned here at create time (public lookup, warn-fallback) so
111
+ // the initial commit already carries it — see resolveEnginePin above.
112
+ const enginePin = await resolveEnginePin(apiBase, log);
112
113
  writeFileSync(
113
114
  join(dir, 'package.json'),
114
115
  JSON.stringify(
@@ -116,8 +117,10 @@ export async function create({
116
117
  name,
117
118
  private: true,
118
119
  description: `A Looop game. Play at https://play.looop.games/g/${name}`,
119
- scripts: { dev: 'looop dev', publish: 'looop publish' },
120
- devDependencies: { '@looop-games/cli': cliSpec },
120
+ scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test' },
121
+ devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC },
122
+ allowScripts: ALLOW_SCRIPTS,
123
+ ...(enginePin ? { looop: { engine: enginePin } } : {}),
121
124
  },
122
125
  null,
123
126
  2,
@@ -126,10 +129,40 @@ export async function create({
126
129
 
127
130
  if (install) {
128
131
  log(`Installing the CLI (npm install in ${name}/)…`);
129
- execFileSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'pipe' });
132
+ runNpm(['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'pipe' });
133
+ // Fetch the smoke-test browser NOW (cached machine-wide per version) so
134
+ // the first `looop test` never stalls on a surprise download mid-build.
135
+ // Resolved + run via node directly — NEVER a nested `npx`: when create
136
+ // itself runs under `npm exec --package=<tarball>` (the stranger
137
+ // gesture), the inherited npm_config_* env makes an inner npx try to
138
+ // re-install the tarball and cancel (caught live, 2026-07-10).
139
+ try {
140
+ log('Fetching the test browser (Chromium — one-time download, cached for every game)…');
141
+ const req = createRequire(join(dir, 'package.json'));
142
+ const playwrightCli = join(dirname(req.resolve('playwright/package.json')), 'cli.js');
143
+ execFileSync(process.execPath, [playwrightCli, 'install', 'chromium'], { cwd: dir, stdio: 'inherit' });
144
+ } catch {
145
+ log('⚠️ Could not fetch Chromium (offline?) — `looop test` will fetch it when first needed.');
146
+ }
130
147
  }
131
148
  try {
132
149
  execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'pipe' });
150
+ // Commit the scaffold: the initial commit is the first revert baseline
151
+ // (rejecting milestone 1 resets to it) and what lets `git worktree add`
152
+ // work — on a never-committed repo it silently creates an empty orphan
153
+ // lane instead.
154
+ execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
155
+ const msg = ['commit', '-q', '-m', 'initial scaffold (looop create)'];
156
+ try {
157
+ execFileSync('git', msg, { cwd: dir, stdio: 'pipe' });
158
+ } catch {
159
+ // Non-dev machines often have no git identity configured — commit as
160
+ // the scaffold rather than leaving the repo without a baseline.
161
+ execFileSync('git', ['-c', 'user.name=Looop', '-c', 'user.email=create@looop.games', ...msg], {
162
+ cwd: dir,
163
+ stdio: 'pipe',
164
+ });
165
+ }
133
166
  } catch {
134
167
  // git not installed — fine, the folder still works.
135
168
  }