@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/inject.mjs ADDED
@@ -0,0 +1,78 @@
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:
3
+ //
4
+ // * Head injection mirrors the production /g/<slug> serve path
5
+ // (builder/functions/g/[[path]].ts): window.GAME_SLUG + window.LOOOP_IDENTITY
6
+ // (classic scripts, so globals exist before any module runs) + the ONE
7
+ // platform-layer module tag. New platform features are added to
8
+ // shared/platform/platform.js FEATURES — never here.
9
+ // * ?v=<mtime> rewriting on <script src> and JS imports busts Chrome's
10
+ // sticky module cache on edit.
11
+ import { statSync } from 'node:fs';
12
+ import { join, dirname, normalize } from 'node:path';
13
+
14
+ // MUST match builder/functions/_shared/serve-identity.ts DEV_IDENTITY.
15
+ export const DEV_IDENTITY = { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' };
16
+
17
+ export const PLATFORM_URL = '/shared/platform/platform.js';
18
+
19
+ // JSON safe for an inline <script>: `<` → < so a value containing
20
+ // "</script>" can't terminate the script early.
21
+ function jsJson(value) {
22
+ return JSON.stringify(value).replaceAll('<', '\\u003c');
23
+ }
24
+
25
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL } = {}) {
26
+ const parts = [];
27
+ if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
28
+ parts.push(`<script>window.LOOOP_IDENTITY = ${jsJson(identity)};</script>`);
29
+ parts.push(
30
+ `<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
31
+ );
32
+ const inject = '\n ' + parts.join('\n ');
33
+ const m = /<head[^>]*>/i.exec(html);
34
+ if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
35
+ return parts.join('\n') + '\n' + html;
36
+ }
37
+
38
+ // Match `import ... from "X"`, `import "X"`, `import("X")`, `export ... from "X"`.
39
+ const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
40
+ // Match <script src="X"> in HTML.
41
+ const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
42
+
43
+ // Resolve `relPath` and return `<relPath>?v=<mtime-seconds>` or null.
44
+ // `resolveUrl(urlPath)` maps an absolute URL path through the server's mounts.
45
+ function versionedPath(relPath, { baseDir, resolveUrl, allowBareRelative = false }) {
46
+ if (/^(https?:)?\/\//.test(relPath) || relPath.startsWith('data:') || relPath.startsWith('blob:')) return null;
47
+ if (relPath.includes('?') || relPath.includes('#')) return null;
48
+ if (!/\.(js|mjs)$/.test(relPath)) return null;
49
+ let target;
50
+ if (relPath.startsWith('/')) target = resolveUrl(relPath);
51
+ else if (relPath.startsWith('.')) target = normalize(join(baseDir, relPath));
52
+ else if (allowBareRelative) target = normalize(join(baseDir, relPath));
53
+ else return null; // bare module specifier
54
+ if (!target) return null;
55
+ try {
56
+ const mtime = Math.floor(statSync(target).mtimeMs / 1000);
57
+ return `${relPath}?v=${mtime}`;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function rewriteJsImports(text, { baseDir, resolveUrl }) {
64
+ return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
65
+ const v = versionedPath(path, { baseDir, resolveUrl });
66
+ return `${prefix}${quote}${v ?? path}${quote}`;
67
+ });
68
+ }
69
+
70
+ export function rewriteHtmlScripts(html, { baseDir, resolveUrl }) {
71
+ return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
72
+ const v = versionedPath(path, { baseDir, resolveUrl, allowBareRelative: true });
73
+ return `${prefix}${v ?? path}${suffix}`;
74
+ });
75
+ }
76
+
77
+ // For callers that need the base dir of a served file.
78
+ export { dirname };
@@ -0,0 +1,87 @@
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
+ });
@@ -0,0 +1,74 @@
1
+ // Local platform-services shim (:8788) — decision Q3b in cuqfzo.
2
+ //
3
+ // Thin auth/CORS plumbing between a game running on localhost and the
4
+ // platform's services API (LLM today; whatever comes next). It forwards
5
+ // /api/* and /lb* to the platform, attaching the creator's token as a Bearer
6
+ // header. It is deliberately NOT a local implementation of any service and
7
+ // never holds provider keys — those live only server-side.
8
+ //
9
+ // Port convention: :8788 canonically (shared/ui/llm/index.js derives
10
+ // 8788 + (staticPort − 8000) from location.port — same rule dev.sh uses).
11
+ import http from 'node:http';
12
+
13
+ const FORWARD_PREFIXES = ['/api/', '/lb'];
14
+
15
+ export const DEFAULT_API_BASE = 'https://play.looop.games';
16
+
17
+ export function createLlmShim({ apiBase = DEFAULT_API_BASE, getToken = () => null } = {}) {
18
+ const server = http.createServer(async (req, res) => {
19
+ const urlPath = new URL(req.url, 'http://x').pathname;
20
+ if (!FORWARD_PREFIXES.some((p) => urlPath.startsWith(p))) {
21
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
22
+ return res.end('404 — this shim only forwards /api/* and /lb*');
23
+ }
24
+ const cors = {
25
+ 'Access-Control-Allow-Origin': '*',
26
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
27
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
28
+ };
29
+ if (req.method === 'OPTIONS') {
30
+ res.writeHead(204, cors);
31
+ return res.end();
32
+ }
33
+ const chunks = [];
34
+ for await (const c of req) chunks.push(c);
35
+ const body = Buffer.concat(chunks);
36
+ const headers = { 'Content-Type': req.headers['content-type'] ?? 'application/json' };
37
+ const token = getToken();
38
+ if (token) headers.Authorization = `Bearer ${token}`;
39
+ try {
40
+ const upstream = await fetch(apiBase + req.url, {
41
+ method: req.method,
42
+ headers,
43
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
44
+ });
45
+ const buf = Buffer.from(await upstream.arrayBuffer());
46
+ res.writeHead(upstream.status, {
47
+ ...cors,
48
+ 'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
49
+ 'Content-Length': buf.length,
50
+ });
51
+ res.end(buf);
52
+ } catch (err) {
53
+ res.writeHead(502, { ...cors, 'Content-Type': 'application/json' });
54
+ res.end(JSON.stringify({ error: 'upstream unreachable', detail: String(err?.cause ?? err), apiBase }));
55
+ }
56
+ });
57
+
58
+ return {
59
+ server,
60
+ get port() {
61
+ return server.address()?.port;
62
+ },
63
+ listen(port, bind = '0.0.0.0') {
64
+ return new Promise((resolveP, rejectP) => {
65
+ server.once('error', rejectP);
66
+ server.listen(port, bind, () => resolveP(server.address().port));
67
+ });
68
+ },
69
+ close() {
70
+ server.close();
71
+ server.closeAllConnections?.();
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,90 @@
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
+ });
package/lib/login.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // `looop login` — device-flow authentication (cuqfzo Slice 1, decisions
2
+ // Q2/Q6, the `gh auth login` shape):
3
+ //
4
+ // 1. ask the platform for a pairing (device_code for us, user_code for the
5
+ // human),
6
+ // 2. show the human the short code and open /activate in their browser,
7
+ // 3. poll until they approve,
8
+ // 4. store the minted creator token in ~/.looop/config.json.
9
+ //
10
+ // The secret never passes through the human's hands or the chat — non-dev
11
+ // creators can't fumble a 40-char paste (why web-paste was rejected).
12
+ import { spawn } from 'node:child_process';
13
+ import { setToken, getToken, getApiBase } from './config.mjs';
14
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ function defaultOpenBrowser(url) {
19
+ const [cmd, args] =
20
+ process.platform === 'darwin'
21
+ ? ['open', [url]]
22
+ : process.platform === 'win32'
23
+ ? ['cmd', ['/c', 'start', '', url]]
24
+ : ['xdg-open', [url]];
25
+ try {
26
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
27
+ } catch {
28
+ // No browser available (headless) — the printed URL is the fallback.
29
+ }
30
+ }
31
+
32
+ async function postJson(url, body) {
33
+ const res = await fetch(url, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(body ?? {}),
37
+ });
38
+ return { status: res.status, body: await res.json().catch(() => ({})) };
39
+ }
40
+
41
+ export async function login({
42
+ apiBase = getApiBase(DEFAULT_API_BASE),
43
+ log = console.log,
44
+ openBrowser = defaultOpenBrowser,
45
+ pollIntervalMsOverride,
46
+ } = {}) {
47
+ const { status, body: grant } = await postJson(`${apiBase}/api/device/code`);
48
+ if (status !== 200 || !grant.device_code) {
49
+ throw new Error(`could not start login against ${apiBase} (HTTP ${status})`);
50
+ }
51
+
52
+ log('');
53
+ log(' To connect this computer to your Looop account:');
54
+ log('');
55
+ log(` 1. Open ${grant.verification_uri}`);
56
+ log(` 2. Enter ${grant.user_code}`);
57
+ log('');
58
+ log(' (opening your browser…)');
59
+ openBrowser(grant.verification_uri_complete ?? grant.verification_uri);
60
+
61
+ const intervalMs = pollIntervalMsOverride ?? (grant.interval ?? 5) * 1000;
62
+ const deadline = Date.now() + (grant.expires_in ?? 900) * 1000;
63
+ while (Date.now() < deadline) {
64
+ await sleep(intervalMs);
65
+ const poll = await postJson(`${apiBase}/api/device/token`, { device_code: grant.device_code });
66
+ if (poll.status === 200 && poll.body.token) {
67
+ setToken(poll.body.token, { apiBase, userId: poll.body.user_id });
68
+ log('');
69
+ log(` ✅ Logged in. This machine now builds and publishes as your account (${poll.body.user_id}).`);
70
+ return { userId: poll.body.user_id };
71
+ }
72
+ if (poll.body.error === 'authorization_pending') continue;
73
+ if (poll.body.error === 'expired_token') {
74
+ throw new Error('that login code expired before it was approved — run `looop login` again for a fresh one.');
75
+ }
76
+ throw new Error(`login failed: ${poll.body.error ?? `HTTP ${poll.status}`}`);
77
+ }
78
+ throw new Error('login timed out — run `looop login` again.');
79
+ }
80
+
81
+ export async function whoami({ apiBase = getApiBase(DEFAULT_API_BASE), log = console.log } = {}) {
82
+ const token = getToken();
83
+ if (!token) throw new Error('not logged in — run `looop login` first.');
84
+ const res = await fetch(`${apiBase}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
85
+ if (res.status === 401) throw new Error('stored token was rejected (unauthenticated) — run `looop login` again.');
86
+ if (!res.ok) throw new Error(`whoami failed: HTTP ${res.status}`);
87
+ const me = await res.json();
88
+ log(`${me.name ?? me.userId} (${me.userId}) via ${apiBase}`);
89
+ return me;
90
+ }
@@ -0,0 +1,89 @@
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/ports.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // Port conventions + takeover, mirroring looop-core tools/game/dev.sh.
2
+ //
3
+ // Canonical ports: static :8000, partykit :1999, services shim :8788. The
4
+ // engine's browser clients derive the other two from location.port
5
+ // (1999/8788 + (static − 8000)), so a non-default --port shifts all three
6
+ // together and everything keeps lining up.
7
+ //
8
+ // Takeover: `looop dev` must "just work" — a stale dev session or orphaned
9
+ // workerd holding a port means the user gets stale code with no recourse, so
10
+ // we TERM (then KILL) the listener instead of skipping, exactly like dev.sh.
11
+ import { execFileSync } from 'node:child_process';
12
+ import net from 'node:net';
13
+ import { networkInterfaces } from 'node:os';
14
+
15
+ export const STATIC_PORT_BASE = 8000;
16
+ export const MP_PORT_BASE = 1999;
17
+ export const SHIM_PORT_BASE = 8788;
18
+
19
+ export function portsFor(staticPort = STATIC_PORT_BASE) {
20
+ const offset = staticPort - STATIC_PORT_BASE;
21
+ return { static: staticPort, mp: MP_PORT_BASE + offset, shim: SHIM_PORT_BASE + offset };
22
+ }
23
+
24
+ export function portInUse(port) {
25
+ return new Promise((resolve) => {
26
+ const sock = net.connect({ port, host: '127.0.0.1' });
27
+ const done = (v) => {
28
+ sock.destroy();
29
+ resolve(v);
30
+ };
31
+ sock.setTimeout(300, () => done(false));
32
+ sock.on('connect', () => done(true));
33
+ sock.on('error', () => done(false));
34
+ });
35
+ }
36
+
37
+ function findPids(port) {
38
+ const pids = new Set();
39
+ try {
40
+ if (process.platform === 'win32') {
41
+ const out = execFileSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 5000 });
42
+ const re = new RegExp(String.raw`\s\S+:${port}\s+\S+\s+LISTENING\s+(\d+)`, 'i');
43
+ for (const line of out.split('\n')) {
44
+ const m = re.exec(line);
45
+ if (m) pids.add(Number(m[1]));
46
+ }
47
+ } else {
48
+ const out = execFileSync('lsof', ['-ti', `:${port}`, '-sTCP:LISTEN'], {
49
+ encoding: 'utf8',
50
+ timeout: 5000,
51
+ });
52
+ for (const line of out.split('\n')) if (/^\d+$/.test(line.trim())) pids.add(Number(line.trim()));
53
+ }
54
+ } catch {
55
+ // lsof exits 1 when nothing listens — that's "no pids".
56
+ }
57
+ return pids;
58
+ }
59
+
60
+ function killPid(pid, force) {
61
+ try {
62
+ if (process.platform === 'win32') {
63
+ execFileSync('taskkill', [...(force ? ['/F'] : []), '/PID', String(pid)], { timeout: 5000 });
64
+ } else {
65
+ process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
66
+ }
67
+ } catch {
68
+ // Already gone.
69
+ }
70
+ }
71
+
72
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
73
+
74
+ export async function killPort(port) {
75
+ let pids = findPids(port);
76
+ if (!pids.size) return true;
77
+ for (const p of pids) killPid(p, false);
78
+ for (let i = 0; i < 10; i++) {
79
+ await sleep(200);
80
+ if (!findPids(port).size) return true;
81
+ }
82
+ for (const p of findPids(port)) killPid(p, true);
83
+ for (let i = 0; i < 10; i++) {
84
+ await sleep(200);
85
+ if (!findPids(port).size) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ export function lanIp() {
91
+ // First non-internal private IPv4 — the address a phone on the same wifi
92
+ // uses to reach this machine. Null when offline / no private interface.
93
+ for (const addrs of Object.values(networkInterfaces())) {
94
+ for (const a of addrs ?? []) {
95
+ if (a.family !== 'IPv4' || a.internal) continue;
96
+ if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address)) return a.address;
97
+ }
98
+ }
99
+ return null;
100
+ }
@@ -0,0 +1,52 @@
1
+ // Locate the standalone game project and its installed engine bundle.
2
+ //
3
+ // A project is the folder holding the game's package.json (created by
4
+ // `looop create`, or by hand). The slug — the published URL is
5
+ // play.looop.games/g/<slug> — defaults to the folder name; package.json's
6
+ // `looop.slug` overrides it for folks whose repo name differs.
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { basename, dirname, join, resolve } from 'node:path';
9
+
10
+ export function findProject(startDir = process.cwd()) {
11
+ let dir = resolve(startDir);
12
+ for (;;) {
13
+ const pkgPath = join(dir, 'package.json');
14
+ if (existsSync(pkgPath) && existsSync(join(dir, 'index.html'))) {
15
+ let pkg = {};
16
+ try {
17
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
18
+ } catch {
19
+ // Unparseable package.json: still treat as the project root; slug
20
+ // falls back to the folder name.
21
+ }
22
+ return { dir, slug: pkg?.looop?.slug || basename(dir), pkg };
23
+ }
24
+ const parent = dirname(dir);
25
+ if (parent === dir) {
26
+ throw new Error(
27
+ `Not inside a Looop game project (no folder with both package.json and index.html above ${startDir}). ` +
28
+ 'Run this from your game folder, or bootstrap one with `looop create <name>`.',
29
+ );
30
+ }
31
+ dir = parent;
32
+ }
33
+ }
34
+
35
+ export function resolveEngine(projectDir) {
36
+ const dir = join(projectDir, 'node_modules', '@looop-games', 'engine');
37
+ const pkgPath = join(dir, 'package.json');
38
+ if (!existsSync(pkgPath)) {
39
+ throw new Error(
40
+ 'the Looop engine is not installed in this project.\n' +
41
+ `Expected it at ${dir}.\n` +
42
+ 'Run `looop dev` (or `looop publish`) — it downloads the engine from the platform (sign-in required).',
43
+ );
44
+ }
45
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
46
+ return {
47
+ dir,
48
+ version: pkg.version,
49
+ sharedDir: join(dir, 'shared'),
50
+ roomServerDir: join(dir, 'room-server'),
51
+ };
52
+ }