@looop-games/cli 0.1.4 → 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.
@@ -1,176 +0,0 @@
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
- });
@@ -1,144 +0,0 @@
1
- // `looop feedback` — the client half of the feedback transport (cuqfzo; the
2
- // send step behind the template's /feedback skill). Against a mock platform,
3
- // pins the wire contract: every unsent notes/feedback/*.md ships verbatim as
4
- // a JSON report (title from the H1, slug/engine/cli from the repo) with the
5
- // creator token as a Bearer header — and a sent file is stamped in its
6
- // frontmatter so it never ships twice.
7
- import { test, after } from 'node:test';
8
- import assert from 'node:assert/strict';
9
- import http from 'node:http';
10
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
11
- import { tmpdir } from 'node:os';
12
- import { join } from 'node:path';
13
- import { sendFeedback } from './feedback.mjs';
14
- import { setToken, clearToken } from './config.mjs';
15
-
16
- process.env.LOOOP_HOME = mkdtempSync(join(tmpdir(), 'looop-fb-home-'));
17
-
18
- // ── mock platform ──
19
- const received = [];
20
- let respond = () => ({ status: 200, body: { id: `fb_${'0'.repeat(15)}${received.length}`, createdAt: 1 } });
21
- const server = http.createServer(async (req, res) => {
22
- const chunks = [];
23
- for await (const c of req) chunks.push(c);
24
- received.push({
25
- url: req.url,
26
- auth: req.headers.authorization ?? null,
27
- report: JSON.parse(Buffer.concat(chunks).toString('utf8')),
28
- });
29
- const { status, body } = respond();
30
- res.writeHead(status, { 'Content-Type': 'application/json' });
31
- res.end(JSON.stringify(body));
32
- });
33
- await new Promise((r) => server.listen(0, r));
34
- const apiBase = `http://localhost:${server.address().port}`;
35
-
36
- // ── fixture standalone game ──
37
- const root = realpathSync(mkdtempSync(join(tmpdir(), 'looop-fb-')));
38
- const game = join(root, 'my-game');
39
- mkdirSync(join(game, 'notes/feedback'), { recursive: true });
40
- writeFileSync(join(game, 'index.html'), '<html></html>');
41
- writeFileSync(join(game, 'package.json'), JSON.stringify({ name: 'my-game', looop: { engine: '0.1.2' } }));
42
-
43
- const UNSENT = `---
44
- created: 2026-07-10
45
- engine: 0.1.2
46
- ---
47
- # looop test hangs on smokes
48
-
49
- **What happened:** …
50
-
51
- ## Transcript
52
-
53
- > creator: run the tests
54
- `;
55
- const ALREADY_SENT = `---
56
- created: 2026-07-01
57
- sent: 2026-07-02T10:00:00.000Z
58
- id: fb_aaaaaaaaaaaaaaaa
59
- ---
60
- # old report
61
- `;
62
-
63
- after(() => {
64
- server.close();
65
- rmSync(root, { recursive: true, force: true });
66
- rmSync(process.env.LOOOP_HOME, { recursive: true, force: true });
67
- });
68
-
69
- test('sends every unsent report verbatim and stamps it sent', async () => {
70
- setToken('looop_' + '1'.repeat(64), { apiBase });
71
- writeFileSync(join(game, 'notes/feedback/test-hangs.md'), UNSENT);
72
- writeFileSync(join(game, 'notes/feedback/old-report.md'), ALREADY_SENT);
73
-
74
- const result = await sendFeedback({ cwd: game, apiBase, log: () => {} });
75
-
76
- // Only the unsent one ships.
77
- assert.equal(received.length, 1);
78
- const { url, auth, report } = received[0];
79
- assert.equal(url, '/api/creator/feedback');
80
- assert.equal(auth, `Bearer looop_${'1'.repeat(64)}`);
81
- // The wire report: H1 as title, whole file as body, repo identity attached.
82
- assert.equal(report.title, 'looop test hangs on smokes');
83
- assert.equal(report.body, UNSENT); // verbatim — frontmatter, transcript and all
84
- assert.equal(report.slug, 'my-game');
85
- assert.equal(report.engine, '0.1.2');
86
- assert.match(report.cli, /^\d+\.\d+\.\d+/);
87
-
88
- // The file is stamped with the platform id so it never ships twice.
89
- assert.equal(result.sent.length, 1);
90
- assert.match(result.sent[0].id, /^fb_[0-9a-f]{16}$/);
91
- const stamped = readFileSync(join(game, 'notes/feedback/test-hangs.md'), 'utf8');
92
- assert.match(stamped, /^sent: /m);
93
- assert.match(stamped, new RegExp(`^id: ${result.sent[0].id}$`, 'm'));
94
-
95
- // Second run: nothing left to send.
96
- const again = await sendFeedback({ cwd: game, apiBase, log: () => {} });
97
- assert.equal(again.sent.length, 0);
98
- assert.equal(received.length, 1);
99
- });
100
-
101
- test('a report without frontmatter gets a stamp block added', async () => {
102
- writeFileSync(join(game, 'notes/feedback/bare.md'), '# bare report\n\nbody\n');
103
- const result = await sendFeedback({ cwd: game, apiBase, log: () => {} });
104
- assert.equal(result.sent.length, 1);
105
- const stamped = readFileSync(join(game, 'notes/feedback/bare.md'), 'utf8');
106
- assert.match(stamped, /^---\nsent: .+\nid: fb_[0-9a-f]{16}\n---\n# bare report/);
107
- });
108
-
109
- test('a title falls back to the filename when there is no H1', async () => {
110
- writeFileSync(join(game, 'notes/feedback/no-heading.md'), 'just prose, no heading\n');
111
- await sendFeedback({ cwd: game, apiBase, log: () => {} });
112
- assert.equal(received.at(-1).report.title, 'no-heading');
113
- });
114
-
115
- test('not logged in → clear error, nothing sent', async () => {
116
- const before = received.length;
117
- writeFileSync(join(game, 'notes/feedback/while-logged-out.md'), '# report\n');
118
- clearToken();
119
- await assert.rejects(() => sendFeedback({ cwd: game, apiBase, log: () => {} }), /looop login/);
120
- assert.equal(received.length, before);
121
- setToken('looop_' + '1'.repeat(64), { apiBase });
122
- rmSync(join(game, 'notes/feedback/while-logged-out.md'));
123
- });
124
-
125
- test('a platform reject surfaces the platform message and leaves the file unstamped', async () => {
126
- writeFileSync(join(game, 'notes/feedback/rejected.md'), '# report\n');
127
- respond = () => ({ status: 401, body: 'invalid or revoked token — run `looop login` again' });
128
- await assert.rejects(() => sendFeedback({ cwd: game, apiBase, log: () => {} }), /HTTP 401/);
129
- respond = () => ({ status: 200, body: { id: `fb_${'e'.repeat(16)}`, createdAt: 1 } });
130
- const content = readFileSync(join(game, 'notes/feedback/rejected.md'), 'utf8');
131
- assert.doesNotMatch(content, /^sent:/m); // still unsent — retried next run
132
- await sendFeedback({ cwd: game, apiBase, log: () => {} }); // clean up: send it
133
- });
134
-
135
- test('no reports at all is a friendly no-op', async () => {
136
- const bare = join(root, 'quiet-game');
137
- mkdirSync(bare, { recursive: true });
138
- writeFileSync(join(bare, 'index.html'), '<html></html>');
139
- writeFileSync(join(bare, 'package.json'), '{}');
140
- const logs = [];
141
- const result = await sendFeedback({ cwd: bare, apiBase, log: (m) => logs.push(m) });
142
- assert.equal(result.sent.length, 0);
143
- assert.match(logs.join('\n'), /[Nn]othing to send/);
144
- });
@@ -1,87 +0,0 @@
1
- // The dev server's HTML/JS transforms must mirror both the production
2
- // /g/<slug> serve path (head injection: GAME_SLUG + LOOOP_IDENTITY + platform
3
- // layer — see builder/functions/g/[[path]].ts) and the monorepo dev server
4
- // (?v=<mtime> cache-busting rewrites — see looop-core tools/game/dev_server.py).
5
- // A drift here means a game that works standalone but breaks published, or
6
- // vice versa.
7
- import { test } from 'node:test';
8
- import assert from 'node:assert/strict';
9
- import { mkdtempSync, writeFileSync, rmSync, utimesSync } from 'node:fs';
10
- import { tmpdir } from 'node:os';
11
- import { join } from 'node:path';
12
- import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, DEV_IDENTITY } from './inject.mjs';
13
-
14
- test('DEV_IDENTITY matches the serve path contract', () => {
15
- // MUST match builder/functions/_shared/serve-identity.ts DEV_IDENTITY —
16
- // games fail closed without a LOOOP_IDENTITY, and tests key off this user id.
17
- assert.deepEqual(DEV_IDENTITY, { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' });
18
- });
19
-
20
- test('injects slug, identity, and platform module into <head>', () => {
21
- const out = injectHeadTags('<html><head><title>x</title></head><body></body></html>', 'basket');
22
- assert.match(out, /<head>\s*<script>window\.GAME_SLUG = "basket";<\/script>/);
23
- assert.match(out, /window\.LOOOP_IDENTITY = \{"userId":"dev-local-user"/);
24
- assert.match(
25
- out,
26
- /<script type="module">import \{ installPlatform \} from "\/shared\/platform\/platform\.js"; installPlatform\(\);<\/script>/,
27
- );
28
- // Identity must come as a classic script BEFORE the platform module so the
29
- // global exists when any deferred module runs.
30
- assert.ok(out.indexOf('LOOOP_IDENTITY') < out.indexOf('installPlatform'));
31
- });
32
-
33
- test('head injection escapes </script>-breaking values', () => {
34
- const out = injectHeadTags('<head></head>', '</script><script>alert(1)');
35
- assert.ok(!out.includes('</script><script>alert(1)'));
36
- assert.match(out, /\\u003c\/script/);
37
- });
38
-
39
- test('injection without a <head> prepends', () => {
40
- const out = injectHeadTags('<body>hi</body>', 'g');
41
- assert.ok(out.startsWith('<script>window.GAME_SLUG'));
42
- });
43
-
44
- test('html <script src> gets ?v=<mtime>, absolute URLs and querystrings left alone', () => {
45
- const dir = mkdtempSync(join(tmpdir(), 'looop-inject-'));
46
- try {
47
- writeFileSync(join(dir, 'main.js'), '// x');
48
- utimesSync(join(dir, 'main.js'), new Date(1700000000000), new Date(1700000000000));
49
- const html = '<script src="main.js"></script><script src="https://x.test/a.js"></script><script src="b.js?v=1"></script>';
50
- const out = rewriteHtmlScripts(html, {
51
- baseDir: dir,
52
- resolveUrl: () => null,
53
- });
54
- assert.match(out, /src="main\.js\?v=1700000000"/);
55
- assert.match(out, /src="https:\/\/x\.test\/a\.js"/);
56
- assert.match(out, /src="b\.js\?v=1"/);
57
- } finally {
58
- rmSync(dir, { recursive: true, force: true });
59
- }
60
- });
61
-
62
- test('js relative and absolute imports get ?v=<mtime>; bare specifiers untouched', () => {
63
- const dir = mkdtempSync(join(tmpdir(), 'looop-inject-js-'));
64
- try {
65
- writeFileSync(join(dir, 'world.js'), '// w');
66
- utimesSync(join(dir, 'world.js'), new Date(1700000000000), new Date(1700000000000));
67
- const sharedDir = mkdtempSync(join(tmpdir(), 'looop-inject-shared-'));
68
- writeFileSync(join(sharedDir, 'client.js'), '// c');
69
- utimesSync(join(sharedDir, 'client.js'), new Date(1700000001000), new Date(1700000001000));
70
- const src = [
71
- "import './world.js';",
72
- "import { x } from '/shared/ui/room/client.js';",
73
- "import * as C from 'cannon-es';",
74
- "const d = await import('./world.js');",
75
- ].join('\n');
76
- const out = rewriteJsImports(src, {
77
- baseDir: dir,
78
- resolveUrl: (p) => (p === '/shared/ui/room/client.js' ? join(sharedDir, 'client.js') : null),
79
- });
80
- assert.match(out, /'\.\/world\.js\?v=1700000000'/);
81
- assert.match(out, /'\/shared\/ui\/room\/client\.js\?v=1700000001'/);
82
- assert.match(out, /'cannon-es'/);
83
- assert.match(out, /import\('\.\/world\.js\?v=1700000000'\)/);
84
- } finally {
85
- rmSync(dir, { recursive: true, force: true });
86
- }
87
- });
@@ -1,90 +0,0 @@
1
- // The local services shim (:8788) is thin auth/CORS plumbing to the platform
2
- // API — NEVER a local implementation of a service and NEVER a holder of
3
- // provider keys (decision Q3b in cuqfzo). These tests run it against a mock
4
- // upstream and pin: path/method/body pass-through, the creator token becoming
5
- // an Authorization header, tokenless operation (pre-login dev must work), and
6
- // local CORS handling for the browser.
7
- import { test, after } from 'node:test';
8
- import assert from 'node:assert/strict';
9
- import http from 'node:http';
10
- import { createLlmShim } from './llm-shim.mjs';
11
-
12
- let lastReq = null;
13
- const upstream = http.createServer((req, res) => {
14
- let body = '';
15
- req.on('data', (c) => (body += c));
16
- req.on('end', () => {
17
- lastReq = { method: req.method, url: req.url, auth: req.headers.authorization ?? null, body };
18
- res.writeHead(200, { 'Content-Type': 'application/json' });
19
- res.end(JSON.stringify({ ok: true, echo: req.url }));
20
- });
21
- });
22
- await new Promise((r) => upstream.listen(0, r));
23
- const apiBase = `http://localhost:${upstream.address().port}`;
24
-
25
- const shim = createLlmShim({ apiBase, getToken: () => 'looop_test_token' });
26
- await shim.listen(0);
27
- const base = `http://localhost:${shim.port}`;
28
-
29
- after(() => {
30
- shim.close();
31
- upstream.close();
32
- });
33
-
34
- test('forwards /api/llm with method, body, and bearer token', async () => {
35
- const res = await fetch(`${base}/api/llm`, {
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/json' },
38
- body: JSON.stringify({ prompt: 'hi' }),
39
- });
40
- assert.equal(res.status, 200);
41
- assert.deepEqual(await res.json(), { ok: true, echo: '/api/llm' });
42
- assert.equal(lastReq.method, 'POST');
43
- assert.equal(lastReq.url, '/api/llm');
44
- assert.equal(lastReq.auth, 'Bearer looop_test_token');
45
- assert.equal(lastReq.body, '{"prompt":"hi"}');
46
- });
47
-
48
- test('forwards /lb paths too (leaderboards ride the same shim)', async () => {
49
- const res = await fetch(`${base}/lb/pong/top`);
50
- assert.equal(res.status, 200);
51
- assert.equal(lastReq.url, '/lb/pong/top');
52
- });
53
-
54
- test('works without a token — no Authorization header sent', async () => {
55
- const anon = createLlmShim({ apiBase, getToken: () => null });
56
- await anon.listen(0);
57
- try {
58
- await fetch(`http://localhost:${anon.port}/api/llm`, { method: 'POST', body: '{}' });
59
- assert.equal(lastReq.auth, null);
60
- } finally {
61
- anon.close();
62
- }
63
- });
64
-
65
- test('answers preflight locally with permissive CORS', async () => {
66
- const before = lastReq;
67
- const res = await fetch(`${base}/api/llm`, {
68
- method: 'OPTIONS',
69
- headers: { Origin: 'http://localhost:8000', 'Access-Control-Request-Method': 'POST' },
70
- });
71
- assert.equal(res.status, 204);
72
- assert.equal(res.headers.get('access-control-allow-origin'), '*');
73
- assert.match(res.headers.get('access-control-allow-headers'), /content-type/i);
74
- assert.equal(lastReq, before, 'preflight must not reach upstream');
75
- });
76
-
77
- test('non-API paths 404 instead of forwarding', async () => {
78
- assert.equal((await fetch(`${base}/etc/passwd`)).status, 404);
79
- });
80
-
81
- test('upstream failure surfaces as 502, not a hang', async () => {
82
- const dead = createLlmShim({ apiBase: 'http://127.0.0.1:1', getToken: () => null });
83
- await dead.listen(0);
84
- try {
85
- const res = await fetch(`http://localhost:${dead.port}/api/llm`, { method: 'POST', body: '{}' });
86
- assert.equal(res.status, 502);
87
- } finally {
88
- dead.close();
89
- }
90
- });
@@ -1,89 +0,0 @@
1
- // `looop login` — the CLI half of the device flow (cuqfzo Slice 1, Q6).
2
- // Driven against a mock platform implementing the real wire contract
3
- // (/api/device/code → poll /api/device/token → /api/me), pinning: the human
4
- // sees the code + URL, the CLI polls through authorization_pending, the token
5
- // lands in config (never printed), expiry fails cleanly.
6
- import { test, after } from 'node:test';
7
- import assert from 'node:assert/strict';
8
- import http from 'node:http';
9
- import { mkdtempSync, rmSync } from 'node:fs';
10
- import { tmpdir } from 'node:os';
11
- import { join } from 'node:path';
12
- import { login, whoami } from './login.mjs';
13
- import { getToken, setToken } from './config.mjs';
14
-
15
- process.env.LOOOP_HOME = mkdtempSync(join(tmpdir(), 'looop-login-home-'));
16
-
17
- let pollsUntilApproved = 2;
18
- let expireEverything = false;
19
- const server = http.createServer(async (req, res) => {
20
- const send = (status, body) => {
21
- res.writeHead(status, { 'Content-Type': 'application/json' });
22
- res.end(JSON.stringify(body));
23
- };
24
- if (req.url === '/api/device/code') {
25
- return send(200, {
26
- device_code: 'd'.repeat(64),
27
- user_code: 'ABCD-EFGH',
28
- verification_uri: 'http://mock/activate',
29
- verification_uri_complete: 'http://mock/activate?code=ABCD-EFGH',
30
- expires_in: 900,
31
- interval: 5,
32
- });
33
- }
34
- if (req.url === '/api/device/token') {
35
- if (expireEverything) return send(400, { error: 'expired_token' });
36
- if (pollsUntilApproved-- > 0) return send(400, { error: 'authorization_pending' });
37
- return send(200, { token: `looop_${'e'.repeat(64)}`, user_id: 'u_fran' });
38
- }
39
- if (req.url === '/api/me') {
40
- const ok = req.headers.authorization === `Bearer looop_${'e'.repeat(64)}`;
41
- return ok ? send(200, { userId: 'u_fran', name: 'Fran', color: '#38bdf8' }) : send(401, { error: 'unauthenticated' });
42
- }
43
- send(404, { error: 'nope' });
44
- });
45
- await new Promise((r) => server.listen(0, r));
46
- const apiBase = `http://localhost:${server.address().port}`;
47
-
48
- after(() => {
49
- server.close();
50
- rmSync(process.env.LOOOP_HOME, { recursive: true, force: true });
51
- });
52
-
53
- test('login polls to a token, stores it, and never prints the secret', async () => {
54
- const lines = [];
55
- const opened = [];
56
- const result = await login({
57
- apiBase,
58
- log: (s) => lines.push(s),
59
- openBrowser: (url) => opened.push(url),
60
- pollIntervalMsOverride: 10,
61
- });
62
- assert.equal(result.userId, 'u_fran');
63
- assert.equal(getToken(), `looop_${'e'.repeat(64)}`);
64
- const out = lines.join('\n');
65
- assert.match(out, /ABCD-EFGH/);
66
- assert.match(out, /http:\/\/mock\/activate/);
67
- assert.ok(!out.includes('e'.repeat(64)), 'token leaked to output');
68
- assert.deepEqual(opened, ['http://mock/activate?code=ABCD-EFGH']);
69
- });
70
-
71
- test('whoami reports the authenticated identity', async () => {
72
- const lines = [];
73
- const me = await whoami({ apiBase, log: (s) => lines.push(s) });
74
- assert.equal(me.name, 'Fran');
75
- assert.match(lines.join('\n'), /Fran/);
76
- });
77
-
78
- test('an expired code fails with a clear retry message', async () => {
79
- expireEverything = true;
80
- await assert.rejects(
81
- () => login({ apiBase, log: () => {}, openBrowser: () => {}, pollIntervalMsOverride: 10 }),
82
- /expired.*looop login/is,
83
- );
84
- });
85
-
86
- test('whoami without a valid token says so', async () => {
87
- setToken('looop_' + 'f'.repeat(64));
88
- await assert.rejects(() => whoami({ apiBase, log: () => {} }), /not logged in|unauthenticated/i);
89
- });
package/lib/npm.test.mjs DELETED
@@ -1,61 +0,0 @@
1
- // Every npm invocation in the CLI goes through runNpm. Pins the two things
2
- // that made `looop create` unusable on Windows (first creator bug report,
3
- // 2026-07-11: `looop create: spawnSync npm ENOENT`):
4
- //
5
- // 1. npm on Windows is `npm.cmd`, and Node's execFile/spawn cannot launch
6
- // a .bat/.cmd shim. Renaming to 'npm.cmd' does NOT fix it — since the
7
- // CVE-2024-27980 fix (Node >= 18.20.2) that path throws EINVAL instead
8
- // of ENOENT. The shim has to go through cmd.exe, which is what
9
- // cross-spawn does (with the arg escaping the raw `shell: true` route
10
- // would drop — engine tarball paths live under the user's home, and
11
- // "C:\Users\John Smith\..." has a space in it).
12
- // 2. Callers (create, ensureEngine) rely on a FAILED npm install throwing.
13
- // cross-spawn's sync API returns a status instead, so runNpm re-throws.
14
- import { test } from 'node:test';
15
- import assert from 'node:assert/strict';
16
- import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs';
17
- import { tmpdir } from 'node:os';
18
- import { join } from 'node:path';
19
- import { runNpm } from './npm.mjs';
20
-
21
- const base = mkdtempSync(join(tmpdir(), 'looop-npm-'));
22
-
23
- test('runNpm reaches the real npm on this platform', () => {
24
- const out = runNpm(['--version'], { cwd: base, stdio: 'pipe' }).stdout.toString().trim();
25
- assert.match(out, /^\d+\.\d+\.\d+/, 'npm --version came back, so the binary resolved');
26
- });
27
-
28
- test('a failed npm run throws — create/ensureEngine treat a throw as the failure signal', () => {
29
- assert.throws(
30
- () => runNpm(['run', 'no-such-script-here'], { cwd: base, stdio: 'pipe' }),
31
- /npm run no-such-script-here/,
32
- 'non-zero exit surfaces as an Error, not a silent status',
33
- );
34
- });
35
-
36
- // The regression guard. The bug was not "one call site got it wrong" — it was
37
- // that ANY bare 'npm' string reaches Windows broken, and we had two of them
38
- // (create.mjs and engine.mjs) plus the smokes a Windows CI lane has to run.
39
- // This test fails the moment someone reintroduces one.
40
- test('nothing spawns a bare `npm` — every call site goes through runNpm', () => {
41
- const root = join(import.meta.dirname, '..');
42
- const files = [
43
- ...readdirSync(join(root, 'lib')).map((f) => join('lib', f)),
44
- ...readdirSync(join(root, 'bin')).map((f) => join('bin', f)),
45
- ...readdirSync(root).filter((f) => f.endsWith('.smoke.mjs')),
46
- ].filter((f) => /\.(mjs|js)$/.test(f) && !f.endsWith('lib/npm.mjs') && !f.endsWith('lib/npm.test.mjs'));
47
-
48
- const offenders = [];
49
- for (const f of files) {
50
- const src = readFileSync(join(root, f), 'utf8');
51
- src.split('\n').forEach((line, i) => {
52
- // execFileSync('npm', …) / spawnSync('npm', …) / spawn('npm', …)
53
- if (/(?:execFileSync|execFile|spawnSync|spawn)\(\s*['"]npm(?:\.cmd)?['"]/.test(line)) {
54
- offenders.push(`${f}:${i + 1} ${line.trim()}`);
55
- }
56
- });
57
- }
58
- assert.deepEqual(offenders, [], `bare npm spawn is ENOENT/EINVAL on Windows — use runNpm():\n${offenders.join('\n')}`);
59
- });
60
-
61
- process.on('exit', () => rmSync(base, { recursive: true, force: true }));