@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 +35 -0
- package/bin/looop.mjs +12 -1
- package/lib/dev.mjs +25 -7
- package/lib/lane.mjs +127 -0
- package/lib/ports.mjs +98 -0
- package/lib/primitives.mjs +6 -2
- package/lib/publish.mjs +21 -0
- package/lib/static-server.mjs +14 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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} (
|
|
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' });
|
package/lib/primitives.mjs
CHANGED
|
@@ -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,
|
|
110
|
-
`
|
|
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 });
|
package/lib/static-server.mjs
CHANGED
|
@@ -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();
|