@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/LICENSE +202 -0
- package/README.md +22 -0
- package/bin/looop.mjs +76 -0
- package/lib/agent-files.mjs +151 -0
- package/lib/config.mjs +51 -0
- package/lib/config.test.mjs +37 -0
- package/lib/create.mjs +142 -0
- package/lib/create.test.mjs +92 -0
- package/lib/dev.mjs +130 -0
- package/lib/engine.mjs +136 -0
- package/lib/engine.test.mjs +176 -0
- package/lib/inject.mjs +78 -0
- package/lib/inject.test.mjs +87 -0
- package/lib/llm-shim.mjs +74 -0
- package/lib/llm-shim.test.mjs +90 -0
- package/lib/login.mjs +90 -0
- package/lib/login.test.mjs +89 -0
- package/lib/ports.mjs +100 -0
- package/lib/project.mjs +52 -0
- package/lib/project.test.mjs +88 -0
- package/lib/publish.mjs +91 -0
- package/lib/publish.test.mjs +104 -0
- package/lib/static-server.mjs +260 -0
- package/lib/static-server.test.mjs +161 -0
- package/package.json +23 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// End-to-end (real HTTP) tests for the standalone dev static server. Pins the
|
|
2
|
+
// serving contract a game depends on: the /games/<slug>/ mount, the /shared/
|
|
3
|
+
// alias into the engine bundle, head injection + reload client on HTML, ?v=
|
|
4
|
+
// cache busting, directory-index injection (the bare-URL regression from
|
|
5
|
+
// dev_server.py), path-traversal containment, and SSE reload on file edits.
|
|
6
|
+
import { test, after } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { createStaticServer } from './static-server.mjs';
|
|
12
|
+
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), 'looop-static-'));
|
|
14
|
+
const game = join(root, 'pong');
|
|
15
|
+
const shared = join(root, 'engine-shared');
|
|
16
|
+
mkdirSync(join(game, 'assets'), { recursive: true });
|
|
17
|
+
mkdirSync(join(shared, 'platform'), { recursive: true });
|
|
18
|
+
mkdirSync(join(shared, 'ui/room'), { recursive: true });
|
|
19
|
+
writeFileSync(
|
|
20
|
+
join(game, 'index.html'),
|
|
21
|
+
'<html><head><title>pong</title></head><body><script type="module" src="main.js"></script></body></html>',
|
|
22
|
+
);
|
|
23
|
+
writeFileSync(join(game, 'main.js'), "import './world.js';\nwindow.started = true;\n");
|
|
24
|
+
writeFileSync(join(game, 'world.js'), 'export const w = 1;\n');
|
|
25
|
+
writeFileSync(join(game, 'assets/sprite.png'), 'not-really-a-png');
|
|
26
|
+
writeFileSync(join(shared, 'platform/platform.js'), 'export function installPlatform() {}\n');
|
|
27
|
+
writeFileSync(join(shared, 'ui/room/client.js'), "import './x.js';\nexport const c = 1;\n");
|
|
28
|
+
writeFileSync(join(shared, 'ui/room/x.js'), 'export const x = 1;\n');
|
|
29
|
+
writeFileSync(join(root, 'secret.txt'), 'nope');
|
|
30
|
+
|
|
31
|
+
const srv = createStaticServer({
|
|
32
|
+
slug: 'pong',
|
|
33
|
+
mounts: [
|
|
34
|
+
{ url: '/games/pong/', dir: game },
|
|
35
|
+
{ url: '/shared/', dir: shared },
|
|
36
|
+
],
|
|
37
|
+
watchDirs: [game, shared],
|
|
38
|
+
watchIntervalMs: 100,
|
|
39
|
+
});
|
|
40
|
+
await srv.listen(0);
|
|
41
|
+
const base = `http://localhost:${srv.port}`;
|
|
42
|
+
|
|
43
|
+
after(() => {
|
|
44
|
+
srv.close();
|
|
45
|
+
rmSync(root, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('game HTML gets head injection + reload client + versioned scripts', async () => {
|
|
49
|
+
const res = await fetch(`${base}/games/pong/index.html`);
|
|
50
|
+
assert.equal(res.status, 200);
|
|
51
|
+
assert.equal(res.headers.get('cache-control'), 'no-store, must-revalidate');
|
|
52
|
+
const html = await res.text();
|
|
53
|
+
assert.match(html, /window\.GAME_SLUG = "pong"/);
|
|
54
|
+
assert.match(html, /window\.LOOOP_IDENTITY = \{"userId":"dev-local-user"/);
|
|
55
|
+
assert.match(html, /installPlatform\(\)/);
|
|
56
|
+
assert.match(html, /EventSource\('\/__reload'\)/);
|
|
57
|
+
assert.match(html, /src="main\.js\?v=\d+"/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('bare directory URL serves index.html THROUGH the injecting path', async () => {
|
|
61
|
+
const html = await (await fetch(`${base}/games/pong/`)).text();
|
|
62
|
+
assert.match(html, /window\.GAME_SLUG = "pong"/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('root redirects to the game entry', async () => {
|
|
66
|
+
const res = await fetch(`${base}/`, { redirect: 'manual' });
|
|
67
|
+
assert.equal(res.status, 302);
|
|
68
|
+
assert.equal(res.headers.get('location'), '/games/pong/index.html');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('/shared/ serves the engine bundle with versioned imports', async () => {
|
|
72
|
+
const res = await fetch(`${base}/shared/ui/room/client.js`);
|
|
73
|
+
assert.equal(res.status, 200);
|
|
74
|
+
assert.match(res.headers.get('content-type'), /text\/javascript/);
|
|
75
|
+
assert.match(await res.text(), /'\.\/x\.js\?v=\d+'/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('game JS imports are versioned too', async () => {
|
|
79
|
+
const body = await (await fetch(`${base}/games/pong/main.js`)).text();
|
|
80
|
+
assert.match(body, /'\.\/world\.js\?v=\d+'/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('binary assets pass through with a sensible type', async () => {
|
|
84
|
+
const res = await fetch(`${base}/games/pong/assets/sprite.png`);
|
|
85
|
+
assert.equal(res.status, 200);
|
|
86
|
+
assert.match(res.headers.get('content-type'), /image\/png/);
|
|
87
|
+
assert.equal(await res.text(), 'not-really-a-png');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('path traversal cannot escape a mount', async () => {
|
|
91
|
+
for (const p of ['/shared/../secret.txt', '/games/pong/../../secret.txt', '/shared/%2e%2e/secret.txt']) {
|
|
92
|
+
const res = await fetch(base + p);
|
|
93
|
+
assert.notEqual(res.status, 200, `escaped via ${p}`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('unknown path 404s', async () => {
|
|
98
|
+
assert.equal((await fetch(`${base}/games/pong/nope.js`)).status, 404);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('?as=<name> injects a distinct dev identity — two-window MP testing (cuqfzo Slice 6 catch)', async () => {
|
|
102
|
+
// The room dedups same-account connections even for unverified dev claims,
|
|
103
|
+
// so two tabs as the constant dev-local-user evict each other. ?as= lets a
|
|
104
|
+
// human be two players locally; without it, identity is the standard one.
|
|
105
|
+
const plain = await (await fetch(`${base}/games/pong/index.html`)).text();
|
|
106
|
+
assert.match(plain, /"userId":"dev-local-user"/);
|
|
107
|
+
const asP2 = await (await fetch(`${base}/games/pong/index.html?as=p2`)).text();
|
|
108
|
+
assert.match(asP2, /"userId":"dev-local-p2"/);
|
|
109
|
+
assert.match(asP2, /"name":"p2"/);
|
|
110
|
+
// Hostile values can't break out of the inline script or the id charset.
|
|
111
|
+
const hostile = await (await fetch(`${base}/games/pong/index.html?as=${encodeURIComponent('</script><x>!!')}`)).text();
|
|
112
|
+
assert.ok(!hostile.includes('</script><x>'));
|
|
113
|
+
assert.match(hostile, /"userId":"dev-local-scriptx"/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('overrides mount shadows the engine per-file and falls through otherwise', async () => {
|
|
117
|
+
// Slice 2 (cuqfzo Q1): a game's overrides/shared/... wins over the bundle
|
|
118
|
+
// for THAT file only; everything else falls through to the next mount.
|
|
119
|
+
const overrides = join(root, 'game-overrides');
|
|
120
|
+
mkdirSync(join(overrides, 'ui/room'), { recursive: true });
|
|
121
|
+
writeFileSync(join(overrides, 'ui/room/x.js'), 'export const x = "OVERRIDDEN";\n');
|
|
122
|
+
const srv2 = createStaticServer({
|
|
123
|
+
slug: 'pong',
|
|
124
|
+
mounts: [
|
|
125
|
+
{ url: '/games/pong/', dir: game },
|
|
126
|
+
{ url: '/shared/', dir: overrides },
|
|
127
|
+
{ url: '/shared/', dir: shared },
|
|
128
|
+
],
|
|
129
|
+
watchDirs: [],
|
|
130
|
+
});
|
|
131
|
+
await srv2.listen(0);
|
|
132
|
+
try {
|
|
133
|
+
const overridden = await (await fetch(`http://localhost:${srv2.port}/shared/ui/room/x.js`)).text();
|
|
134
|
+
assert.match(overridden, /OVERRIDDEN/);
|
|
135
|
+
const fallThrough = await fetch(`http://localhost:${srv2.port}/shared/ui/room/client.js`);
|
|
136
|
+
assert.equal(fallThrough.status, 200);
|
|
137
|
+
assert.match(await fallThrough.text(), /export const c = 1/);
|
|
138
|
+
} finally {
|
|
139
|
+
srv2.close();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('editing a watched file pushes an SSE reload', async () => {
|
|
144
|
+
const res = await fetch(`${base}/__reload`);
|
|
145
|
+
assert.match(res.headers.get('content-type'), /text\/event-stream/);
|
|
146
|
+
const reader = res.body.getReader();
|
|
147
|
+
const decoder = new TextDecoder();
|
|
148
|
+
let buf = '';
|
|
149
|
+
// First chunk is the :connected flush.
|
|
150
|
+
buf += decoder.decode((await reader.read()).value);
|
|
151
|
+
assert.match(buf, /:connected/);
|
|
152
|
+
writeFileSync(join(game, 'world.js'), 'export const w = 2;\n');
|
|
153
|
+
const deadline = Date.now() + 5000;
|
|
154
|
+
while (!buf.includes('data: reload') && Date.now() < deadline) {
|
|
155
|
+
const { value, done } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
buf += decoder.decode(value);
|
|
158
|
+
}
|
|
159
|
+
assert.match(buf, /data: reload/);
|
|
160
|
+
await reader.cancel();
|
|
161
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@looop-games/cli",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"looop": "bin/looop.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20.11"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test 'lib/*.test.mjs'"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"partykit": "^0.0.115"
|
|
22
|
+
}
|
|
23
|
+
}
|