@looop-games/cli 0.1.14 → 0.1.16

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/CHANGELOG.md CHANGED
@@ -14,6 +14,41 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.16] - 2026-07-13
18
+
19
+ ### Added
20
+ - `looop lane <name>` — open an isolated copy of your game to experiment in. It
21
+ lives in its own folder, on its own branch, with its own dev stack, so an
22
+ experiment can't disturb the game you're in the middle of. Whatever you have
23
+ unsaved stays exactly where it is. Like the winner? Merge it. Don't? Delete
24
+ the folder. The lane borrows the engine from your main folder rather than
25
+ downloading it again, so opening one is close to instant. Your agent knows
26
+ when to offer this — ask it for `/worktree`.
27
+
28
+ ### Fixed
29
+ - **`looop publish` no longer guesses a name inside a lane.** Publishing claims
30
+ a name permanently, and the name defaults to your folder — so publishing from
31
+ a lane would have claimed `<game>-<lane>` forever for a throwaway experiment
32
+ (and, if your `package.json` sets `looop.slug`, would have overwritten your
33
+ live game with it). From a lane, publish now asks you to say the name out
34
+ loud: `--slug <name>`. Publishing your actual game is unchanged.
35
+ - **`looop dev` no longer kills a dev server that isn't its own.** It used to
36
+ take over ports 8000/1999/8788 unconditionally, so a second game — or a lane —
37
+ would shut down the game you already had running and steal its ports. Now it
38
+ steps around anything else on those ports and picks the next free set, telling
39
+ you which it took. Run as many games and lanes at once as you like; they no
40
+ longer fight. It still reclaims *its own* stale server, so you never end up
41
+ looking at yesterday's code.
42
+
43
+ ## [0.1.15] - 2026-07-13
44
+
45
+ ### Changed
46
+ - When a non-primitive file lands in your primitives folder, the error now sends
47
+ you to a `lib/` subfolder for the helper, instead of saying it can live
48
+ "anywhere in your game". It can't: a primitive runs in the browser too (to
49
+ predict with), so a helper outside the `/shared/` tree resolves on the room
50
+ server but 404s in the browser. `lib/` is reachable from both sides.
51
+
17
52
  ## [0.1.14] - 2026-07-13
18
53
 
19
54
  > How to write a room primitive — the folder, the filename-is-type rule, and
package/bin/looop.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // Runs inside a standalone game folder (its own repo). The agent is the
5
5
  // primary user: keep output plain, actionable, and machine-legible.
6
6
  import { dev } from '../lib/dev.mjs';
7
+ import { lane } from '../lib/lane.mjs';
7
8
  import { login, whoami } from '../lib/login.mjs';
8
9
  import { publish } from '../lib/publish.mjs';
9
10
  import { create } from '../lib/create.mjs';
@@ -26,6 +27,7 @@ const HELP = `looop — build and run Looop games
26
27
  Usage:
27
28
  looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
28
29
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
30
+ looop lane <name> Open an isolated copy of the game to experiment in, safely
29
31
  looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
30
32
  looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
31
33
  looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
@@ -38,7 +40,9 @@ Usage:
38
40
  looop help This message
39
41
 
40
42
  The dev stack serves your game at http://localhost:8000/games/<name>/index.html
41
- with local multiplayer on :1999 and the platform services shim on :8788.
43
+ with local multiplayer on :1999 and the platform services shim on :8788. Run
44
+ several at once (a lane, another game) and each picks its own free ports —
45
+ 'looop dev' never disturbs a stack that isn't its own.
42
46
  `;
43
47
 
44
48
  try {
@@ -54,6 +58,13 @@ try {
54
58
  // Keep the process alive; servers + children hold the loop open.
55
59
  break;
56
60
  }
61
+ case 'lane': {
62
+ const name = rest.find((a) => !a.startsWith('--'));
63
+ if (!name) throw new Error('Name the lane: looop lane <name> (e.g. looop lane judder)');
64
+ const { dir } = await lane(name);
65
+ console.log(`\nWork in it:\n cd ${dir} && npx looop dev\n`);
66
+ break;
67
+ }
57
68
  case 'create':
58
69
  await create({ name: rest.find((a) => !a.startsWith('--')) });
59
70
  break;
package/lib/dev.mjs CHANGED
@@ -10,13 +10,13 @@
10
10
  import { spawn } from 'node:child_process';
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
- import { join, dirname } from 'node:path';
13
+ import { join, dirname, basename } from 'node:path';
14
14
  import { findProject } from './project.mjs';
15
15
  import { ensureEngine } from './engine.mjs';
16
16
  import { createStaticServer } from './static-server.mjs';
17
17
  import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
18
18
  import { getToken, getApiBase } from './config.mjs';
19
- import { portsFor, portInUse, killPort, lanIp } from './ports.mjs';
19
+ import { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
20
20
  import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
21
21
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
22
 
@@ -52,7 +52,21 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
52
52
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
53
53
  // the platform's login-gated registry (no-op when the pinned version is in).
54
54
  const engine = await ensureEngine(project.dir, { log });
55
- const ports = portsFor(port ?? Number(process.env.LOOOP_DEV_PORT ?? 8000));
55
+
56
+ // Which ports, and may we take them? An explicit --port is obeyed as given
57
+ // (takeover included). With no flag we step around anyone else's stack —
58
+ // another lane, another game, an unrelated app — and only ever reclaim our
59
+ // OWN stale server. See ports.mjs; this is what makes a lane collision-free
60
+ // without the creator having to remember a flag.
61
+ const explicit = port ?? (process.env.LOOOP_DEV_PORT ? Number(process.env.LOOOP_DEV_PORT) : null);
62
+ const { ports, tookOver, steppedAside } = await resolvePorts({
63
+ projectDir: project.dir,
64
+ requested: explicit,
65
+ });
66
+ for (const s of steppedAside) {
67
+ const who = s.projectDir ? `another Looop game (${basename(s.projectDir)})` : 'another app';
68
+ log(`→ :${s.port} (held by ${who} — leaving it alone)`);
69
+ }
56
70
  const children = [];
57
71
  const servers = [];
58
72
 
@@ -89,8 +103,11 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
89
103
  };
90
104
 
91
105
  // ─── static ───
92
- if (await portInUse(ports.static)) {
93
- log(`→ static :${ports.static} (in use killing to take over)`);
106
+ // `tookOver` is the ONLY licence to kill: an explicit --port, or our own
107
+ // stale server on this block. resolvePorts already walked away from
108
+ // everyone else's, so anything still here is fair game.
109
+ if (tookOver && (await portInUse(ports.static))) {
110
+ log(`→ static :${ports.static} (our stale server — reclaiming)`);
94
111
  await killPort(ports.static);
95
112
  }
96
113
  // overrides/shared/… : the game's engine modifications mount ahead of the
@@ -108,6 +125,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
108
125
  const overridesDir = join(project.dir, 'overrides', 'shared');
109
126
  const staticServer = createStaticServer({
110
127
  slug: project.slug,
128
+ projectDir: project.dir, // answers /__looop/whoami — see ports.mjs
111
129
  mounts: [
112
130
  { url: `/games/${project.slug}/`, dir: project.dir },
113
131
  { url: '/shared/', dir: generatedOverridesDir },
@@ -124,8 +142,8 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
124
142
  if (noMp) {
125
143
  log(`→ multiplayer :${ports.mp} (skipped — NO_MP=1)`);
126
144
  } else {
127
- if (await portInUse(ports.mp)) {
128
- log(`→ multiplayer :${ports.mp} (in usekilling to take over)`);
145
+ if (tookOver && (await portInUse(ports.mp))) {
146
+ log(`→ multiplayer :${ports.mp} (our stale server reclaiming)`);
129
147
  await killPort(ports.mp);
130
148
  }
131
149
  const pk = spawn(
package/lib/lane.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // `looop lane <name>` — open an isolated lane for an experiment.
2
+ //
3
+ // A lane is this game checked out a second time, in its own folder, on its own
4
+ // branch, with its own dev stack. Work in a lane cannot disturb the main
5
+ // checkout: different files, different branch, different servers, different
6
+ // multiplayer room. Win → merge it. Lose → delete the folder; nothing that
7
+ // mattered was ever touched.
8
+ //
9
+ // This exists because the lane used to be a PROCEDURE — a bash block in the
10
+ // build skill: commit-guard, `git worktree add`, `npm install`, and remember a
11
+ // distinct `--port` or you'd kill the main checkout's servers. Four steps,
12
+ // three of them footguns, one of them slow. Agents read that, priced the
13
+ // ceremony, and skipped isolating altogether — repeatedly. The COST was the
14
+ // bug. A procedure a tool can just perform should never be written down and
15
+ // hoped for.
16
+ //
17
+ // What used to be expensive, and isn't any more:
18
+ //
19
+ // npm install → node_modules is CLONED from the main checkout. On APFS
20
+ // (macOS) and btrfs/xfs (Linux) that's a copy-on-write
21
+ // reflink: effectively instant, near-zero extra disk. The
22
+ // engine is already sitting one directory up; resolving it
23
+ // again over the network was always silly.
24
+ // --port 8010 → gone. `looop dev` resolves its own ports now and steps
25
+ // around anyone else's stack (ports.mjs). A lane just runs
26
+ // `npx looop dev`, like everywhere else.
27
+ import { execFileSync } from 'node:child_process';
28
+ import { cpSync, existsSync, statSync } from 'node:fs';
29
+ import { basename, join, resolve } from 'node:path';
30
+ import { findProject } from './project.mjs';
31
+ import { runNpm } from './npm.mjs';
32
+
33
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
34
+
35
+ // Is this folder a lane (a linked git worktree) rather than the game itself?
36
+ //
37
+ // Git's own marker, no subprocess: in a linked worktree `.git` is a FILE
38
+ // holding `gitdir: …/.git/worktrees/<name>`; in an ordinary repo it's a
39
+ // directory. A folder with no git at all is not a lane.
40
+ //
41
+ // This matters to `publish`, which is global, permanent and irreversible — see
42
+ // the guard there.
43
+ export function isLane(dir) {
44
+ try {
45
+ return statSync(join(dir, '.git')).isFile();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // Clone a directory tree as cheaply as the filesystem allows.
52
+ //
53
+ // `cp -c` (macOS) and `cp --reflink=auto` (Linux) ask for copy-on-write: the
54
+ // new tree shares its blocks with the old one until something writes. For a
55
+ // node_modules of tens of thousands of files that is the difference between
56
+ // "instant" and "go and make a coffee". Neither is guaranteed — a non-APFS
57
+ // volume, a filesystem without reflinks — so both fall back to a real copy,
58
+ // which is still local, and still beats a network resolve.
59
+ export function cloneTree(src, dest) {
60
+ const args =
61
+ process.platform === 'darwin'
62
+ ? ['-c', '-R', src, dest] // APFS clonefile
63
+ : process.platform === 'linux'
64
+ ? ['-r', '--reflink=auto', src, dest] // btrfs/xfs, degrades to a copy
65
+ : null;
66
+ if (args) {
67
+ try {
68
+ execFileSync('cp', args, { stdio: 'ignore' });
69
+ return 'cloned';
70
+ } catch {
71
+ // Not a CoW filesystem (or no `cp` worth the name — Windows). Fall through.
72
+ }
73
+ }
74
+ cpSync(src, dest, { recursive: true });
75
+ return 'copied';
76
+ }
77
+
78
+ export async function lane(name, { cwd = process.cwd(), log = console.log } = {}) {
79
+ const project = findProject(cwd);
80
+ const slug = project.slug;
81
+ const branch = `lane/${name}`;
82
+ const dir = resolve(project.dir, '..', `${slug}-${name}`);
83
+
84
+ if (existsSync(dir)) {
85
+ throw new Error(
86
+ `A lane called "${name}" already exists at ${dir}.\n` +
87
+ `Work in it (cd ${basename(dir)} && npx looop dev), or remove it first ` +
88
+ `(git worktree remove ${dir}).`,
89
+ );
90
+ }
91
+
92
+ // `git worktree add` on a repo with NO commits silently produces an empty
93
+ // orphan lane — the folder exists, the game isn't in it. To a creator that
94
+ // reads as "the tool deleted my game". Save first, but ONLY in that case:
95
+ // committing on someone's behalf when they didn't ask is its own betrayal.
96
+ let savedFirst = false;
97
+ try {
98
+ git(project.dir, 'rev-parse', 'HEAD');
99
+ } catch {
100
+ log('→ saving your game first (a lane needs at least one save to branch from)');
101
+ git(project.dir, 'add', '-A');
102
+ git(project.dir, 'commit', '-m', 'save: initial');
103
+ savedFirst = true;
104
+ }
105
+
106
+ // Their uncommitted work is deliberately NOT carried across: the lane starts
107
+ // from the last save. That IS the isolation — the whole point is that what
108
+ // they're in the middle of stays exactly where it is, untouched.
109
+ git(project.dir, 'worktree', 'add', '-b', branch, dir);
110
+ log(`→ lane ${dir} (branch ${branch})`);
111
+
112
+ // node_modules is gitignored, so the lane arrives empty. Without it, `npx
113
+ // looop` falls through to a WRONG public package on npm and dies with an
114
+ // error that reads like a bug in the creator's game.
115
+ const src = join(project.dir, 'node_modules');
116
+ let installed = false;
117
+ if (existsSync(src)) {
118
+ const how = cloneTree(src, join(dir, 'node_modules'));
119
+ log(`→ engine ${how} from the main checkout (no npm install needed)`);
120
+ } else {
121
+ log('→ engine installing (the main checkout has no node_modules to clone)');
122
+ runNpm(['install'], { cwd: dir, stdio: 'inherit' });
123
+ installed = true;
124
+ }
125
+
126
+ return { dir, branch, savedFirst, installed };
127
+ }
package/lib/ports.mjs CHANGED
@@ -8,6 +8,25 @@
8
8
  // Takeover: `looop dev` must "just work" — a stale dev session or orphaned
9
9
  // workerd holding a port means the user gets stale code with no recourse, so
10
10
  // we TERM (then KILL) the listener instead of skipping, exactly like dev.sh.
11
+ //
12
+ // …but ONLY our own. Takeover used to be unconditional: `portInUse()` is a bare
13
+ // TCP connect, so dev killed whatever answered without asking whose it was. In
14
+ // a worktree lane that meant a plain `npx looop dev` SIGKILLed the MAIN
15
+ // checkout's whole stack (game + multiplayer + services) and stole its ports —
16
+ // the tool manufacturing the exact collision a lane exists to prevent, with a
17
+ // remembered `--port 8010` as the only defence. Isolation you have to remember
18
+ // isn't isolation.
19
+ //
20
+ // So a port now has an OWNER, and the answer decides:
21
+ //
22
+ // free → take it
23
+ // our own dev server → take it OVER (the stale-code case above)
24
+ // another project's, or a
25
+ // foreign app's → LEAVE IT and step to the next block
26
+ //
27
+ // Ownership is asked, not inferred: the static server answers `/__looop/whoami`
28
+ // with its project dir (static-server.mjs). No registry file to go stale, and
29
+ // nothing to clean up after a crash — if it answers, it's alive.
11
30
  import { execFileSync } from 'node:child_process';
12
31
  import net from 'node:net';
13
32
  import { networkInterfaces } from 'node:os';
@@ -16,11 +35,90 @@ export const STATIC_PORT_BASE = 8000;
16
35
  export const MP_PORT_BASE = 1999;
17
36
  export const SHIM_PORT_BASE = 8788;
18
37
 
38
+ // Ports move in BLOCKS: the browser derives mp/shim from location.port, so the
39
+ // whole stack shifts together. 8000/1999/8788 → 8010/2009/8798 → …
40
+ export const BLOCK_STRIDE = 10;
41
+ export const WHOAMI_PATH = '/__looop/whoami';
42
+
19
43
  export function portsFor(staticPort = STATIC_PORT_BASE) {
20
44
  const offset = staticPort - STATIC_PORT_BASE;
21
45
  return { static: staticPort, mp: MP_PORT_BASE + offset, shim: SHIM_PORT_BASE + offset };
22
46
  }
23
47
 
48
+ // Who is on this port? → { projectDir, pid } if one of our dev servers answers,
49
+ // null if the port is silent, or if something that isn't us holds it (a foreign
50
+ // app owes us no answer — and either way the verdict is "not mine, don't touch").
51
+ export function whoIsOn(port, timeoutMs = 400) {
52
+ return new Promise((resolve) => {
53
+ const req = net.connect({ port, host: '127.0.0.1' });
54
+ let body = '';
55
+ const done = (v) => {
56
+ req.destroy();
57
+ resolve(v);
58
+ };
59
+ req.setTimeout(timeoutMs, () => done(null));
60
+ req.on('error', () => done(null)); // nothing listening
61
+ req.on('connect', () => {
62
+ req.write(`GET ${WHOAMI_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n`);
63
+ });
64
+ req.on('data', (chunk) => {
65
+ body += chunk;
66
+ const i = body.indexOf('\r\n\r\n');
67
+ if (i === -1) return; // headers still arriving
68
+ if (!/^HTTP\/1\.[01] 200/.test(body)) return done(null); // answered, but not ours
69
+ try {
70
+ const parsed = JSON.parse(body.slice(i + 4));
71
+ done(parsed.projectDir ? parsed : null);
72
+ } catch {
73
+ /* body may still be arriving; wait for more, or for the timeout */
74
+ }
75
+ });
76
+ req.on('end', () => done(null));
77
+ });
78
+ }
79
+
80
+ // Pick the port block this dev stack should bind.
81
+ //
82
+ // `requested` (an explicit --port) is obeyed as given, takeover included: you
83
+ // named the port, you get the port. Explicit beats clever — it's the AUTO path
84
+ // that has to be safe, because that's the one nobody thought about.
85
+ // `base` exists so the tests can run in a private port range: a suite that
86
+ // probes :8000 for real would fight (and report on) whatever dev servers the
87
+ // developer has running — which is how this file's own tests first "failed".
88
+ export async function resolvePorts({
89
+ projectDir,
90
+ requested = null,
91
+ blocks = 24,
92
+ base = STATIC_PORT_BASE,
93
+ } = {}) {
94
+ if (requested != null) {
95
+ return { ports: portsFor(requested), tookOver: true, steppedAside: [] };
96
+ }
97
+ const steppedAside = [];
98
+ for (let i = 0; i < blocks; i++) {
99
+ const ports = portsFor(base + i * BLOCK_STRIDE);
100
+ const owner = (await portInUse(ports.static)) ? await whoIsOn(ports.static) : undefined;
101
+
102
+ if (owner === undefined) {
103
+ // The game port is free — but a block only isolates if the WHOLE stack
104
+ // fits in it. Half a stack here and half somewhere else is worse than
105
+ // moving on, so a contaminated block is skipped entire.
106
+ if ((await portInUse(ports.mp)) || (await portInUse(ports.shim))) {
107
+ steppedAside.push({ port: ports.static, projectDir: null });
108
+ continue;
109
+ }
110
+ return { ports, tookOver: false, steppedAside };
111
+ }
112
+ if (owner && owner.projectDir === projectDir) {
113
+ return { ports, tookOver: true, steppedAside }; // our own stale server
114
+ }
115
+ steppedAside.push({ port: ports.static, projectDir: owner?.projectDir ?? null });
116
+ }
117
+ throw new Error(
118
+ `No free port block after trying ${blocks}. Close a dev server, or pass --port explicitly.`,
119
+ );
120
+ }
121
+
24
122
  export function portInUse(port) {
25
123
  return new Promise((resolve) => {
26
124
  const sock = net.connect({ port, host: '127.0.0.1' });
@@ -106,8 +106,12 @@ export function scanPrimitives(projectDir) {
106
106
  throw new Error(
107
107
  `${PRIMITIVES_DIR}/${name} has no default export, so it cannot be a primitive.\n` +
108
108
  'A primitive is `export default class { ... }`, and its filename is its type name.\n' +
109
- `If ${name} is a HELPER your primitive imports, move it out of this folder anywhere else in\n` +
110
- `your game works, or a subfolder here (e.g. ${PRIMITIVES_DIR}/lib/${name}), which is not scanned.`,
109
+ `If ${name} is a HELPER your primitive imports, put it in a lib/ subfolder here —\n` +
110
+ ` ${PRIMITIVES_DIR}/lib/${name}\n` +
111
+ `and import it as './lib/${name}'. The scanner skips lib/, and a primitive runs on BOTH\n` +
112
+ 'sides — bundled into the room AND served to the browser to predict with — so its helper\n' +
113
+ 'has to sit under this /shared/ tree to be reachable from both. Anywhere else in your game\n' +
114
+ 'resolves for the room but 404s in the browser.',
111
115
  );
112
116
  }
113
117
  found.push({ type: name.slice(0, -3), module: `${PRIMITIVES_DIR}/${name}` });
package/lib/publish.mjs CHANGED
@@ -13,6 +13,7 @@ import { createHash } from 'node:crypto';
13
13
  import { readdirSync, readFileSync, statSync } from 'node:fs';
14
14
  import { join, relative } from 'node:path';
15
15
  import { findProject } from './project.mjs';
16
+ import { isLane } from './lane.mjs';
16
17
  import { ensureEngine } from './engine.mjs';
17
18
  import { getToken, getApiBase } from './config.mjs';
18
19
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
@@ -65,6 +66,26 @@ export async function publish({
65
66
  log = console.log,
66
67
  } = {}) {
67
68
  const project = findProject(cwd);
69
+
70
+ // A publish is GLOBAL, PERMANENT and IRREVERSIBLE — the first one claims the
71
+ // name for this account forever. A lane is a throwaway experiment in a folder
72
+ // named after it, and the slug defaults to the folder name, so a bare publish
73
+ // from one would claim "riven-judder" for all time (or 403 against whoever
74
+ // owns it). And when a game overrides `looop.slug`, the lane inherits it and
75
+ // would silently overwrite the LIVE game with the experiment.
76
+ //
77
+ // A name this permanent is never guessed inside a lane. The guard is on
78
+ // GUESSING, not on publishing: name it with --slug and it goes through.
79
+ if (!slug && isLane(project.dir)) {
80
+ throw new Error(
81
+ `This is a lane, not your game. Publishing from here would claim the name ` +
82
+ `"${project.slug}" permanently.\n\n` +
83
+ ` npx looop publish --slug ${project.slug}-test # publish an A/B copy to compare\n` +
84
+ ` npx looop publish --slug <your-game> # ship this lane AS the real game\n\n` +
85
+ `Or merge the lane into your game and publish from there.`,
86
+ );
87
+ }
88
+
68
89
  // The declared engineVersion must be a version the platform can resolve —
69
90
  // ensureEngine keeps the local install synced to the game's pin.
70
91
  const engine = await ensureEngine(project.dir, { apiBase, log });
@@ -13,6 +13,7 @@ import { EventEmitter } from 'node:events';
13
13
  import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
14
14
  import { extname, join, normalize, sep } from 'node:path';
15
15
  import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL } from './inject.mjs';
16
+ import { WHOAMI_PATH } from './ports.mjs';
16
17
 
17
18
  const RELOAD_CLIENT = `<script>
18
19
  (() => {
@@ -96,6 +97,7 @@ function mtimesChanged(a, b) {
96
97
  export function createStaticServer({
97
98
  slug,
98
99
  mounts,
100
+ projectDir = null,
99
101
  watchDirs = [],
100
102
  injectReload = true,
101
103
  watchIntervalMs = 400,
@@ -214,6 +216,18 @@ export function createStaticServer({
214
216
  const reqUrl = new URL(req.url, 'http://x');
215
217
  const urlPath = reqUrl.pathname;
216
218
  if (urlPath === '/__reload') return handleSse(res);
219
+ // Ownership probe (ports.mjs): "whose dev server is this?". It is what lets
220
+ // another `looop dev` — a lane, another game — tell OUR stale server (kill
221
+ // and reclaim) from a live one that belongs to somebody else (step around,
222
+ // never touch). Answering honestly here is what keeps lanes from colliding.
223
+ if (urlPath === WHOAMI_PATH) {
224
+ return sendBody(
225
+ res,
226
+ 200,
227
+ JSON.stringify({ projectDir, slug, pid: process.pid }),
228
+ 'application/json',
229
+ );
230
+ }
217
231
  if (urlPath === '/' || urlPath === '/index.html') {
218
232
  res.writeHead(302, { Location: gameEntry });
219
233
  return res.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",