@looop-games/cli 0.1.2

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/lib/create.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // `looop create <name>` — bootstrap a standalone Looop game (cuqfzo Slice 4).
2
+ //
3
+ // The create-next-app gesture: one command → a complete folder that is its
4
+ // own repo, where `looop dev` immediately serves a WORKING multiplayer game
5
+ // (the platform is not optional — the template joins a room on line one, no
6
+ // "add multiplayer later" tier). The folder name is the slug.
7
+ 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
+ `;
31
+
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';
36
+
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 });
46
+
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;
51
+
52
+ const keys = new Set();
53
+ addEventListener('keydown', (e) => keys.add(e.key.toLowerCase()));
54
+ addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
55
+
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 });
67
+ }
68
+
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);
83
+ }
84
+ requestAnimationFrame(tick);
85
+ `;
86
+
87
+ export async function create({
88
+ name,
89
+ cwd = process.cwd(),
90
+ install = true,
91
+ cliSpec = process.env.LOOOP_CREATE_CLI_SPEC || '^0.1.0',
92
+ log = console.log,
93
+ } = {}) {
94
+ if (!name || !NAME_OK.test(name)) {
95
+ throw new Error(
96
+ `"${name ?? ''}" won't work as a game name — use lowercase letters, digits, and dashes (it becomes the URL: play.looop.games/g/<name>).`,
97
+ );
98
+ }
99
+ const dir = join(cwd, name);
100
+ if (existsSync(dir)) throw new Error(`${dir} already exists — pick another name or remove it first.`);
101
+
102
+ 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');
109
+ // 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.
112
+ writeFileSync(
113
+ join(dir, 'package.json'),
114
+ JSON.stringify(
115
+ {
116
+ name,
117
+ private: true,
118
+ 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 },
121
+ },
122
+ null,
123
+ 2,
124
+ ) + '\n',
125
+ );
126
+
127
+ if (install) {
128
+ log(`Installing the CLI (npm install in ${name}/)…`);
129
+ execFileSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'pipe' });
130
+ }
131
+ try {
132
+ execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'pipe' });
133
+ } catch {
134
+ // git not installed — fine, the folder still works.
135
+ }
136
+
137
+ log('');
138
+ log(`✅ ${name} is ready.`);
139
+ log(` cd ${dir} && npx looop dev`);
140
+ log(' (first run downloads the Looop engine — it will ask you to sign in)');
141
+ return { dir };
142
+ }
@@ -0,0 +1,92 @@
1
+ // `looop create <name>` — the create-next-app gesture (cuqfzo Slice 4): one
2
+ // command produces a standalone game folder where `dev` immediately serves a
3
+ // working multiplayer game. Pins the scaffold contract: a real MP template
4
+ // (imports the room client), a package.json depending on @looop-games/cli
5
+ // (the engine is NOT an npm dependency — Q4 revision: `looop dev` installs it
6
+ // from the platform's login-gated registry via ensureEngine), agent
7
+ // instructions, and a slug-safe name. The full install-and-boot path is
8
+ // covered by packed-install.smoke.mjs.
9
+ import { test, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { create } from './create.mjs';
15
+
16
+ const base = mkdtempSync(join(tmpdir(), 'looop-create-'));
17
+ after(() => rmSync(base, { recursive: true, force: true }));
18
+
19
+ test('scaffolds a complete standalone game folder', async () => {
20
+ const { dir } = await create({ name: 'tower-jump', cwd: base, install: false, log: () => {} });
21
+ assert.equal(dir, join(base, 'tower-jump'));
22
+
23
+ const html = readFileSync(join(dir, 'index.html'), 'utf8');
24
+ assert.match(html, /<title>tower-jump<\/title>/);
25
+ const js = readFileSync(join(dir, 'game.js'), 'utf8');
26
+ assert.match(js, /from '\/shared\/ui\/room\/client\.js'/); // real multiplayer from minute one
27
+
28
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
29
+ assert.equal(pkg.name, 'tower-jump');
30
+ assert.equal(pkg.private, true);
31
+ // Q4 revision: the engine is not an npm package — no dependency on it.
32
+ // ensureEngine() installs it at first `looop dev` (login-gated) and writes
33
+ // the `looop.engine` pin then.
34
+ assert.equal(pkg.dependencies, undefined);
35
+ assert.ok(pkg.devDependencies['@looop-games/cli']);
36
+ assert.match(pkg.scripts.dev, /looop dev/);
37
+ assert.match(pkg.scripts.publish, /looop publish/);
38
+
39
+ // The agent-facing surface: instructions + a pointer at the practices docs
40
+ // that ship inside the installed engine bundle.
41
+ const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
42
+ assert.match(agents, /looop dev/);
43
+ assert.match(agents, /overrides\/shared\//);
44
+ assert.match(agents, /node_modules\/@looop-games\/engine\/shared\/practices/);
45
+
46
+ assert.match(readFileSync(join(dir, '.gitignore'), 'utf8'), /node_modules/);
47
+ });
48
+
49
+ test('scaffolds the agent surface for every vendor (no plugin needed — Q4 addendum)', async () => {
50
+ const { dir } = await create({ name: 'multi-agent', cwd: base, install: false, log: () => {} });
51
+
52
+ // AGENTS.md is canonical; CLAUDE.md and GEMINI.md are thin pointers that
53
+ // inline it (@import) with a plain-text fallback line for tools without
54
+ // import support.
55
+ for (const pointer of ['CLAUDE.md', 'GEMINI.md']) {
56
+ const text = readFileSync(join(dir, pointer), 'utf8');
57
+ assert.match(text, /^@AGENTS\.md$/m, `${pointer} imports AGENTS.md`);
58
+ assert.match(text, /read .*AGENTS\.md/i, `${pointer} has the fallback instruction`);
59
+ }
60
+
61
+ // The looop skill ships with the scaffold (Claude Code picks up
62
+ // .claude/skills/ per project) — same knowledge the plugin used to carry,
63
+ // now versioned with the CLI that scaffolded it.
64
+ const skill = readFileSync(join(dir, '.claude', 'skills', 'looop', 'SKILL.md'), 'utf8');
65
+ assert.match(skill, /^---\nname: looop\n/, 'skill frontmatter');
66
+ assert.match(skill, /npx looop publish --slug/, 'documents the CLI surface');
67
+ assert.match(skill, /overrides\/shared\//, 'documents the override mechanism');
68
+ assert.match(skill, /multiplayer/i);
69
+
70
+ // The scaffold's .gitignore must NOT swallow the agent files.
71
+ const ignore = readFileSync(join(dir, '.gitignore'), 'utf8');
72
+ assert.ok(!/\.claude/.test(ignore), '.claude/skills travels with the repo');
73
+ });
74
+
75
+ test('the CLI dependency spec is overridable for tarball installs (the smoke path)', async () => {
76
+ const { dir } = await create({
77
+ name: 'spec-game',
78
+ cwd: base,
79
+ install: false,
80
+ cliSpec: 'file:../cli.tgz',
81
+ log: () => {},
82
+ });
83
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
84
+ assert.equal(pkg.devDependencies['@looop-games/cli'], 'file:../cli.tgz');
85
+ });
86
+
87
+ test('rejects slug-unsafe names and existing folders', async () => {
88
+ await assert.rejects(() => create({ name: 'My Game!', cwd: base, install: false, log: () => {} }), /letters/);
89
+ await create({ name: 'dupe', cwd: base, install: false, log: () => {} });
90
+ await assert.rejects(() => create({ name: 'dupe', cwd: base, install: false, log: () => {} }), /exists/);
91
+ assert.ok(existsSync(join(base, 'dupe')));
92
+ });
package/lib/dev.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // `looop dev` — the standalone three-service dev stack (decision Q3 in
2
+ // cuqfzo), same shape the monorepo's dev.sh proved:
3
+ //
4
+ // static :8000 game folder at /games/<slug>/, /shared/ → engine bundle,
5
+ // head injection + auto-reload
6
+ // mp :1999 local partykit running the BUNDLE's room-server code
7
+ // shim :8788 auth/CORS plumbing to the platform services API
8
+ //
9
+ // Multiplayer runs from minute one — the platform is not optional (the Next
10
+ // analogy deliberately breaks there). NO_MP=1 opts out for purely-SP work.
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, dirname } from 'node:path';
15
+ import { findProject } from './project.mjs';
16
+ import { ensureEngine } from './engine.mjs';
17
+ import { createStaticServer } from './static-server.mjs';
18
+ import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
19
+ import { getToken, getApiBase } from './config.mjs';
20
+ import { portsFor, portInUse, killPort, lanIp } from './ports.mjs';
21
+
22
+ // Resolve the partykit CLI entry from OUR dependencies (the game never
23
+ // declares partykit; it rides @looop-games/cli). partykit's `exports` map hides
24
+ // package.json from require.resolve, so walk node_modules dirs by hand.
25
+ export function partykitBin() {
26
+ let dir = import.meta.dirname;
27
+ for (;;) {
28
+ const candidate = join(dir, 'node_modules', 'partykit', 'package.json');
29
+ if (existsSync(candidate)) {
30
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
31
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.partykit;
32
+ return join(dirname(candidate), bin);
33
+ }
34
+ const parent = dirname(dir);
35
+ if (parent === dir) throw new Error('partykit is not installed alongside @looop-games/cli — reinstall the CLI.');
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
41
+ const project = findProject(cwd);
42
+ // Q4 revision: the engine is not an npm dependency — install/refresh it from
43
+ // the platform's login-gated registry (no-op when the pinned version is in).
44
+ const engine = await ensureEngine(project.dir, { log });
45
+ const ports = portsFor(port ?? Number(process.env.LOOOP_DEV_PORT ?? 8000));
46
+ const children = [];
47
+ const servers = [];
48
+
49
+ const stop = () => {
50
+ for (const c of children) {
51
+ // partykit is npx-style: node wrapper forks workerd. Kill the tree.
52
+ try {
53
+ process.kill(-c.pid, 'SIGTERM');
54
+ } catch {
55
+ try {
56
+ c.kill('SIGTERM');
57
+ } catch {
58
+ /* gone */
59
+ }
60
+ }
61
+ }
62
+ for (const s of servers) s.close();
63
+ };
64
+
65
+ // ─── static ───
66
+ if (await portInUse(ports.static)) {
67
+ log(`→ static :${ports.static} (in use — killing to take over)`);
68
+ await killPort(ports.static);
69
+ }
70
+ // overrides/shared/… (Slice 2, Q1): the game's first-class engine
71
+ // modifications mount ahead of the bundle and shadow it per-file — the same
72
+ // shadowing the publish resolve step applies in production.
73
+ const overridesDir = join(project.dir, 'overrides', 'shared');
74
+ const staticServer = createStaticServer({
75
+ slug: project.slug,
76
+ mounts: [
77
+ { url: `/games/${project.slug}/`, dir: project.dir },
78
+ { url: '/shared/', dir: overridesDir },
79
+ { url: '/shared/', dir: engine.sharedDir },
80
+ ],
81
+ watchDirs: [project.dir, engine.sharedDir],
82
+ });
83
+ await staticServer.listen(ports.static);
84
+ servers.push(staticServer);
85
+ log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload on)`);
86
+
87
+ // ─── multiplayer (partykit on the bundle's room code) ───
88
+ if (noMp) {
89
+ log(`→ multiplayer :${ports.mp} (skipped — NO_MP=1)`);
90
+ } else {
91
+ if (await portInUse(ports.mp)) {
92
+ log(`→ multiplayer :${ports.mp} (in use — killing to take over)`);
93
+ await killPort(ports.mp);
94
+ }
95
+ const pk = spawn(
96
+ process.execPath,
97
+ [partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
98
+ { cwd: engine.roomServerDir, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
99
+ );
100
+ pk.stdout.on('data', () => {});
101
+ pk.stderr.on('data', (d) => {
102
+ const s = String(d);
103
+ if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
104
+ });
105
+ children.push(pk);
106
+ log(`→ multiplayer :${ports.mp} (partykit on the bundle's room-server, pid ${pk.pid})`);
107
+ }
108
+
109
+ // ─── platform services shim ───
110
+ if (await portInUse(ports.shim)) {
111
+ log(`→ services :${ports.shim} (already running — leaving it alone)`);
112
+ } else {
113
+ const shim = createLlmShim({ apiBase: getApiBase(DEFAULT_API_BASE), getToken });
114
+ await shim.listen(ports.shim);
115
+ servers.push(shim);
116
+ const who = getToken() ? 'authenticated' : 'anonymous — run `looop login`';
117
+ log(`→ services :${ports.shim} (→ ${getApiBase(DEFAULT_API_BASE)}, ${who})`);
118
+ }
119
+
120
+ const url = `http://localhost:${ports.static}/games/${project.slug}/index.html`;
121
+ const ip = lanIp();
122
+ log('');
123
+ log('────────────────────────────────────────────────────────────');
124
+ log(`✅ ${project.slug} ready. Open:`);
125
+ log(` ${url}`);
126
+ if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
127
+ log('────────────────────────────────────────────────────────────');
128
+
129
+ return { project, engine, ports, url, stop };
130
+ }
package/lib/engine.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // ensureEngine — make the engine present before dev/publish (Q4 revision:
2
+ // the engine is NOT an npm package; npm ships only this CLI).
3
+ //
4
+ // The engine installs from the platform's release registry via the
5
+ // LOGIN-GATED /api/creator/engine lane — every download maps to a creator
6
+ // account (attribution, revocation, and the /activate approval as the terms
7
+ // gate). The flow, Playwright-style (npm tool + vendor-served artifact):
8
+ //
9
+ // 1. honor the game's `looop.engine` pin in package.json (written on first
10
+ // install, so repeat runs are reproducible and offline-friendly)
11
+ // 2. if the installed node_modules/@looop-games/engine already matches → done
12
+ // 3. otherwise: auto-run the device-flow login when the machine has no
13
+ // token, download the release tarball (cached in ~/.looop/cache — a
14
+ // later `npm install` may prune the engine from node_modules; the cache
15
+ // makes the self-heal local), `npm install --no-save` it, write the pin.
16
+ //
17
+ // LOOOP_ENGINE_TARBALL=<path> short-circuits the network — the offline/smoke
18
+ // lane (packed-install smoke, air-gapped work against a local build).
19
+ import { execFileSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { join, resolve } from 'node:path';
22
+ import { resolveEngine } from './project.mjs';
23
+ import { getToken, getApiBase, cacheDir } from './config.mjs';
24
+ import { login } from './login.mjs';
25
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
26
+
27
+ export function readEnginePin(projectDir) {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
30
+ return typeof pkg?.looop?.engine === 'string' ? pkg.looop.engine : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function writeEnginePin(projectDir, version) {
37
+ const pkgPath = join(projectDir, 'package.json');
38
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
39
+ pkg.looop = { ...pkg.looop, engine: version };
40
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
41
+ }
42
+
43
+ function installedEngine(projectDir) {
44
+ try {
45
+ return resolveEngine(projectDir);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // `npm install --no-save <tgz>` — npm extracts the package into
52
+ // node_modules/@looop-games/engine AND installs its runtime dependencies
53
+ // (the room server's bare imports), without touching package.json.
54
+ function npmInstallTarball(projectDir, tgzPath) {
55
+ execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', resolve(tgzPath)], {
56
+ cwd: projectDir,
57
+ stdio: 'pipe',
58
+ });
59
+ }
60
+
61
+ export async function ensureEngine(
62
+ projectDir,
63
+ {
64
+ apiBase = getApiBase(DEFAULT_API_BASE),
65
+ log = console.log,
66
+ fetchImpl = fetch,
67
+ loginFn = login,
68
+ installTarball = npmInstallTarball,
69
+ tarballOverride = process.env.LOOOP_ENGINE_TARBALL,
70
+ } = {},
71
+ ) {
72
+ let pin = readEnginePin(projectDir);
73
+
74
+ const installed = installedEngine(projectDir);
75
+ if (installed && (!pin || installed.version === pin)) {
76
+ // Adopt a pre-existing install (e.g. the npm-distribution era) as the pin.
77
+ if (!pin) writeEnginePin(projectDir, installed.version);
78
+ return installed;
79
+ }
80
+
81
+ // Offline/smoke lane: a local tarball wins over everything network.
82
+ if (tarballOverride) {
83
+ log(`Installing the engine from ${tarballOverride}…`);
84
+ installTarball(projectDir, tarballOverride);
85
+ const engine = installedEngine(projectDir);
86
+ if (!engine) throw new Error(`installing ${tarballOverride} did not produce node_modules/@looop-games/engine`);
87
+ if (pin && engine.version !== pin) {
88
+ log(` (note: this tarball is ${engine.version}; the game pinned ${pin} — pin updated)`);
89
+ }
90
+ writeEnginePin(projectDir, engine.version);
91
+ return engine;
92
+ }
93
+
94
+ // The download lane is login-gated: no token → run the device flow now.
95
+ if (!getToken()) {
96
+ log('The Looop engine is downloaded from the platform and needs your account.');
97
+ await loginFn({ apiBase, log });
98
+ if (!getToken()) throw new Error('login did not produce a token — run `looop login` and retry.');
99
+ }
100
+ const auth = { Authorization: `Bearer ${getToken()}` };
101
+
102
+ if (!pin) {
103
+ const res = await fetchImpl(`${apiBase}/api/creator/engine`, { headers: auth });
104
+ if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `looop login` again.');
105
+ if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
106
+ const { latest } = await res.json();
107
+ if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
108
+ pin = latest;
109
+ }
110
+
111
+ const cache = cacheDir();
112
+ const cached = join(cache, `engine-${pin}.tgz`);
113
+ if (!existsSync(cached)) {
114
+ log(`Downloading engine ${pin} from ${apiBase}…`);
115
+ const res = await fetchImpl(`${apiBase}/api/creator/engine/${pin}`, { headers: auth });
116
+ if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `looop login` again.');
117
+ if (res.status === 404) throw new Error(`engine ${pin} is not downloadable — check the \`looop.engine\` pin in package.json.`);
118
+ if (!res.ok) throw new Error(`engine download failed (HTTP ${res.status})`);
119
+ mkdirSync(cache, { recursive: true });
120
+ // Write-then-rename so a killed download never leaves a truncated .tgz
121
+ // behind as a valid-looking cache entry.
122
+ const partial = `${cached}.partial`;
123
+ writeFileSync(partial, Buffer.from(await res.arrayBuffer()));
124
+ renameSync(partial, cached);
125
+ }
126
+
127
+ log(`Installing engine ${pin}…`);
128
+ installTarball(projectDir, cached);
129
+ const engine = installedEngine(projectDir);
130
+ if (!engine) throw new Error('engine install did not produce node_modules/@looop-games/engine');
131
+ if (engine.version !== pin) {
132
+ throw new Error(`installed engine reports ${engine.version} but ${pin} was requested — corrupt cache? Delete ${cached} and retry.`);
133
+ }
134
+ writeEnginePin(projectDir, pin);
135
+ return engine;
136
+ }
@@ -0,0 +1,176 @@
1
+ // ensureEngine (Q4 revision: the engine is not an npm package) — `looop dev`
2
+ // and `looop publish` call this before touching the engine. Pins the contract:
3
+ // - installed engine matching the `looop.engine` pin → no network at all
4
+ // - no engine → LOGIN-GATED download from /api/creator/engine (auto-runs the
5
+ // device flow when the machine has no token), tarball cached under
6
+ // ~/.looop/cache, installed via the injected installer, pin written back
7
+ // - LOOOP_ENGINE_TARBALL short-circuits the network (offline/smoke lane)
8
+ import { test, beforeEach, after } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { ensureEngine, readEnginePin, writeEnginePin } from './engine.mjs';
14
+
15
+ const base = mkdtempSync(join(tmpdir(), 'looop-engine-test-'));
16
+ after(() => rmSync(base, { recursive: true, force: true }));
17
+
18
+ let n = 0;
19
+ let projectDir;
20
+ beforeEach(() => {
21
+ projectDir = join(base, `game-${n++}`);
22
+ mkdirSync(projectDir, { recursive: true });
23
+ writeFileSync(join(projectDir, 'index.html'), '<!doctype html>');
24
+ writeFileSync(
25
+ join(projectDir, 'package.json'),
26
+ JSON.stringify({ name: 'game', private: true, devDependencies: { '@looop-games/cli': '^0.1.0' } }, null, 2) + '\n',
27
+ );
28
+ process.env.LOOOP_HOME = join(base, `home-${n}`);
29
+ delete process.env.LOOOP_ENGINE_TARBALL;
30
+ });
31
+
32
+ function installFakeEngine(dir, version) {
33
+ const engineDir = join(dir, 'node_modules', '@looop-games', 'engine');
34
+ mkdirSync(join(engineDir, 'shared'), { recursive: true });
35
+ mkdirSync(join(engineDir, 'room-server'), { recursive: true });
36
+ writeFileSync(join(engineDir, 'package.json'), JSON.stringify({ name: '@looop-games/engine', version }));
37
+ }
38
+
39
+ // A fake installer standing in for `npm install --no-save <tgz>`: reads the
40
+ // version the test encoded in the tarball file's contents.
41
+ function fakeInstaller(calls = []) {
42
+ return (dir, tgzPath) => {
43
+ calls.push(tgzPath);
44
+ installFakeEngine(dir, readFileSync(tgzPath, 'utf8').trim());
45
+ };
46
+ }
47
+
48
+ test('pin helpers round-trip and preserve the rest of package.json', () => {
49
+ assert.equal(readEnginePin(projectDir), null);
50
+ writeEnginePin(projectDir, '0.1.2');
51
+ assert.equal(readEnginePin(projectDir), '0.1.2');
52
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
53
+ assert.equal(pkg.looop.engine, '0.1.2');
54
+ assert.ok(pkg.devDependencies['@looop-games/cli'], 'other fields untouched');
55
+ });
56
+
57
+ test('installed engine matching the pin: no network, no login', async () => {
58
+ installFakeEngine(projectDir, '0.1.2');
59
+ writeEnginePin(projectDir, '0.1.2');
60
+ const engine = await ensureEngine(projectDir, {
61
+ log: () => {},
62
+ fetchImpl: () => assert.fail('must not touch the network'),
63
+ loginFn: () => assert.fail('must not trigger login'),
64
+ installTarball: () => assert.fail('must not reinstall'),
65
+ });
66
+ assert.equal(engine.version, '0.1.2');
67
+ assert.ok(engine.sharedDir.endsWith(join('@looop-games', 'engine', 'shared')));
68
+ });
69
+
70
+ test('installed engine with NO pin: adopted and pinned, no network', async () => {
71
+ installFakeEngine(projectDir, '0.1.1');
72
+ const engine = await ensureEngine(projectDir, {
73
+ log: () => {},
74
+ fetchImpl: () => assert.fail('must not touch the network'),
75
+ loginFn: () => assert.fail('must not trigger login'),
76
+ installTarball: () => assert.fail('must not reinstall'),
77
+ });
78
+ assert.equal(engine.version, '0.1.1');
79
+ assert.equal(readEnginePin(projectDir), '0.1.1');
80
+ });
81
+
82
+ test('no engine + no token: runs the device-flow login, then downloads latest, installs, caches, pins', async () => {
83
+ const fetched = [];
84
+ let loggedIn = false;
85
+ const fetchImpl = async (url, opts = {}) => {
86
+ fetched.push(url);
87
+ assert.equal(opts.headers?.Authorization, 'Bearer looop_tok', 'download rides the creator token');
88
+ if (url.endsWith('/api/creator/engine')) {
89
+ return new Response(JSON.stringify({ versions: ['0.1.2'], latest: '0.1.2' }), { status: 200 });
90
+ }
91
+ assert.match(url, /\/api\/creator\/engine\/0\.1\.2$/);
92
+ return new Response('0.1.2', { status: 200 }); // "tarball" bytes = version, for the fake installer
93
+ };
94
+ const installs = [];
95
+ const engine = await ensureEngine(projectDir, {
96
+ apiBase: 'https://play.test',
97
+ log: () => {},
98
+ fetchImpl,
99
+ loginFn: async () => {
100
+ loggedIn = true;
101
+ // What the real login() does: store the token in LOOOP_HOME config.
102
+ const home = process.env.LOOOP_HOME;
103
+ mkdirSync(home, { recursive: true });
104
+ writeFileSync(join(home, 'config.json'), JSON.stringify({ token: 'looop_tok' }));
105
+ },
106
+ installTarball: fakeInstaller(installs),
107
+ });
108
+ assert.ok(loggedIn, 'device flow triggered');
109
+ assert.equal(engine.version, '0.1.2');
110
+ assert.equal(readEnginePin(projectDir), '0.1.2');
111
+ assert.equal(installs.length, 1);
112
+ assert.ok(existsSync(join(process.env.LOOOP_HOME, 'cache', 'engine-0.1.2.tgz')), 'tarball cached');
113
+ assert.equal(fetched.length, 2, 'one latest lookup + one download');
114
+ });
115
+
116
+ test('pinned version + warm cache: installs from cache without fetching', async () => {
117
+ writeEnginePin(projectDir, '0.1.3');
118
+ const home = process.env.LOOOP_HOME;
119
+ mkdirSync(join(home, 'cache'), { recursive: true });
120
+ writeFileSync(join(home, 'config.json'), JSON.stringify({ token: 'looop_tok' }));
121
+ writeFileSync(join(home, 'cache', 'engine-0.1.3.tgz'), '0.1.3');
122
+ const engine = await ensureEngine(projectDir, {
123
+ log: () => {},
124
+ fetchImpl: () => assert.fail('cache hit must not fetch'),
125
+ loginFn: () => assert.fail('token exists'),
126
+ installTarball: fakeInstaller(),
127
+ });
128
+ assert.equal(engine.version, '0.1.3');
129
+ });
130
+
131
+ test('a 401 download fails with a login hint', async () => {
132
+ writeEnginePin(projectDir, '0.1.2');
133
+ const home = process.env.LOOOP_HOME;
134
+ mkdirSync(home, { recursive: true });
135
+ writeFileSync(join(home, 'config.json'), JSON.stringify({ token: 'looop_stale' }));
136
+ await assert.rejects(
137
+ () =>
138
+ ensureEngine(projectDir, {
139
+ log: () => {},
140
+ fetchImpl: async () => new Response(JSON.stringify({ error: 'invalid or revoked token' }), { status: 401 }),
141
+ loginFn: () => assert.fail('login only auto-runs when NO token exists'),
142
+ installTarball: fakeInstaller(),
143
+ }),
144
+ /looop login/,
145
+ );
146
+ });
147
+
148
+ test('LOOOP_ENGINE_TARBALL short-circuits the network entirely (offline/smoke lane)', async () => {
149
+ const tgz = join(base, 'local-engine.tgz');
150
+ writeFileSync(tgz, '0.9.9');
151
+ process.env.LOOOP_ENGINE_TARBALL = tgz;
152
+ const engine = await ensureEngine(projectDir, {
153
+ log: () => {},
154
+ fetchImpl: () => assert.fail('must not touch the network'),
155
+ loginFn: () => assert.fail('must not trigger login'),
156
+ installTarball: fakeInstaller(),
157
+ });
158
+ assert.equal(engine.version, '0.9.9');
159
+ assert.equal(readEnginePin(projectDir), '0.9.9');
160
+ });
161
+
162
+ test('installed engine that mismatches the pin is reinstalled at the pin', async () => {
163
+ installFakeEngine(projectDir, '0.1.1');
164
+ writeEnginePin(projectDir, '0.1.4');
165
+ const home = process.env.LOOOP_HOME;
166
+ mkdirSync(join(home, 'cache'), { recursive: true });
167
+ writeFileSync(join(home, 'config.json'), JSON.stringify({ token: 'looop_tok' }));
168
+ writeFileSync(join(home, 'cache', 'engine-0.1.4.tgz'), '0.1.4');
169
+ const engine = await ensureEngine(projectDir, {
170
+ log: () => {},
171
+ fetchImpl: () => assert.fail('cache hit must not fetch'),
172
+ loginFn: () => assert.fail('token exists'),
173
+ installTarball: fakeInstaller(),
174
+ });
175
+ assert.equal(engine.version, '0.1.4');
176
+ });