@waica/cli 0.5.0 → 0.6.1
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/README.md +42 -37
- package/dist/editor/assets/{cssMode-2_8FrcPt.js → cssMode-D3SZSKEA.js} +1 -1
- package/dist/editor/assets/{freemarker2-BgSLfjNS.js → freemarker2-BH1pa3TU.js} +1 -1
- package/dist/editor/assets/{handlebars-CZIkUhwZ.js → handlebars-Cf7meNHo.js} +1 -1
- package/dist/editor/assets/{html-B66ooLhD.js → html-6hqYzl5P.js} +1 -1
- package/dist/editor/assets/{htmlMode-BiIT--Mv.js → htmlMode-D_IiyUdi.js} +1 -1
- package/dist/editor/assets/{index-Br-M9Tdo.js → index-CJyB1cBF.js} +2788 -406
- package/dist/editor/assets/{index-q7IkLe-P.css → index-Cy5pmBhN.css} +1 -1
- package/dist/editor/assets/{javascript-BZrbbzWQ.js → javascript-CTeTqLj8.js} +1 -1
- package/dist/editor/assets/{jsonMode-CABkbZgt.js → jsonMode-t8AC-38H.js} +1 -1
- package/dist/editor/assets/{liquid-CT7pyNYe.js → liquid-Dc6NoKas.js} +1 -1
- package/dist/editor/assets/{mdx-DW7MPqdm.js → mdx-DCPtX-bs.js} +1 -1
- package/dist/editor/assets/{python-CgBwEaNf.js → python-DXAju_oJ.js} +1 -1
- package/dist/editor/assets/{razor-Dvggr_Q1.js → razor-DqQldpv6.js} +1 -1
- package/dist/editor/assets/{tsMode-OfEpAJE7.js → tsMode-B60EhHlZ.js} +1 -1
- package/dist/editor/assets/{typescript-BQbhlbBK.js → typescript-C4LyYAns.js} +1 -1
- package/dist/editor/assets/{xml-5qToUR1k.js → xml-B0QcT5_Z.js} +1 -1
- package/dist/editor/assets/{yaml-CFM1jPNu.js → yaml-CEpyO2Qc.js} +1 -1
- package/dist/editor/index.html +2 -2
- package/dist/mcp/introspection.js +2 -0
- package/dist/mcp/node_modules/@waica/archetype-platformer/dist/prefabs.js +1 -1
- package/dist/mcp/node_modules/@waica/archetype-platformer/dist/scene-default.js +9 -1
- package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
- package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +4 -3
- package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +12 -15
- package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
- package/dist/mcp/node_modules/@waica/engine/README.md +85 -0
- package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.d.ts +39 -0
- package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.js +157 -0
- package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +5 -0
- package/dist/mcp/node_modules/@waica/engine/dist/component.js +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +7 -0
- package/dist/mcp/node_modules/@waica/engine/dist/game.js +79 -1
- package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +7 -1
- package/dist/mcp/node_modules/@waica/engine/dist/index.js +3 -0
- package/dist/mcp/node_modules/@waica/engine/dist/input.d.ts +10 -0
- package/dist/mcp/node_modules/@waica/engine/dist/input.js +40 -2
- package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.d.ts +69 -0
- package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.js +105 -0
- package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.d.ts +71 -0
- package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.js +279 -0
- package/dist/mcp/node_modules/@waica/engine/package.json +3 -2
- package/dist/mcp/project-component-loader.js +506 -240
- package/dist/mcp/project-component-runner.js +237 -0
- package/dist/mcp/runtime-browser.js +336 -0
- package/dist/mcp/runtime-dev-server.js +273 -0
- package/dist/mcp/runtime-preflight.js +249 -0
- package/dist/mcp/runtime-service.js +8 -0
- package/dist/mcp/runtime-session-manager.js +265 -0
- package/dist/mcp/scaffolds.js +126 -6
- package/dist/mcp/server.js +330 -9
- package/dist/mcp/stdio.js +0 -25
- package/dist/mcp/template/src/characters/player.character.json +1 -1
- package/dist/mcp/template/src/main.ts +6 -1
- package/dist/mcp/template/src/scenes/main.scene.json +7 -1
- package/dist/mcp/validation.js +77 -6
- package/package.json +2 -1
- package/dist/mcp/native-import.cjs +0 -3
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { RuntimeToolError } from './runtime-service.js';
|
|
4
|
+
const TAIL_BYTES = 64 * 1024;
|
|
5
|
+
const LOOPBACK_URL = /http:\/\/127\.0\.0\.1:(\d+)\/?/;
|
|
6
|
+
export const RUNTIME_PORT_PLACEHOLDER = '__WAICA_RUNTIME_PORT__';
|
|
7
|
+
class ByteTail {
|
|
8
|
+
value = Buffer.alloc(0);
|
|
9
|
+
append(chunk) {
|
|
10
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
11
|
+
this.value = Buffer.concat([this.value, bytes]).subarray(-TAIL_BYTES);
|
|
12
|
+
}
|
|
13
|
+
text() {
|
|
14
|
+
return this.value.toString('utf8');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function delay(milliseconds) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
19
|
+
}
|
|
20
|
+
async function allocateLoopbackPort() {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const server = net.createServer();
|
|
23
|
+
server.unref();
|
|
24
|
+
server.once('error', reject);
|
|
25
|
+
server.listen(0, '127.0.0.1', () => {
|
|
26
|
+
const address = server.address();
|
|
27
|
+
if (!address || typeof address === 'string') {
|
|
28
|
+
server.close();
|
|
29
|
+
reject(new Error('could not allocate a loopback TCP port'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { port } = address;
|
|
33
|
+
server.close((error) => (error ? reject(error) : resolve(port)));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function groupAlive(pid) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(-pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return error.code !== 'ESRCH';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function signalGroup(pid, signal) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(-pid, signal);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code !== 'ESRCH')
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function waitForGroupExit(pid, timeoutMs) {
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
while (Date.now() <= deadline) {
|
|
58
|
+
if (!groupAlive(pid))
|
|
59
|
+
return true;
|
|
60
|
+
await delay(10);
|
|
61
|
+
}
|
|
62
|
+
return !groupAlive(pid);
|
|
63
|
+
}
|
|
64
|
+
async function terminateGroup(child, graceMs, forceWaitMs) {
|
|
65
|
+
const pid = child.pid;
|
|
66
|
+
if (pid === undefined || !groupAlive(pid))
|
|
67
|
+
return;
|
|
68
|
+
signalGroup(pid, 'SIGTERM');
|
|
69
|
+
if (await waitForGroupExit(pid, graceMs))
|
|
70
|
+
return;
|
|
71
|
+
signalGroup(pid, 'SIGKILL');
|
|
72
|
+
if (!(await waitForGroupExit(pid, forceWaitMs))) {
|
|
73
|
+
throw new Error(`process group ${pid} survived SIGKILL`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function portAcceptsConnections(url) {
|
|
77
|
+
const parsed = new URL(url);
|
|
78
|
+
const port = Number(parsed.port);
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
81
|
+
const finish = (open) => {
|
|
82
|
+
socket.destroy();
|
|
83
|
+
resolve(open);
|
|
84
|
+
};
|
|
85
|
+
socket.setTimeout(100);
|
|
86
|
+
socket.once('connect', () => finish(true));
|
|
87
|
+
socket.once('timeout', () => finish(false));
|
|
88
|
+
socket.once('error', () => finish(false));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function waitForClosedPort(url, timeoutMs) {
|
|
92
|
+
const deadline = Date.now() + timeoutMs;
|
|
93
|
+
while (Date.now() <= deadline) {
|
|
94
|
+
if (!(await portAcceptsConnections(url)))
|
|
95
|
+
return true;
|
|
96
|
+
await delay(10);
|
|
97
|
+
}
|
|
98
|
+
return !(await portAcceptsConnections(url));
|
|
99
|
+
}
|
|
100
|
+
async function probe(url) {
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(250) });
|
|
103
|
+
await response.body?.cancel();
|
|
104
|
+
return response.ok;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
class OwnedRuntimeDevServer {
|
|
111
|
+
url;
|
|
112
|
+
child;
|
|
113
|
+
stdout;
|
|
114
|
+
stderr;
|
|
115
|
+
graceMs;
|
|
116
|
+
forceWaitMs;
|
|
117
|
+
command;
|
|
118
|
+
args;
|
|
119
|
+
stopping = false;
|
|
120
|
+
stopped = false;
|
|
121
|
+
exitHandler;
|
|
122
|
+
constructor(url, child, stdout, stderr, graceMs, forceWaitMs, command, args) {
|
|
123
|
+
this.url = url;
|
|
124
|
+
this.child = child;
|
|
125
|
+
this.stdout = stdout;
|
|
126
|
+
this.stderr = stderr;
|
|
127
|
+
this.graceMs = graceMs;
|
|
128
|
+
this.forceWaitMs = forceWaitMs;
|
|
129
|
+
this.command = command;
|
|
130
|
+
this.args = args;
|
|
131
|
+
child.once('exit', () => {
|
|
132
|
+
if (!this.stopping)
|
|
133
|
+
this.exitHandler?.(this.diagnostics());
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
setExitHandler(handler) {
|
|
137
|
+
this.exitHandler = handler;
|
|
138
|
+
}
|
|
139
|
+
diagnostics() {
|
|
140
|
+
return {
|
|
141
|
+
command: this.command,
|
|
142
|
+
args: this.args,
|
|
143
|
+
stdout: this.stdout.text(),
|
|
144
|
+
stderr: this.stderr.text(),
|
|
145
|
+
exitCode: this.child.exitCode,
|
|
146
|
+
signal: this.child.signalCode,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async stop() {
|
|
150
|
+
if (this.stopped)
|
|
151
|
+
return;
|
|
152
|
+
this.stopping = true;
|
|
153
|
+
await terminateGroup(this.child, this.graceMs, this.forceWaitMs);
|
|
154
|
+
if (!(await waitForClosedPort(this.url, this.forceWaitMs))) {
|
|
155
|
+
throw new Error(`loopback port remained open after process cleanup: ${this.url}`);
|
|
156
|
+
}
|
|
157
|
+
this.stopped = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function diagnostics(child, stdout, stderr, command, args) {
|
|
161
|
+
return {
|
|
162
|
+
command,
|
|
163
|
+
args,
|
|
164
|
+
stdout: stdout.text(),
|
|
165
|
+
stderr: stderr.text(),
|
|
166
|
+
exitCode: child.exitCode,
|
|
167
|
+
signal: child.signalCode,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async function startAttempt(preflight, args, expectedPort, options) {
|
|
171
|
+
const stdout = new ByteTail();
|
|
172
|
+
const stderr = new ByteTail();
|
|
173
|
+
let child;
|
|
174
|
+
try {
|
|
175
|
+
child = spawn(preflight.command, args, {
|
|
176
|
+
cwd: preflight.projectPath,
|
|
177
|
+
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
178
|
+
detached: true,
|
|
179
|
+
shell: false,
|
|
180
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
throw new RuntimeToolError({
|
|
185
|
+
code: 'runtime-start-failed',
|
|
186
|
+
stage: 'dev-server',
|
|
187
|
+
message: `Could not spawn the Project dev script: ${error instanceof Error ? error.message : String(error)}`,
|
|
188
|
+
projectPath: preflight.projectPath,
|
|
189
|
+
diagnostics: { command: preflight.command, args, stdout: '', stderr: '' },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
child.stdout?.on('data', (chunk) => stdout.append(chunk));
|
|
193
|
+
child.stderr?.on('data', (chunk) => stderr.append(chunk));
|
|
194
|
+
const spawnError = new Promise((resolve) => {
|
|
195
|
+
child.once('error', (error) => resolve(error));
|
|
196
|
+
child.once('spawn', () => resolve(null));
|
|
197
|
+
});
|
|
198
|
+
const error = await spawnError;
|
|
199
|
+
if (error) {
|
|
200
|
+
throw new RuntimeToolError({
|
|
201
|
+
code: 'runtime-start-failed',
|
|
202
|
+
stage: 'dev-server',
|
|
203
|
+
message: `Could not spawn the Project dev script: ${error.message}`,
|
|
204
|
+
projectPath: preflight.projectPath,
|
|
205
|
+
diagnostics: { command: preflight.command, args, stdout: stdout.text(), stderr: stderr.text() },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const deadline = Date.now() + preflight.timeoutMs;
|
|
209
|
+
let parsedUrl;
|
|
210
|
+
while (Date.now() <= deadline) {
|
|
211
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
212
|
+
break;
|
|
213
|
+
const match = LOOPBACK_URL.exec(`${stdout.text()}\n${stderr.text()}`);
|
|
214
|
+
if (match) {
|
|
215
|
+
const reportedPort = Number(match[1]);
|
|
216
|
+
parsedUrl = `http://127.0.0.1:${reportedPort}/`;
|
|
217
|
+
if (expectedPort !== undefined && reportedPort !== expectedPort) {
|
|
218
|
+
await terminateGroup(child, options.graceMs ?? 2_000, options.forceWaitMs ?? 2_000);
|
|
219
|
+
throw new RuntimeToolError({
|
|
220
|
+
code: 'runtime-start-failed',
|
|
221
|
+
stage: 'dev-server',
|
|
222
|
+
message: `Project dev script did not forward the required --port ${expectedPort}; it reported ${parsedUrl}.`,
|
|
223
|
+
projectPath: preflight.projectPath,
|
|
224
|
+
diagnostics: diagnostics(child, stdout, stderr, preflight.command, args),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (await probe(parsedUrl)) {
|
|
228
|
+
return new OwnedRuntimeDevServer(parsedUrl, child, stdout, stderr, options.graceMs ?? 2_000, options.forceWaitMs ?? 2_000, preflight.command, args);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await delay(20);
|
|
232
|
+
}
|
|
233
|
+
const detail = diagnostics(child, stdout, stderr, preflight.command, args);
|
|
234
|
+
await terminateGroup(child, options.graceMs ?? 2_000, options.forceWaitMs ?? 2_000);
|
|
235
|
+
throw new RuntimeToolError({
|
|
236
|
+
code: 'runtime-start-failed',
|
|
237
|
+
stage: 'dev-server',
|
|
238
|
+
message: child.exitCode !== null
|
|
239
|
+
? `Project dev script exited with code ${child.exitCode} before a usable loopback URL was ready.`
|
|
240
|
+
: parsedUrl
|
|
241
|
+
? `Project dev script reported ${parsedUrl}, but its HTTP page never became ready.`
|
|
242
|
+
: 'Project dev script never reported a usable http://127.0.0.1 URL.',
|
|
243
|
+
projectPath: preflight.projectPath,
|
|
244
|
+
diagnostics: detail,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function isBindCollision(error) {
|
|
248
|
+
if (!(error instanceof RuntimeToolError))
|
|
249
|
+
return false;
|
|
250
|
+
const diagnosticsText = JSON.stringify(error.body.diagnostics ?? {});
|
|
251
|
+
return /EADDRINUSE|address already in use|Port \d+ is already in use/i.test(`${error.message}\n${diagnosticsText}`);
|
|
252
|
+
}
|
|
253
|
+
export async function startRuntimeDevServer(preflight, options = {}) {
|
|
254
|
+
const placeholder = preflight.args.indexOf(RUNTIME_PORT_PLACEHOLDER);
|
|
255
|
+
if (placeholder === -1)
|
|
256
|
+
return startAttempt(preflight, preflight.args, undefined, options);
|
|
257
|
+
const attempts = options.maxPortAttempts ?? 3;
|
|
258
|
+
let lastError;
|
|
259
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
260
|
+
const port = await (options.allocatePort ?? allocateLoopbackPort)();
|
|
261
|
+
const args = [...preflight.args];
|
|
262
|
+
args[placeholder] = String(port);
|
|
263
|
+
try {
|
|
264
|
+
return await startAttempt(preflight, args, port, options);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
lastError = error;
|
|
268
|
+
if (!isBindCollision(error) || attempt === attempts - 1)
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
throw lastError;
|
|
273
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { RuntimeToolError, } from './runtime-service.js';
|
|
7
|
+
const RUNTIME_PORT_ARGUMENT = '__WAICA_RUNTIME_PORT__';
|
|
8
|
+
function runtimeError(projectPath, stage, message) {
|
|
9
|
+
return new RuntimeToolError({
|
|
10
|
+
code: 'runtime-prerequisite-missing',
|
|
11
|
+
stage,
|
|
12
|
+
message,
|
|
13
|
+
projectPath,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function exists(file) {
|
|
17
|
+
try {
|
|
18
|
+
await access(file);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT')
|
|
23
|
+
return false;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function defaultCommandAvailable(command) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const child = spawn(command, ['--version'], { stdio: 'ignore' });
|
|
30
|
+
child.once('error', (error) => {
|
|
31
|
+
if (error.code === 'ENOENT')
|
|
32
|
+
resolve(false);
|
|
33
|
+
else
|
|
34
|
+
resolve(false);
|
|
35
|
+
});
|
|
36
|
+
child.once('spawn', () => {
|
|
37
|
+
child.once('close', () => resolve(true));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const MAC_BROWSERS = [
|
|
42
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
43
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
44
|
+
];
|
|
45
|
+
const LINUX_BROWSERS = [
|
|
46
|
+
'/usr/bin/google-chrome',
|
|
47
|
+
'/usr/bin/google-chrome-stable',
|
|
48
|
+
'/usr/bin/chromium',
|
|
49
|
+
'/usr/bin/chromium-browser',
|
|
50
|
+
];
|
|
51
|
+
async function defaultDiscoverBrowser(explicitPath) {
|
|
52
|
+
const candidates = explicitPath
|
|
53
|
+
? [explicitPath]
|
|
54
|
+
: process.platform === 'darwin'
|
|
55
|
+
? MAC_BROWSERS
|
|
56
|
+
: LINUX_BROWSERS;
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
try {
|
|
59
|
+
await access(candidate, constants.X_OK);
|
|
60
|
+
return realpath(candidate);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Continue deterministic discovery.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export const DEFAULT_RUNTIME_PREFLIGHT_ADAPTERS = {
|
|
69
|
+
platform: process.platform,
|
|
70
|
+
commandAvailable: defaultCommandAvailable,
|
|
71
|
+
discoverBrowser: defaultDiscoverBrowser,
|
|
72
|
+
};
|
|
73
|
+
function isPositiveInteger(value) {
|
|
74
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
75
|
+
}
|
|
76
|
+
function validateViewport(projectPath, value, label) {
|
|
77
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
78
|
+
return null;
|
|
79
|
+
const { width, height } = value;
|
|
80
|
+
if (!isPositiveInteger(width) || !isPositiveInteger(height) || width * height > 1_000_000) {
|
|
81
|
+
if (label === 'explicit') {
|
|
82
|
+
throw runtimeError(projectPath, 'project', 'viewport width and height must be positive integers totaling at most 1,000,000 pixels.');
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return { width, height };
|
|
87
|
+
}
|
|
88
|
+
async function projectViewport(projectPath, explicit) {
|
|
89
|
+
if (explicit)
|
|
90
|
+
return validateViewport(projectPath, explicit, 'explicit');
|
|
91
|
+
try {
|
|
92
|
+
const game = JSON.parse(await readFile(path.join(projectPath, 'src/game.json'), 'utf8'));
|
|
93
|
+
return validateViewport(projectPath, game.resolution, 'project') ?? { width: 640, height: 360 };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { width: 640, height: 360 };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function selectPackageManager(projectPath, manifest) {
|
|
100
|
+
if (manifest.packageManager !== undefined) {
|
|
101
|
+
if (typeof manifest.packageManager !== 'string') {
|
|
102
|
+
throw runtimeError(projectPath, 'package-manager', 'packageManager must be a string such as pnpm@11.4.0.');
|
|
103
|
+
}
|
|
104
|
+
const match = /^(npm|pnpm|yarn|bun)@[^\s]+$/.exec(manifest.packageManager);
|
|
105
|
+
if (!match) {
|
|
106
|
+
throw runtimeError(projectPath, 'package-manager', `Unsupported or invalid packageManager "${manifest.packageManager}"; use npm, pnpm, yarn or bun with a version.`);
|
|
107
|
+
}
|
|
108
|
+
return match[1];
|
|
109
|
+
}
|
|
110
|
+
const locks = [
|
|
111
|
+
['package-lock.json', 'npm'],
|
|
112
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
113
|
+
['yarn.lock', 'yarn'],
|
|
114
|
+
['bun.lock', 'bun'],
|
|
115
|
+
['bun.lockb', 'bun'],
|
|
116
|
+
];
|
|
117
|
+
const present = [];
|
|
118
|
+
for (const lock of locks) {
|
|
119
|
+
if (await exists(path.join(projectPath, lock[0])))
|
|
120
|
+
present.push(lock);
|
|
121
|
+
}
|
|
122
|
+
if (present.length > 1) {
|
|
123
|
+
throw runtimeError(projectPath, 'package-manager', `Conflicting root lockfiles: ${present.map(([file]) => file).join(', ')}. Keep exactly one.`);
|
|
124
|
+
}
|
|
125
|
+
return present[0]?.[1] ?? 'npm';
|
|
126
|
+
}
|
|
127
|
+
function devArgs(manager) {
|
|
128
|
+
const viteArgs = [
|
|
129
|
+
'--host',
|
|
130
|
+
'127.0.0.1',
|
|
131
|
+
'--port',
|
|
132
|
+
RUNTIME_PORT_ARGUMENT,
|
|
133
|
+
'--strictPort',
|
|
134
|
+
];
|
|
135
|
+
return manager === 'yarn'
|
|
136
|
+
? ['run', 'dev', ...viteArgs]
|
|
137
|
+
: ['run', 'dev', '--', ...viteArgs];
|
|
138
|
+
}
|
|
139
|
+
async function packageRootFromEntry(entry, packageName) {
|
|
140
|
+
for (let current = path.dirname(entry);; current = path.dirname(current)) {
|
|
141
|
+
try {
|
|
142
|
+
const manifest = JSON.parse(await readFile(path.join(current, 'package.json'), 'utf8'));
|
|
143
|
+
if (manifest.name === packageName)
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.code !== 'ENOENT')
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
const parent = path.dirname(current);
|
|
151
|
+
if (parent === current)
|
|
152
|
+
throw new Error(`could not locate package root for ${packageName}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function verifyDependencies(projectPath, manifest) {
|
|
156
|
+
const dependencies = {
|
|
157
|
+
...(manifest.dependencies ?? {}),
|
|
158
|
+
...(manifest.devDependencies ?? {}),
|
|
159
|
+
};
|
|
160
|
+
const names = Object.keys(dependencies).sort();
|
|
161
|
+
if (!names.includes('@waica/engine')) {
|
|
162
|
+
throw runtimeError(projectPath, 'dependencies', 'The Project must declare and install @waica/engine before it can run.');
|
|
163
|
+
}
|
|
164
|
+
const anchoredRequire = createRequire(path.join(projectPath, 'package.json'));
|
|
165
|
+
const entries = new Map();
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
try {
|
|
168
|
+
entries.set(name, anchoredRequire.resolve(name));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw runtimeError(projectPath, 'dependencies', `Project dependency ${name} is not resolvable; install dependencies before starting. (${error instanceof Error ? error.message : String(error)})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const engineRoot = await packageRootFromEntry(entries.get('@waica/engine'), '@waica/engine');
|
|
175
|
+
const engineManifest = JSON.parse(await readFile(path.join(engineRoot, 'package.json'), 'utf8'));
|
|
176
|
+
if (typeof engineManifest.version !== 'string' || engineManifest.version.length === 0) {
|
|
177
|
+
throw runtimeError(projectPath, 'dependencies', 'Installed @waica/engine has no valid version.');
|
|
178
|
+
}
|
|
179
|
+
return { package: '@waica/engine', version: engineManifest.version, source: 'project' };
|
|
180
|
+
}
|
|
181
|
+
export async function preflightRuntimeProject(input, adapters = DEFAULT_RUNTIME_PREFLIGHT_ADAPTERS) {
|
|
182
|
+
if (adapters.platform === 'win32') {
|
|
183
|
+
throw new RuntimeToolError({
|
|
184
|
+
code: 'runtime-unsupported-host',
|
|
185
|
+
stage: 'project',
|
|
186
|
+
message: 'Run Sessions support macOS and Linux; Windows is not supported in this MVP.',
|
|
187
|
+
projectPath: input.projectPath,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (adapters.platform !== 'darwin' && adapters.platform !== 'linux') {
|
|
191
|
+
throw new RuntimeToolError({
|
|
192
|
+
code: 'runtime-unsupported-host',
|
|
193
|
+
stage: 'project',
|
|
194
|
+
message: `Run Sessions do not support host platform ${adapters.platform}.`,
|
|
195
|
+
projectPath: input.projectPath,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
let projectPath;
|
|
199
|
+
try {
|
|
200
|
+
projectPath = await realpath(input.projectPath);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
throw runtimeError(input.projectPath, 'project', `Project path is not accessible: ${error instanceof Error ? error.message : String(error)}`);
|
|
204
|
+
}
|
|
205
|
+
if (!(await exists(path.join(projectPath, 'src/game.json'))) &&
|
|
206
|
+
!(await exists(path.join(projectPath, 'src/scenes/main.scene.json')))) {
|
|
207
|
+
throw runtimeError(projectPath, 'project', 'Not a Waica Project: expected src/game.json or src/scenes/main.scene.json.');
|
|
208
|
+
}
|
|
209
|
+
let manifest;
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(await readFile(path.join(projectPath, 'package.json'), 'utf8'));
|
|
212
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
213
|
+
throw new Error('expected an object');
|
|
214
|
+
manifest = parsed;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
throw runtimeError(projectPath, 'project', `package.json must be readable JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
}
|
|
219
|
+
const dev = manifest.scripts?.dev;
|
|
220
|
+
if (typeof dev !== 'string' || dev.trim().length === 0) {
|
|
221
|
+
throw runtimeError(projectPath, 'project', 'package.json scripts.dev must be a nonempty string.');
|
|
222
|
+
}
|
|
223
|
+
const packageManager = await selectPackageManager(projectPath, manifest);
|
|
224
|
+
if (!(await adapters.commandAvailable(packageManager))) {
|
|
225
|
+
throw runtimeError(projectPath, 'package-manager', `Package-manager executable "${packageManager}" is not available on PATH.`);
|
|
226
|
+
}
|
|
227
|
+
const engine = await verifyDependencies(projectPath, manifest);
|
|
228
|
+
const browserExecutablePath = await adapters.discoverBrowser(input.browserExecutablePath);
|
|
229
|
+
if (!browserExecutablePath) {
|
|
230
|
+
throw runtimeError(projectPath, 'browser', input.browserExecutablePath
|
|
231
|
+
? `Browser executable is not available: ${input.browserExecutablePath}`
|
|
232
|
+
: 'No compatible system Chrome or Chromium executable was found.');
|
|
233
|
+
}
|
|
234
|
+
const timeoutMs = input.timeoutMs ?? 30_000;
|
|
235
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 120_000) {
|
|
236
|
+
throw runtimeError(projectPath, 'project', 'timeout_ms must be an integer from 1,000 through 120,000.');
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
projectPath,
|
|
240
|
+
packageManager,
|
|
241
|
+
command: packageManager,
|
|
242
|
+
args: devArgs(packageManager),
|
|
243
|
+
viewport: await projectViewport(projectPath, input.viewport),
|
|
244
|
+
timeoutMs,
|
|
245
|
+
headless: input.headless ?? true,
|
|
246
|
+
browserExecutablePath,
|
|
247
|
+
engine,
|
|
248
|
+
};
|
|
249
|
+
}
|