@p10i/create-nestbox 0.4.0 → 0.6.0

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/host-runner.mjs DELETED
@@ -1,292 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
- import { hostname, platform } from 'node:os';
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises';
7
-
8
- const installDirectory = dirname(fileURLToPath(import.meta.url));
9
- const defaultWorkspace = basename(installDirectory) === '.nestbox' ? dirname(installDirectory) : installDirectory;
10
- const workspace = resolve(process.env.NESTBOX_HOST_WORKSPACE || defaultWorkspace);
11
- const canonicalInstallDirectory = await realpath(installDirectory).catch(() => resolve(installDirectory));
12
- const canonicalWorkspace = await realpath(workspace).catch(() => workspace);
13
-
14
- function parseEnv(contents) {
15
- const values = {};
16
- for (const line of contents.split(/\r?\n/)) {
17
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
18
- if (!match) continue;
19
- let value = match[2].trim();
20
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
21
- values[match[1]] = value;
22
- }
23
- return values;
24
- }
25
-
26
- const fileEnv = await readFile(join(installDirectory, '.env'), 'utf8').then(parseEnv, error => error?.code === 'ENOENT' ? {} : Promise.reject(error));
27
- const bindAddress = process.env.BIND_ADDRESS || fileEnv.BIND_ADDRESS || '127.0.0.1';
28
- const gatewayAddress = bindAddress === '0.0.0.0' ? '127.0.0.1' : bindAddress === '::' ? '::1' : bindAddress;
29
- const gatewayHost = gatewayAddress.includes(':') && !gatewayAddress.startsWith('[') ? `[${gatewayAddress}]` : gatewayAddress;
30
- const webPort = process.env.WEB_PORT || fileEnv.WEB_PORT || '4180';
31
- const controlUrl = (process.env.NESTBOX_CONTROL_HOST_URL || fileEnv.NESTBOX_CONTROL_HOST_URL || `http://${gatewayHost}:${webPort}/_nestbox`).replace(/\/$/, '');
32
- const tokenPath = process.env.NESTBOX_HOST_TOKEN_FILE || fileEnv.NESTBOX_HOST_TOKEN_FILE || '';
33
- const resolvedTokenPath = tokenPath ? await realpath(resolve(tokenPath)).catch(() => resolve(tokenPath)) : '';
34
- function containsPath(root, target) {
35
- const child = relative(resolve(root), target);
36
- return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
37
- }
38
- if (resolvedTokenPath && (containsPath(canonicalWorkspace, resolvedTokenPath) || containsPath(canonicalInstallDirectory, resolvedTokenPath))) {
39
- throw new Error('NESTBOX_HOST_TOKEN_FILE must be outside the workspace and Nestbox installation.');
40
- }
41
- const fileToken = resolvedTokenPath ? await readFile(resolvedTokenPath, 'utf8').then(value => value.trim(), () => '') : '';
42
- const hostToken = fileToken;
43
- if (hostToken.length < 32) throw new Error('NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters.');
44
-
45
- const runnerId = randomUUID();
46
- const once = process.argv.slice(2).includes('--once');
47
- const jsonOutput = process.argv.slice(2).includes('--json');
48
- const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
49
- const npmCli = process.env.npm_execpath || (!process.env.NESTBOX_HOST_NPM && process.platform === 'win32' ? join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js') : '');
50
- const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
51
- const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
52
- const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
53
- const maximumPendingApprovals = Number(process.env.NESTBOX_HOST_APPROVAL_MAX_PENDING || 20);
54
- const hostPlatform = platform();
55
- const hostName = hostname();
56
- const approvals = new Map();
57
- const activeChildren = new Set();
58
- let heartbeatTimer;
59
- let stopping = false;
60
- let shutdownDeadline = 0;
61
-
62
- function emit(type, fields = {}) {
63
- const event = { ...fields, type, timestamp: new Date().toISOString() };
64
- if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
65
- if (type === 'runner.ready') console.log(`Nestbox host runner connected to ${controlUrl} for ${workspace}`);
66
- else if (type === 'npm.scripts.confirmation_required') {
67
- console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
68
- if (event.command !== undefined) console.log(`Command: ${event.command}`);
69
- console.log(`Confirmation code: ${event.code} (expires ${event.expiresAt})\n`);
70
- } else if (type === 'npm.scripts.changed') console.log(`Approved ${event.operation} for npm script "${event.name}".`);
71
- else if (type === 'runner.error') console.error(event.error);
72
- }
73
-
74
- function headers(json = false) {
75
- return {
76
- Authorization: `Bearer ${hostToken}`,
77
- 'X-Nestbox-Runner-Id': runnerId,
78
- 'X-Nestbox-Runner-Pid': String(process.pid),
79
- 'X-Nestbox-Runner-Platform': hostPlatform,
80
- 'X-Nestbox-Runner-Host': hostName,
81
- ...(json ? { 'Content-Type': 'application/json' } : {}),
82
- };
83
- }
84
-
85
- async function controlRequest(method, path, body, timeoutMs = 35000) {
86
- const controller = new AbortController();
87
- const timer = setTimeout(() => controller.abort(), timeoutMs);
88
- try {
89
- const response = await fetch(controlUrl + path, {
90
- method,
91
- headers: headers(body !== undefined),
92
- body: body === undefined ? undefined : JSON.stringify(body),
93
- signal: controller.signal,
94
- });
95
- const text = await response.text();
96
- const value = text ? JSON.parse(text) : null;
97
- if (!response.ok) {
98
- const error = new Error(`Control ${method} ${path} returned ${response.status}: ${JSON.stringify(value)}`);
99
- error.status = response.status;
100
- throw error;
101
- }
102
- return { status: response.status, value };
103
- } finally {
104
- clearTimeout(timer);
105
- }
106
- }
107
-
108
- async function packageText() { return readFile(join(workspace, 'package.json'), 'utf8'); }
109
- function parsePackageDocument(text) {
110
- const document = JSON.parse(text);
111
- if (!document || typeof document !== 'object' || Array.isArray(document)) throw new Error('package.json must contain a JSON object.');
112
- if (document.scripts !== undefined && (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts))) throw new Error('package.json scripts must be an object.');
113
- return document;
114
- }
115
- async function packageDocument() { return parsePackageDocument(await packageText()); }
116
- function hash(contents) { return createHash('sha256').update(contents).digest('hex'); }
117
- async function packageScripts() {
118
- const document = await packageDocument();
119
- if (document.scripts === undefined) return {};
120
- if (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts)) throw new Error('package.json scripts must be an object.');
121
- return document.scripts;
122
- }
123
-
124
- async function writePackageJson(document, expectedHash) {
125
- const path = join(workspace, 'package.json');
126
- const temporary = `${path}.${runnerId}.tmp`;
127
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
128
- if (hash(await packageText()) !== expectedHash) {
129
- await unlink(temporary).catch(() => {});
130
- throw new Error('package.json changed while the approved update was being prepared.');
131
- }
132
- try { await rename(temporary, path); }
133
- catch (error) {
134
- if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
135
- const backup = `${path}.${runnerId}.backup`;
136
- await rename(path, backup);
137
- try { await rename(temporary, path); await unlink(backup); }
138
- catch (replaceError) { await rename(backup, path).catch(() => {}); throw replaceError; }
139
- }
140
- }
141
-
142
- function validateScriptChange(job, scripts) {
143
- if (!['add', 'edit', 'delete'].includes(job.operation)) throw new Error('Unsupported npm script operation.');
144
- if (typeof job.name !== 'string' || !/^[A-Za-z0-9:_@./ -]{1,128}$/.test(job.name)) throw new Error('Invalid npm script name.');
145
- if (job.name === 'host') throw new Error('The core host script cannot be changed.');
146
- const exists = Object.hasOwn(scripts, job.name);
147
- if (job.operation === 'add' && exists) throw new Error(`npm script already exists: ${job.name}`);
148
- if (job.operation !== 'add' && !exists) throw new Error(`npm script is not defined: ${job.name}`);
149
- if (job.operation !== 'delete' && (typeof job.command !== 'string' || !job.command.trim() || job.command.length > 8192)) throw new Error('A non-empty command of at most 8192 characters is required.');
150
- if (job.operation === 'delete' && job.command !== undefined) throw new Error('Delete does not accept a command.');
151
- }
152
-
153
- function pruneApprovals() {
154
- const now = Date.now();
155
- for (const [requestId, approval] of approvals) if (approval.expiresAtMs <= now) approvals.delete(requestId);
156
- }
157
-
158
- async function requestScriptChange(job) {
159
- if (typeof job.requestId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/.test(job.requestId)) throw new Error('A valid request ID is required.');
160
- pruneApprovals();
161
- if (approvals.has(job.requestId)) throw new Error('The request ID is already pending.');
162
- if (approvals.size >= maximumPendingApprovals) throw new Error('Too many npm script approvals are pending.');
163
- const text = await packageText();
164
- const scripts = parsePackageDocument(text).scripts || {};
165
- validateScriptChange(job, scripts);
166
- const code = randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
167
- const expiresAtMs = Date.now() + approvalTtlMs;
168
- approvals.set(job.requestId, { ...job, expectedCommand: Object.hasOwn(scripts, job.name) ? String(scripts[job.name]) : null, expectedPackageHash: hash(text), code, attempts: 0, expiresAtMs });
169
- const expiresAt = new Date(expiresAtMs).toISOString();
170
- emit('npm.scripts.confirmation_required', { ...job, code, expiresAt });
171
- return { jobId: job.jobId, status: 'confirmation_required', requestId: job.requestId, operation: job.operation, name: job.name, expiresAt };
172
- }
173
-
174
- async function confirmScriptChange(job) {
175
- pruneApprovals();
176
- const approval = approvals.get(job.requestId);
177
- if (!approval) throw new Error('The npm script approval was not found or has expired.');
178
- approval.attempts += 1;
179
- if (String(job.code).trim().toUpperCase() !== approval.code) {
180
- if (approval.attempts >= approvalMaximumAttempts) approvals.delete(job.requestId);
181
- throw new Error(approval.attempts >= approvalMaximumAttempts ? 'Invalid confirmation code; the approval has been cancelled.' : 'Invalid confirmation code.');
182
- }
183
- const text = await packageText();
184
- if (hash(text) !== approval.expectedPackageHash) { approvals.delete(job.requestId); throw new Error('package.json changed after approval was requested; request a new approval.'); }
185
- const document = parsePackageDocument(text), scripts = document.scripts || {};
186
- validateScriptChange(approval, scripts);
187
- if (approval.operation === 'delete') delete scripts[approval.name]; else scripts[approval.name] = approval.command;
188
- document.scripts = scripts;
189
- await writePackageJson(document, approval.expectedPackageHash);
190
- approvals.delete(job.requestId);
191
- emit('npm.scripts.changed', approval);
192
- return { jobId: job.jobId, status: 'success', requestId: approval.requestId, operation: approval.operation, name: approval.name };
193
- }
194
-
195
- function appendLimited(chunks, chunk, state) {
196
- const buffer = Buffer.from(chunk); state.totalBytes += buffer.length;
197
- const available = Math.max(0, maximumOutputBytes - state.bytes);
198
- if (available) chunks.push(buffer.subarray(0, available));
199
- state.bytes += Math.min(buffer.length, available); if (buffer.length > available) state.truncated = true;
200
- }
201
-
202
- async function runNpm(job) {
203
- const scripts = await packageScripts();
204
- if (typeof job.script !== 'string' || !Array.isArray(job.args) || !job.args.every(value => typeof value === 'string')) throw new Error('Invalid host npm job.');
205
- if (job.script === 'host') throw new Error('The host bridge cannot invoke itself.');
206
- if (!Object.hasOwn(scripts, job.script)) throw new Error(`npm script is not defined: ${job.script}`);
207
- const args = ['run', job.script, ...(job.args.length ? ['--', ...job.args] : [])];
208
- const executable = npmCli ? process.execPath : npmExecutable;
209
- const executableArgs = npmCli ? [npmCli, ...args] : args;
210
- const startedAt = new Date().toISOString();
211
- return new Promise(resolveJob => {
212
- const child = spawn(executable, executableArgs, { cwd: workspace, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
213
- activeChildren.add(child);
214
- const stdout = [], stderr = [], out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
215
- child.stdout.on('data', chunk => appendLimited(stdout, chunk, out)); child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
216
- const finish = (exitCode, message = '') => resolveJob({ jobId: job.jobId, exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: message || Buffer.concat(stderr).toString('utf8'), pid: child.pid ?? null, hostPlatform, hostName, stdoutTruncated: out.truncated, stderrTruncated: err.truncated, stdoutTotalBytes: out.totalBytes, stderrTotalBytes: message ? Buffer.byteLength(message) : err.totalBytes, startedAt, completedAt: new Date().toISOString() });
217
- child.on('error', error => { activeChildren.delete(child); finish(1, error.message); });
218
- child.on('close', code => { activeChildren.delete(child); finish(Number.isInteger(code) ? code : 1); });
219
- });
220
- }
221
-
222
- async function execute(job) {
223
- if (job?.schemaVersion !== 1 || typeof job?.jobId !== 'string') throw new Error('Unsupported host job schema.');
224
- if (job.type === 'npm.scripts.request') return requestScriptChange(job);
225
- if (job.type === 'npm.scripts.confirm') return confirmScriptChange(job);
226
- if (job.type === 'npm.run') return runNpm(job);
227
- throw new Error('Unsupported host job type.');
228
- }
229
-
230
- async function stopChildren() {
231
- await Promise.all([...activeChildren].map(child => new Promise(resolveStop => {
232
- if (!child.pid) return resolveStop();
233
- const done = () => resolveStop(); child.once('close', done);
234
- if (process.platform === 'win32') { const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }); killer.once('close', done); killer.once('error', done); }
235
- else { try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref(); }
236
- })));
237
- }
238
-
239
- async function main() {
240
- let announced = false;
241
- let backoffMs = 500;
242
- while (!stopping) {
243
- try {
244
- await controlRequest('POST', '/host/heartbeat', {});
245
- if (!announced) {
246
- announced = true;
247
- heartbeatTimer = setInterval(() => controlRequest('POST', '/host/heartbeat', {}).catch(error => emit('runner.error', { error: error.message })), 2000);
248
- emit('runner.ready', { workspace, controlUrl, pid: process.pid, platform: hostPlatform, hostname: hostName });
249
- }
250
- const response = await controlRequest('GET', '/host/jobs/next?timeout=25', undefined, 35000);
251
- backoffMs = 500;
252
- if (response.status === 204) {
253
- if (once) break;
254
- continue;
255
- }
256
- const job = response.value;
257
- let result;
258
- if (stopping) {
259
- const timestamp = new Date().toISOString();
260
- result = { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: 'The host runner stopped before executing the assigned job.', pid: null, hostPlatform, hostName, interrupted: true, startedAt: timestamp, completedAt: timestamp };
261
- }
262
- else try { result = await execute(job); }
263
- catch (error) { const message = error instanceof Error ? error.message : String(error); result = { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, startedAt: new Date().toISOString(), completedAt: new Date().toISOString() }; }
264
- do {
265
- try {
266
- await controlRequest('POST', `/host/jobs/${encodeURIComponent(result.jobId)}/result`, result);
267
- break;
268
- } catch (error) {
269
- if (error.status && error.status < 500) { emit('runner.error', { error: error.message }); break; }
270
- await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
271
- backoffMs = Math.min(10000, backoffMs * 2);
272
- }
273
- } while (!stopping || Date.now() < shutdownDeadline);
274
- if (once) break;
275
- } catch (error) {
276
- if (error.status === 401) throw error;
277
- emit('runner.error', { error: `${error.message}; reconnecting` });
278
- await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
279
- backoffMs = Math.min(10000, backoffMs * 2);
280
- }
281
- }
282
- clearInterval(heartbeatTimer);
283
- }
284
-
285
- for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
286
- if (stopping) return;
287
- stopping = true;
288
- shutdownDeadline = Date.now() + 5000;
289
- clearInterval(heartbeatTimer);
290
- stopChildren().catch(error => emit('runner.error', { error: error.message || String(error) }));
291
- });
292
- main().catch(error => { emit('runner.error', { error: error.message || String(error) }); clearInterval(heartbeatTimer); stopChildren().finally(() => process.exit(1)); });
package/package.host.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "name": "nestbox-workspace",
3
- "private": true,
4
- "scripts": {
5
- "host": "node .nestbox/host-runner.mjs",
6
- "test:host": "node .nestbox/tests/host-runner.mjs"
7
- }
8
- }
@@ -1,143 +0,0 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { createServer } from 'node:http';
3
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
-
8
- const fixture = await mkdtemp(join(tmpdir(), 'nestbox-host-runner-'));
9
- const tokenFixture = await mkdtemp(join(tmpdir(), 'nestbox-host-token-'));
10
- const testDirectory = dirname(fileURLToPath(import.meta.url));
11
- const runnerPath = resolve(testDirectory, '..', 'host-runner.mjs');
12
- const token = 'test-token-'.padEnd(64, 'x');
13
- const jobs = [];
14
- const results = new Map();
15
- const resultAttempts = new Map();
16
- let runner;
17
- let runnerStdout = '';
18
- let heartbeatConflicts = 1;
19
-
20
- function readBody(request) {
21
- return new Promise((resolveBody, reject) => {
22
- const chunks = [];
23
- request.on('data', chunk => chunks.push(chunk));
24
- request.on('end', () => { try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (error) { reject(error); } });
25
- request.on('error', reject);
26
- });
27
- }
28
-
29
- const server = createServer(async (request, response) => {
30
- if (request.headers.authorization !== `Bearer ${token}` || !request.headers['x-nestbox-runner-id']) {
31
- response.writeHead(401).end('{"error":"unauthorized"}'); return;
32
- }
33
- if (request.method === 'POST' && request.url === '/_nestbox/host/heartbeat') {
34
- if (heartbeatConflicts > 0) { heartbeatConflicts -= 1; response.writeHead(409).end('{"error":"another host runner is active"}'); return; }
35
- await readBody(request); response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); return;
36
- }
37
- if (request.method === 'GET' && request.url.startsWith('/_nestbox/host/jobs/next')) {
38
- const job = jobs.shift();
39
- if (!job) { response.writeHead(204).end(); return; }
40
- response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(job)); return;
41
- }
42
- const match = request.url.match(/^\/_nestbox\/host\/jobs\/([^/]+)\/result$/);
43
- if (request.method === 'POST' && match) {
44
- const jobId = decodeURIComponent(match[1]);
45
- const result = await readBody(request);
46
- const attempt = (resultAttempts.get(jobId) || 0) + 1;
47
- resultAttempts.set(jobId, attempt);
48
- if (jobId === 'job-retry' && attempt === 1) { response.writeHead(503).end('{"error":"restarting"}'); return; }
49
- results.set(jobId, result);
50
- response.writeHead(202, { 'content-type': 'application/json' }).end('{"accepted":true}'); return;
51
- }
52
- response.writeHead(404).end();
53
- });
54
-
55
- async function waitForResult(jobId, timeout = 10000) {
56
- const deadline = Date.now() + timeout;
57
- while (Date.now() < deadline) {
58
- if (results.has(jobId)) return results.get(jobId);
59
- await new Promise(resolveWait => setTimeout(resolveWait, 25));
60
- }
61
- throw new Error(`Timed out waiting for result ${jobId}`);
62
- }
63
-
64
- async function waitForEvent(type, requestId, timeout = 10000) {
65
- const deadline = Date.now() + timeout;
66
- while (Date.now() < deadline) {
67
- for (const line of runnerStdout.split(/\r?\n/).filter(Boolean)) {
68
- const event = JSON.parse(line);
69
- if (event.type === type && (!requestId || event.requestId === requestId)) return event;
70
- }
71
- await new Promise(resolveWait => setTimeout(resolveWait, 25));
72
- }
73
- throw new Error(`Timed out waiting for event ${type}`);
74
- }
75
-
76
- function submit(job) { jobs.push({ schemaVersion: 1, ...job }); return waitForResult(job.jobId); }
77
-
78
- try {
79
- await writeFile(join(fixture, 'package.json'), JSON.stringify({
80
- private: true,
81
- scripts: {
82
- host: 'node host-runner.mjs',
83
- ok: `node -e "process.stdout.write('host-ok')"`,
84
- fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
85
- },
86
- }));
87
- const tokenPath = join(tokenFixture, 'host-token');
88
- await writeFile(tokenPath, `${token}\n`);
89
- await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolveListen); });
90
- const port = server.address().port;
91
- runner = spawn(process.execPath, [runnerPath, '--json'], {
92
- env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
93
- stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
94
- });
95
- let runnerStderr = '';
96
- runner.stdout.on('data', chunk => runnerStdout += chunk); runner.stderr.on('data', chunk => runnerStderr += chunk);
97
- await waitForEvent('runner.ready');
98
-
99
- const success = await submit({ jobId: 'job-ok', type: 'npm.run', script: 'ok', args: [] });
100
- const failure = await submit({ jobId: 'job-fail', type: 'npm.run', script: 'fail', args: [] });
101
- if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
102
- if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
103
- const retried = await submit({ jobId: 'job-retry', type: 'npm.run', script: 'ok', args: [] });
104
- if (retried.exitCode !== 0 || resultAttempts.get('job-retry') !== 2) throw new Error('Host result was not retried after a transient control failure.');
105
-
106
- const requestId = 'npm-script-request-test-add';
107
- const requestedPromise = submit({ jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` });
108
- const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
109
- const requested = await requestedPromise;
110
- if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
111
- const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
112
- const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
113
- if (confirmed.status !== 'success' || !changed.scripts.approved) throw new Error('Approved script was not added.');
114
-
115
- const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
116
- if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
117
-
118
- const exit = new Promise((resolveExit, reject) => { runner.on('error', reject); runner.on('close', code => code === 0 || code === null ? resolveExit() : reject(new Error(`Runner exited ${code}: ${runnerStderr}`))); });
119
- runner.kill('SIGTERM'); await exit; runner = undefined;
120
-
121
- const onceRunner = spawn(process.execPath, [runnerPath, '--once', '--json'], {
122
- env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
123
- stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
124
- });
125
- const onceExit = await Promise.race([
126
- new Promise(resolveExit => onceRunner.once('close', resolveExit)),
127
- new Promise((_, reject) => setTimeout(() => reject(new Error('--once runner did not exit after an empty poll.')), 5000)),
128
- ]);
129
- if (onceExit !== 0) throw new Error(`--once runner exited ${onceExit}.`);
130
-
131
- const unsafeToken = join(fixture, 'unsafe-token');
132
- await writeFile(unsafeToken, `${token}\n`);
133
- const unsafe = spawnSync(process.execPath, [runnerPath, '--once'], {
134
- env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: unsafeToken, NESTBOX_HOST_TOKEN: '' }, encoding: 'utf8', windowsHide: true,
135
- });
136
- if (unsafe.status === 0 || !unsafe.stderr.includes('must be outside')) throw new Error('Runner accepted a token inside the workspace.');
137
- console.log('Host runner HTTP tests passed.');
138
- } finally {
139
- runner?.kill('SIGTERM');
140
- await new Promise(resolveClose => server.close(resolveClose));
141
- await rm(fixture, { recursive: true, force: true });
142
- await rm(tokenFixture, { recursive: true, force: true });
143
- }