@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.
@@ -1088,16 +1088,15 @@
1088
1088
  "license": "CC0-1.0"
1089
1089
  },
1090
1090
  "node_modules/nanoid": {
1091
- "version": "3.3.12",
1092
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1093
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
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.14",
1214
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
1215
- "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
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.11",
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.2",
2024
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz",
2025
- "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==",
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 { mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
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 runnerConfig = await readFile(join(installDirectory, '.runtime', 'host-runner.json'), 'utf8')
12
- .then(value => JSON.parse(value), error => error?.code === 'ENOENT' ? {} : Promise.reject(error));
13
- const queueRoot = resolve(process.env.NESTBOX_HOST_QUEUE || runnerConfig.queue || join(installDirectory, '.runtime', 'host-runner'));
14
- const queueRelative = relative(workspace, queueRoot);
15
- if (queueRelative === '' || (!queueRelative.startsWith('..') && !isAbsolute(queueRelative))) {
16
- throw new Error('NESTBOX host queue must be outside the mounted workspace.');
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 requestsDirectory = join(queueRoot, 'requests');
19
- const runningDirectory = join(queueRoot, 'running');
20
- const resultsDirectory = join(queueRoot, 'results');
21
- const heartbeatPath = join(queueRoot, 'heartbeat.json');
22
- const lockPath = join(queueRoot, 'runner.lock');
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 listening in ${workspace}`);
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
- async function atomicJson(path, value) {
51
- const temporary = `${path}.${instanceId}.tmp`;
52
- await writeFile(temporary, `${JSON.stringify(value)}\n`, 'utf8');
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 rename(temporary, path);
55
- } catch (error) {
56
- if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
57
- await unlink(path).catch(() => {});
58
- await rename(temporary, path);
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 packageDocument() {
63
- const document = JSON.parse(await readFile(join(workspace, 'package.json'), 'utf8'));
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
- async function packageHash() {
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}.${instanceId}.tmp`;
122
+ const temporary = `${path}.${runnerId}.tmp`;
85
123
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
86
- if (expectedHash && await packageHash() !== expectedHash) {
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
- await rename(temporary, path);
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}.${instanceId}.backup`;
131
+ const backup = `${path}.${runnerId}.backup`;
95
132
  await rename(path, backup);
96
- try {
97
- await rename(temporary, path);
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 scripts = await packageScripts();
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 document = await packageDocument();
157
- if (await packageHash() !== approval.expectedPackageHash) {
158
- approvals.delete(job.requestId);
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 = process.env.npm_execpath ? process.execPath : npmExecutable;
193
- const executableArgs = process.env.npm_execpath ? [process.env.npm_execpath, ...args] : args;
204
+ const executable = npmCli ? process.execPath : npmExecutable;
205
+ const executableArgs = npmCli ? [npmCli, ...args] : args;
194
206
  const startedAt = new Date().toISOString();
195
- return await new Promise(resolveJob => {
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
- const out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
200
- child.stdout.on('data', chunk => appendLimited(stdout, chunk, out));
201
- child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
202
- const result = (exitCode, message = '') => resolveJob({
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 processRequest(path) {
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
- if (process.platform === 'win32') {
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
- await Promise.all([mkdir(requestsDirectory, { recursive: true }), mkdir(runningDirectory, { recursive: true }), mkdir(resultsDirectory, { recursive: true })]);
260
- try {
261
- const lock = await open(lockPath, 'wx'); await lock.writeFile(`${instanceId}\n`); await lock.close();
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
- takeover = await open(takeoverPath, 'wx');
270
- await takeover.writeFile(`${instanceId}\n`);
271
- } catch {
272
- throw new Error('Another host runner is recovering the stale lock.');
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
- const heartbeat = () => atomicJson(heartbeatPath, { schemaVersion: 1, instanceId, pid: process.pid, hostname: hostName, platform: hostPlatform, workspace, updatedAt: new Date().toISOString() });
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, () => cleanup().finally(() => process.exit(0)));
301
- main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); cleanup().finally(() => process.exit(1)); });
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.2.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",
@@ -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
- if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || !/^NESTBOX_HOST_QUEUE_PATH=.+$/m.test(nestedEnv)) throw new Error('Nested runtime paths were not configured.');
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' });