@p10i/create-nestbox 0.2.0 → 0.3.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/.env.example +4 -3
- package/CHANGELOG.md +7 -0
- package/INSTALL.md +10 -6
- package/README.md +1 -9
- package/bin/create-nestbox.js +23 -14
- package/docker/control/server.py +254 -48
- package/docker-compose.yaml +8 -3
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +3 -3
- package/home/configs/rollup/package-lock.json +10 -13
- package/host-runner.mjs +152 -165
- package/package.json +7 -4
- package/scripts/check-package.js +4 -0
- package/tests/control.mjs +19 -0
- package/tests/creator.mjs +2 -1
- package/tests/host-runner.mjs +87 -47
- package/tests/nestbox.ps1 +9 -3
- package/tests/nestbox.sh +7 -5
- package/home/code/scripts-bundle.css +0 -13295
- package/home/code/scripts-bundle.js +0 -6277
|
@@ -1088,16 +1088,15 @@
|
|
|
1088
1088
|
"license": "CC0-1.0"
|
|
1089
1089
|
},
|
|
1090
1090
|
"node_modules/nanoid": {
|
|
1091
|
-
"version": "3.3.
|
|
1092
|
-
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.
|
|
1093
|
-
"integrity": "sha512-
|
|
1091
|
+
"version": "3.3.18",
|
|
1092
|
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
|
1093
|
+
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
|
1094
1094
|
"funding": [
|
|
1095
1095
|
{
|
|
1096
1096
|
"type": "github",
|
|
1097
1097
|
"url": "https://github.com/sponsors/ai"
|
|
1098
1098
|
}
|
|
1099
1099
|
],
|
|
1100
|
-
"license": "MIT",
|
|
1101
1100
|
"peer": true,
|
|
1102
1101
|
"bin": {
|
|
1103
1102
|
"nanoid": "bin/nanoid.cjs"
|
|
@@ -1210,9 +1209,9 @@
|
|
|
1210
1209
|
}
|
|
1211
1210
|
},
|
|
1212
1211
|
"node_modules/postcss": {
|
|
1213
|
-
"version": "8.5.
|
|
1214
|
-
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.
|
|
1215
|
-
"integrity": "sha512-
|
|
1212
|
+
"version": "8.5.26",
|
|
1213
|
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
|
1214
|
+
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
|
1216
1215
|
"funding": [
|
|
1217
1216
|
{
|
|
1218
1217
|
"type": "opencollective",
|
|
@@ -1227,10 +1226,9 @@
|
|
|
1227
1226
|
"url": "https://github.com/sponsors/ai"
|
|
1228
1227
|
}
|
|
1229
1228
|
],
|
|
1230
|
-
"license": "MIT",
|
|
1231
1229
|
"peer": true,
|
|
1232
1230
|
"dependencies": {
|
|
1233
|
-
"nanoid": "^3.3.
|
|
1231
|
+
"nanoid": "^3.3.17",
|
|
1234
1232
|
"picocolors": "^1.1.1",
|
|
1235
1233
|
"source-map-js": "^1.2.1"
|
|
1236
1234
|
},
|
|
@@ -2020,10 +2018,9 @@
|
|
|
2020
2018
|
}
|
|
2021
2019
|
},
|
|
2022
2020
|
"node_modules/svgo": {
|
|
2023
|
-
"version": "2.8.
|
|
2024
|
-
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.
|
|
2025
|
-
"integrity": "sha512-
|
|
2026
|
-
"license": "MIT",
|
|
2021
|
+
"version": "2.8.3",
|
|
2022
|
+
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.3.tgz",
|
|
2023
|
+
"integrity": "sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==",
|
|
2027
2024
|
"dependencies": {
|
|
2028
2025
|
"commander": "^7.2.0",
|
|
2029
2026
|
"css-select": "^4.1.3",
|
package/host-runner.mjs
CHANGED
|
@@ -1,30 +1,48 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
3
3
|
import { hostname, platform } from 'node:os';
|
|
4
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import {
|
|
6
|
+
import { readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
|
|
8
8
|
const installDirectory = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const defaultWorkspace = basename(installDirectory) === '.nestbox' ? dirname(installDirectory) : installDirectory;
|
|
10
10
|
const workspace = resolve(process.env.NESTBOX_HOST_WORKSPACE || defaultWorkspace);
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 controlUrl = (process.env.NESTBOX_CONTROL_HOST_URL || fileEnv.NESTBOX_CONTROL_HOST_URL || `http://127.0.0.1:${process.env.NESTBOX_CONTROL_HOST_PORT || fileEnv.NESTBOX_CONTROL_HOST_PORT || '4089'}`).replace(/\/$/, '');
|
|
28
|
+
const tokenPath = process.env.NESTBOX_HOST_TOKEN_FILE || fileEnv.NESTBOX_HOST_TOKEN_FILE || '';
|
|
29
|
+
const resolvedTokenPath = tokenPath ? await realpath(resolve(tokenPath)).catch(() => resolve(tokenPath)) : '';
|
|
30
|
+
function containsPath(root, target) {
|
|
31
|
+
const child = relative(resolve(root), target);
|
|
32
|
+
return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
|
|
33
|
+
}
|
|
34
|
+
if (resolvedTokenPath && (containsPath(canonicalWorkspace, resolvedTokenPath) || containsPath(canonicalInstallDirectory, resolvedTokenPath))) {
|
|
35
|
+
throw new Error('NESTBOX_HOST_TOKEN_FILE must be outside the workspace and Nestbox installation.');
|
|
17
36
|
}
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const takeoverPath = join(queueRoot, 'takeover.lock');
|
|
24
|
-
const instanceId = `${process.pid}-${Date.now()}`;
|
|
37
|
+
const fileToken = resolvedTokenPath ? await readFile(resolvedTokenPath, 'utf8').then(value => value.trim(), () => '') : '';
|
|
38
|
+
const hostToken = fileToken;
|
|
39
|
+
if (hostToken.length < 32) throw new Error('NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters.');
|
|
40
|
+
|
|
41
|
+
const runnerId = randomUUID();
|
|
25
42
|
const once = process.argv.slice(2).includes('--once');
|
|
26
43
|
const jsonOutput = process.argv.slice(2).includes('--json');
|
|
27
44
|
const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
|
|
45
|
+
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') : '');
|
|
28
46
|
const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
|
|
29
47
|
const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
|
|
30
48
|
const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
|
|
@@ -34,11 +52,13 @@ const hostName = hostname();
|
|
|
34
52
|
const approvals = new Map();
|
|
35
53
|
const activeChildren = new Set();
|
|
36
54
|
let heartbeatTimer;
|
|
55
|
+
let stopping = false;
|
|
56
|
+
let shutdownDeadline = 0;
|
|
37
57
|
|
|
38
58
|
function emit(type, fields = {}) {
|
|
39
59
|
const event = { ...fields, type, timestamp: new Date().toISOString() };
|
|
40
60
|
if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
41
|
-
if (type === 'runner.ready') console.log(`Nestbox host runner
|
|
61
|
+
if (type === 'runner.ready') console.log(`Nestbox host runner connected to ${controlUrl} for ${workspace}`);
|
|
42
62
|
else if (type === 'npm.scripts.confirmation_required') {
|
|
43
63
|
console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
|
|
44
64
|
if (event.command !== undefined) console.log(`Command: ${event.command}`);
|
|
@@ -47,59 +67,71 @@ function emit(type, fields = {}) {
|
|
|
47
67
|
else if (type === 'runner.error') console.error(event.error);
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
function headers(json = false) {
|
|
71
|
+
return {
|
|
72
|
+
Authorization: `Bearer ${hostToken}`,
|
|
73
|
+
'X-Nestbox-Runner-Id': runnerId,
|
|
74
|
+
'X-Nestbox-Runner-Pid': String(process.pid),
|
|
75
|
+
'X-Nestbox-Runner-Platform': hostPlatform,
|
|
76
|
+
'X-Nestbox-Runner-Host': hostName,
|
|
77
|
+
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function controlRequest(method, path, body, timeoutMs = 35000) {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
53
84
|
try {
|
|
54
|
-
await
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
const response = await fetch(controlUrl + path, {
|
|
86
|
+
method,
|
|
87
|
+
headers: headers(body !== undefined),
|
|
88
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
const text = await response.text();
|
|
92
|
+
const value = text ? JSON.parse(text) : null;
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const error = new Error(`Control ${method} ${path} returned ${response.status}: ${JSON.stringify(value)}`);
|
|
95
|
+
error.status = response.status;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return { status: response.status, value };
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
59
101
|
}
|
|
60
102
|
}
|
|
61
103
|
|
|
62
|
-
async function
|
|
63
|
-
|
|
104
|
+
async function packageText() { return readFile(join(workspace, 'package.json'), 'utf8'); }
|
|
105
|
+
function parsePackageDocument(text) {
|
|
106
|
+
const document = JSON.parse(text);
|
|
64
107
|
if (!document || typeof document !== 'object' || Array.isArray(document)) throw new Error('package.json must contain a JSON object.');
|
|
108
|
+
if (document.scripts !== undefined && (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts))) throw new Error('package.json scripts must be an object.');
|
|
65
109
|
return document;
|
|
66
110
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const contents = await readFile(join(workspace, 'package.json'));
|
|
70
|
-
return createHash('sha256').update(contents).digest('hex');
|
|
71
|
-
}
|
|
72
|
-
|
|
111
|
+
async function packageDocument() { return parsePackageDocument(await packageText()); }
|
|
112
|
+
function hash(contents) { return createHash('sha256').update(contents).digest('hex'); }
|
|
73
113
|
async function packageScripts() {
|
|
74
114
|
const document = await packageDocument();
|
|
75
115
|
if (document.scripts === undefined) return {};
|
|
76
|
-
if (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts))
|
|
77
|
-
throw new Error('package.json scripts must be an object.');
|
|
78
|
-
}
|
|
116
|
+
if (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts)) throw new Error('package.json scripts must be an object.');
|
|
79
117
|
return document.scripts;
|
|
80
118
|
}
|
|
81
119
|
|
|
82
120
|
async function writePackageJson(document, expectedHash) {
|
|
83
121
|
const path = join(workspace, 'package.json');
|
|
84
|
-
const temporary = `${path}.${
|
|
122
|
+
const temporary = `${path}.${runnerId}.tmp`;
|
|
85
123
|
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
86
|
-
if (
|
|
124
|
+
if (hash(await packageText()) !== expectedHash) {
|
|
87
125
|
await unlink(temporary).catch(() => {});
|
|
88
126
|
throw new Error('package.json changed while the approved update was being prepared.');
|
|
89
127
|
}
|
|
90
|
-
try {
|
|
91
|
-
|
|
92
|
-
} catch (error) {
|
|
128
|
+
try { await rename(temporary, path); }
|
|
129
|
+
catch (error) {
|
|
93
130
|
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
|
|
94
|
-
const backup = `${path}.${
|
|
131
|
+
const backup = `${path}.${runnerId}.backup`;
|
|
95
132
|
await rename(path, backup);
|
|
96
|
-
try {
|
|
97
|
-
|
|
98
|
-
await unlink(backup);
|
|
99
|
-
} catch (replaceError) {
|
|
100
|
-
await rename(backup, path).catch(() => {});
|
|
101
|
-
throw replaceError;
|
|
102
|
-
}
|
|
133
|
+
try { await rename(temporary, path); await unlink(backup); }
|
|
134
|
+
catch (replaceError) { await rename(backup, path).catch(() => {}); throw replaceError; }
|
|
103
135
|
}
|
|
104
136
|
}
|
|
105
137
|
|
|
@@ -110,9 +142,7 @@ function validateScriptChange(job, scripts) {
|
|
|
110
142
|
const exists = Object.hasOwn(scripts, job.name);
|
|
111
143
|
if (job.operation === 'add' && exists) throw new Error(`npm script already exists: ${job.name}`);
|
|
112
144
|
if (job.operation !== 'add' && !exists) throw new Error(`npm script is not defined: ${job.name}`);
|
|
113
|
-
if (job.operation !== 'delete' && (typeof job.command !== 'string' || !job.command.trim() || job.command.length > 8192))
|
|
114
|
-
throw new Error('A non-empty command of at most 8192 characters is required.');
|
|
115
|
-
}
|
|
145
|
+
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.');
|
|
116
146
|
if (job.operation === 'delete' && job.command !== undefined) throw new Error('Delete does not accept a command.');
|
|
117
147
|
}
|
|
118
148
|
|
|
@@ -126,47 +156,31 @@ async function requestScriptChange(job) {
|
|
|
126
156
|
pruneApprovals();
|
|
127
157
|
if (approvals.has(job.requestId)) throw new Error('The request ID is already pending.');
|
|
128
158
|
if (approvals.size >= maximumPendingApprovals) throw new Error('Too many npm script approvals are pending.');
|
|
129
|
-
const
|
|
159
|
+
const text = await packageText();
|
|
160
|
+
const scripts = parsePackageDocument(text).scripts || {};
|
|
130
161
|
validateScriptChange(job, scripts);
|
|
131
162
|
const code = randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
|
|
132
163
|
const expiresAtMs = Date.now() + approvalTtlMs;
|
|
133
|
-
approvals.set(job.requestId, {
|
|
134
|
-
...job,
|
|
135
|
-
expectedCommand: Object.hasOwn(scripts, job.name) ? String(scripts[job.name]) : null,
|
|
136
|
-
expectedPackageHash: await packageHash(),
|
|
137
|
-
code,
|
|
138
|
-
attempts: 0,
|
|
139
|
-
expiresAtMs,
|
|
140
|
-
});
|
|
164
|
+
approvals.set(job.requestId, { ...job, expectedCommand: Object.hasOwn(scripts, job.name) ? String(scripts[job.name]) : null, expectedPackageHash: hash(text), code, attempts: 0, expiresAtMs });
|
|
141
165
|
const expiresAt = new Date(expiresAtMs).toISOString();
|
|
142
166
|
emit('npm.scripts.confirmation_required', { ...job, code, expiresAt });
|
|
143
167
|
return { jobId: job.jobId, status: 'confirmation_required', requestId: job.requestId, operation: job.operation, name: job.name, expiresAt };
|
|
144
168
|
}
|
|
145
169
|
|
|
146
170
|
async function confirmScriptChange(job) {
|
|
147
|
-
if (typeof job.requestId !== 'string' || typeof job.code !== 'string') throw new Error('A request ID and confirmation code are required.');
|
|
148
171
|
pruneApprovals();
|
|
149
172
|
const approval = approvals.get(job.requestId);
|
|
150
173
|
if (!approval) throw new Error('The npm script approval was not found or has expired.');
|
|
151
174
|
approval.attempts += 1;
|
|
152
|
-
if (job.code.trim().toUpperCase() !== approval.code) {
|
|
175
|
+
if (String(job.code).trim().toUpperCase() !== approval.code) {
|
|
153
176
|
if (approval.attempts >= approvalMaximumAttempts) approvals.delete(job.requestId);
|
|
154
177
|
throw new Error(approval.attempts >= approvalMaximumAttempts ? 'Invalid confirmation code; the approval has been cancelled.' : 'Invalid confirmation code.');
|
|
155
178
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
throw new Error('package.json changed after approval was requested; request a new approval.');
|
|
160
|
-
}
|
|
161
|
-
const scripts = document.scripts || {};
|
|
162
|
-
const currentCommand = Object.hasOwn(scripts, approval.name) ? String(scripts[approval.name]) : null;
|
|
163
|
-
if (currentCommand !== approval.expectedCommand) {
|
|
164
|
-
approvals.delete(job.requestId);
|
|
165
|
-
throw new Error('package.json changed after approval was requested; request a new approval.');
|
|
166
|
-
}
|
|
179
|
+
const text = await packageText();
|
|
180
|
+
if (hash(text) !== approval.expectedPackageHash) { approvals.delete(job.requestId); throw new Error('package.json changed after approval was requested; request a new approval.'); }
|
|
181
|
+
const document = parsePackageDocument(text), scripts = document.scripts || {};
|
|
167
182
|
validateScriptChange(approval, scripts);
|
|
168
|
-
if (approval.operation === 'delete') delete scripts[approval.name];
|
|
169
|
-
else scripts[approval.name] = approval.command;
|
|
183
|
+
if (approval.operation === 'delete') delete scripts[approval.name]; else scripts[approval.name] = approval.command;
|
|
170
184
|
document.scripts = scripts;
|
|
171
185
|
await writePackageJson(document, approval.expectedPackageHash);
|
|
172
186
|
approvals.delete(job.requestId);
|
|
@@ -175,12 +189,10 @@ async function confirmScriptChange(job) {
|
|
|
175
189
|
}
|
|
176
190
|
|
|
177
191
|
function appendLimited(chunks, chunk, state) {
|
|
178
|
-
const buffer = Buffer.from(chunk);
|
|
179
|
-
state.totalBytes += buffer.length;
|
|
192
|
+
const buffer = Buffer.from(chunk); state.totalBytes += buffer.length;
|
|
180
193
|
const available = Math.max(0, maximumOutputBytes - state.bytes);
|
|
181
194
|
if (available) chunks.push(buffer.subarray(0, available));
|
|
182
|
-
state.bytes += Math.min(buffer.length, available);
|
|
183
|
-
if (buffer.length > available) state.truncated = true;
|
|
195
|
+
state.bytes += Math.min(buffer.length, available); if (buffer.length > available) state.truncated = true;
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
async function runNpm(job) {
|
|
@@ -189,24 +201,17 @@ async function runNpm(job) {
|
|
|
189
201
|
if (job.script === 'host') throw new Error('The host bridge cannot invoke itself.');
|
|
190
202
|
if (!Object.hasOwn(scripts, job.script)) throw new Error(`npm script is not defined: ${job.script}`);
|
|
191
203
|
const args = ['run', job.script, ...(job.args.length ? ['--', ...job.args] : [])];
|
|
192
|
-
const executable =
|
|
193
|
-
const executableArgs =
|
|
204
|
+
const executable = npmCli ? process.execPath : npmExecutable;
|
|
205
|
+
const executableArgs = npmCli ? [npmCli, ...args] : args;
|
|
194
206
|
const startedAt = new Date().toISOString();
|
|
195
|
-
return
|
|
207
|
+
return new Promise(resolveJob => {
|
|
196
208
|
const child = spawn(executable, executableArgs, { cwd: workspace, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
197
209
|
activeChildren.add(child);
|
|
198
|
-
const stdout = [], stderr = [];
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
child.
|
|
202
|
-
|
|
203
|
-
jobId: job.jobId, exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: message || Buffer.concat(stderr).toString('utf8'),
|
|
204
|
-
pid: child.pid ?? null, hostPlatform, hostName, stdoutTruncated: out.truncated, stderrTruncated: err.truncated,
|
|
205
|
-
stdoutTotalBytes: out.totalBytes, stderrTotalBytes: message ? Buffer.byteLength(message) : err.totalBytes,
|
|
206
|
-
startedAt, completedAt: new Date().toISOString(),
|
|
207
|
-
});
|
|
208
|
-
child.on('error', error => { activeChildren.delete(child); result(1, error.message); });
|
|
209
|
-
child.on('close', code => { activeChildren.delete(child); result(Number.isInteger(code) ? code : 1); });
|
|
210
|
+
const stdout = [], stderr = [], out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
|
|
211
|
+
child.stdout.on('data', chunk => appendLimited(stdout, chunk, out)); child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
|
|
212
|
+
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() });
|
|
213
|
+
child.on('error', error => { activeChildren.delete(child); finish(1, error.message); });
|
|
214
|
+
child.on('close', code => { activeChildren.delete(child); finish(Number.isInteger(code) ? code : 1); });
|
|
210
215
|
});
|
|
211
216
|
}
|
|
212
217
|
|
|
@@ -218,84 +223,66 @@ async function execute(job) {
|
|
|
218
223
|
throw new Error('Unsupported host job type.');
|
|
219
224
|
}
|
|
220
225
|
|
|
221
|
-
async function
|
|
222
|
-
const name = basename(path);
|
|
223
|
-
const runningPath = join(runningDirectory, name);
|
|
224
|
-
try { await rename(path, runningPath); } catch (error) { if (['ENOENT', 'EACCES', 'EPERM'].includes(error?.code)) return; throw error; }
|
|
225
|
-
const claimedJobId = name.replace(/\.json$/, '');
|
|
226
|
-
let result;
|
|
227
|
-
try {
|
|
228
|
-
const job = JSON.parse(await readFile(runningPath, 'utf8'));
|
|
229
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(claimedJobId) || job?.jobId !== claimedJobId) throw new Error('Host job ID must match its safe queue filename.');
|
|
230
|
-
result = await execute(job);
|
|
231
|
-
} catch (error) {
|
|
232
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
233
|
-
result = { jobId: claimedJobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, stdoutTruncated: false, stderrTruncated: false, stdoutTotalBytes: 0, stderrTotalBytes: Buffer.byteLength(message), startedAt: new Date().toISOString(), completedAt: new Date().toISOString() };
|
|
234
|
-
}
|
|
235
|
-
await atomicJson(join(resultsDirectory, `${result.jobId}.json`), result);
|
|
236
|
-
await unlink(runningPath).catch(() => {});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function cleanup() {
|
|
240
|
-
clearInterval(heartbeatTimer);
|
|
226
|
+
async function stopChildren() {
|
|
241
227
|
await Promise.all([...activeChildren].map(child => new Promise(resolveStop => {
|
|
242
228
|
if (!child.pid) return resolveStop();
|
|
243
|
-
const done = () => resolveStop();
|
|
244
|
-
child.once('close', done);
|
|
245
|
-
|
|
246
|
-
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
|
|
247
|
-
killer.once('close', done); killer.once('error', done);
|
|
248
|
-
} else {
|
|
249
|
-
try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); }
|
|
250
|
-
setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref();
|
|
251
|
-
}
|
|
229
|
+
const done = () => resolveStop(); child.once('close', done);
|
|
230
|
+
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); }
|
|
231
|
+
else { try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref(); }
|
|
252
232
|
})));
|
|
253
|
-
try {
|
|
254
|
-
if ((await readFile(lockPath, 'utf8')).trim() === instanceId) await Promise.all([unlink(lockPath).catch(() => {}), unlink(heartbeatPath).catch(() => {})]);
|
|
255
|
-
} catch {}
|
|
256
233
|
}
|
|
257
234
|
|
|
258
235
|
async function main() {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
} catch (error) {
|
|
263
|
-
const heartbeatStale = await stat(heartbeatPath).then(value => Date.now() - value.mtimeMs > 5000, () => null);
|
|
264
|
-
const lockOld = await stat(lockPath).then(value => Date.now() - value.mtimeMs > 5000, () => false);
|
|
265
|
-
const stale = heartbeatStale === true || (heartbeatStale === null && lockOld);
|
|
266
|
-
if (error?.code !== 'EEXIST' || !stale) throw error;
|
|
267
|
-
let takeover;
|
|
236
|
+
let announced = false;
|
|
237
|
+
let backoffMs = 500;
|
|
238
|
+
while (!stopping) {
|
|
268
239
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
240
|
+
await controlRequest('POST', '/host/heartbeat', {});
|
|
241
|
+
if (!announced) {
|
|
242
|
+
announced = true;
|
|
243
|
+
heartbeatTimer = setInterval(() => controlRequest('POST', '/host/heartbeat', {}).catch(error => emit('runner.error', { error: error.message })), 2000);
|
|
244
|
+
emit('runner.ready', { workspace, controlUrl, pid: process.pid, platform: hostPlatform, hostname: hostName });
|
|
245
|
+
}
|
|
246
|
+
const response = await controlRequest('GET', '/host/jobs/next?timeout=25', undefined, 35000);
|
|
247
|
+
backoffMs = 500;
|
|
248
|
+
if (response.status === 204) {
|
|
249
|
+
if (once) break;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const job = response.value;
|
|
253
|
+
let result;
|
|
254
|
+
if (stopping) {
|
|
255
|
+
const timestamp = new Date().toISOString();
|
|
256
|
+
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 };
|
|
257
|
+
}
|
|
258
|
+
else try { result = await execute(job); }
|
|
259
|
+
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() }; }
|
|
260
|
+
do {
|
|
261
|
+
try {
|
|
262
|
+
await controlRequest('POST', `/host/jobs/${encodeURIComponent(result.jobId)}/result`, result);
|
|
263
|
+
break;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error.status && error.status < 500) { emit('runner.error', { error: error.message }); break; }
|
|
266
|
+
await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
|
|
267
|
+
backoffMs = Math.min(10000, backoffMs * 2);
|
|
268
|
+
}
|
|
269
|
+
} while (!stopping || Date.now() < shutdownDeadline);
|
|
270
|
+
if (once) break;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error.status === 401) throw error;
|
|
273
|
+
emit('runner.error', { error: `${error.message}; reconnecting` });
|
|
274
|
+
await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
|
|
275
|
+
backoffMs = Math.min(10000, backoffMs * 2);
|
|
273
276
|
}
|
|
274
|
-
try {
|
|
275
|
-
await unlink(heartbeatPath).catch(() => {});
|
|
276
|
-
await unlink(lockPath);
|
|
277
|
-
const lock = await open(lockPath, 'wx'); await lock.writeFile(`${instanceId}\n`); await lock.close();
|
|
278
|
-
} finally {
|
|
279
|
-
await takeover.close();
|
|
280
|
-
await unlink(takeoverPath).catch(() => {});
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
for (const name of await readdir(runningDirectory)) {
|
|
284
|
-
if (!name.endsWith('.json')) continue;
|
|
285
|
-
const jobId = name.replace(/\.json$/, '');
|
|
286
|
-
const message = 'Host runner stopped while this job was active; the job was not replayed.';
|
|
287
|
-
await atomicJson(join(resultsDirectory, name), { jobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, interrupted: true, startedAt: new Date().toISOString(), completedAt: new Date().toISOString() });
|
|
288
|
-
await unlink(join(runningDirectory, name));
|
|
289
277
|
}
|
|
290
|
-
|
|
291
|
-
await heartbeat(); heartbeatTimer = setInterval(() => heartbeat().catch(error => emit('runner.error', { error: error.message })), 1000);
|
|
292
|
-
emit('runner.ready', { workspace, pid: process.pid, platform: hostPlatform, hostname: hostName });
|
|
293
|
-
do {
|
|
294
|
-
for (const name of (await readdir(requestsDirectory)).filter(name => name.endsWith('.json')).sort()) await processRequest(join(requestsDirectory, name));
|
|
295
|
-
if (!once) await new Promise(resolveWait => setTimeout(resolveWait, 100));
|
|
296
|
-
} while (!once);
|
|
297
|
-
await cleanup();
|
|
278
|
+
clearInterval(heartbeatTimer);
|
|
298
279
|
}
|
|
299
280
|
|
|
300
|
-
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () =>
|
|
301
|
-
|
|
281
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
282
|
+
if (stopping) return;
|
|
283
|
+
stopping = true;
|
|
284
|
+
shutdownDeadline = Date.now() + 5000;
|
|
285
|
+
clearInterval(heartbeatTimer);
|
|
286
|
+
stopChildren().catch(error => emit('runner.error', { error: error.message || String(error) }));
|
|
287
|
+
});
|
|
288
|
+
main().catch(error => { emit('runner.error', { error: error.message || String(error) }); clearInterval(heartbeatTimer); stopChildren().finally(() => process.exit(1)); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@p10i/create-nestbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Create Nestbox projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,8 +11,6 @@
|
|
|
11
11
|
"bin/",
|
|
12
12
|
"scripts/",
|
|
13
13
|
"docker/",
|
|
14
|
-
"!docker/control/__pycache__/",
|
|
15
|
-
"!**/*.pyc",
|
|
16
14
|
"home/",
|
|
17
15
|
"tests/",
|
|
18
16
|
"host-runner.mjs",
|
|
@@ -25,7 +23,12 @@
|
|
|
25
23
|
"INSTALL.md",
|
|
26
24
|
"README.md",
|
|
27
25
|
"CHANGELOG.md",
|
|
28
|
-
"LICENSE"
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"!docker/control/__pycache__/",
|
|
28
|
+
"!**/*.pyc",
|
|
29
|
+
"!**/node_modules/",
|
|
30
|
+
"!home/code/scripts-bundle.js",
|
|
31
|
+
"!home/code/scripts-bundle.css"
|
|
29
32
|
],
|
|
30
33
|
"scripts": {
|
|
31
34
|
"host": "node host-runner.mjs",
|
package/scripts/check-package.js
CHANGED
|
@@ -41,4 +41,8 @@ for (const path of ['host-runner.mjs', 'package.host.json']) {
|
|
|
41
41
|
if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
for (const path of ['!**/node_modules/', '!home/code/scripts-bundle.js', '!home/code/scripts-bundle.css']) {
|
|
45
|
+
if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
44
48
|
console.log('Package checks passed.');
|
package/tests/control.mjs
CHANGED
|
@@ -25,6 +25,25 @@ target["Labels"]["com.docker.compose.service"] = "rollup"
|
|
|
25
25
|
assert "not allowed" in control.policy_error(source, target, "1000")
|
|
26
26
|
source["Labels"].pop("nestbox.exec.targets")
|
|
27
27
|
assert "no allowed" in control.policy_error(source, target, "1000")
|
|
28
|
+
broker = control.HostBroker()
|
|
29
|
+
headers = {"X-Nestbox-Runner-Pid": "123", "X-Nestbox-Runner-Platform": "test", "X-Nestbox-Runner-Host": "host"}
|
|
30
|
+
broker.touch("runner-test-1234", headers)
|
|
31
|
+
assert broker.status()["available"]
|
|
32
|
+
job = {"jobId": "job-test", "type": "npm.run"}
|
|
33
|
+
broker.enqueue(job)
|
|
34
|
+
assert broker.next("runner-test-1234", headers, 1) == job
|
|
35
|
+
broker.complete("runner-test-1234", "job-test", {"jobId": "job-test", "exitCode": 0, "stdout": "", "stderr": ""})
|
|
36
|
+
assert broker.wait("job-test", 1)["exitCode"] == 0
|
|
37
|
+
stale = control.HostBroker()
|
|
38
|
+
stale.touch("runner-stale-1234", headers)
|
|
39
|
+
stale.enqueue({"jobId": "job-stale", "type": "npm.run"})
|
|
40
|
+
assert stale.next("runner-stale-1234", headers, 1)["jobId"] == "job-stale"
|
|
41
|
+
control.HOST_HEARTBEAT_TTL = 0
|
|
42
|
+
assert stale.wait("job-stale", 1)["interrupted"] is True
|
|
43
|
+
pending = control.HostBroker()
|
|
44
|
+
pending.touch("runner-pending-1234", headers)
|
|
45
|
+
pending.enqueue({"jobId": "job-pending", "type": "npm.run"})
|
|
46
|
+
assert pending.wait("job-pending", 1)["interrupted"] is True
|
|
28
47
|
`;
|
|
29
48
|
|
|
30
49
|
let result = spawnSync(python, ['-c', policyTest], {
|
package/tests/creator.mjs
CHANGED
|
@@ -19,7 +19,8 @@ try {
|
|
|
19
19
|
if (nestedPackage.name !== 'existing' || nestedPackage.scripts.build !== 'node build.mjs') throw new Error('Creator replaced existing package metadata.');
|
|
20
20
|
if (nestedPackage.scripts.host !== 'node .nestbox/host-runner.mjs') throw new Error('Nested host script has the wrong path.');
|
|
21
21
|
const nestedEnv = await readFile(join(nested, '.nestbox', '.env'), 'utf8');
|
|
22
|
-
|
|
22
|
+
const tokenPath = nestedEnv.match(/^NESTBOX_HOST_TOKEN_FILE=(.+)$/m)?.[1]?.trim();
|
|
23
|
+
if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || !tokenPath || !/^[a-f0-9]{64}$/.test((await readFile(tokenPath, 'utf8')).trim())) throw new Error('Nested runtime authentication was not configured.');
|
|
23
24
|
const npmCommand = process.env.npm_execpath ? process.execPath : (process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
24
25
|
const npmArgs = process.env.npm_execpath ? [process.env.npm_execpath, 'run', 'test:host'] : ['run', 'test:host'];
|
|
25
26
|
const nestedHostTest = spawnSync(npmCommand, npmArgs, { cwd: nested, encoding: 'utf8' });
|