@p10i/create-nestbox 0.5.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.
@@ -1,215 +0,0 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
- import { existsSync } from 'node:fs';
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 testDirectory = dirname(fileURLToPath(import.meta.url));
10
- const runnerPath = resolve(testDirectory, '..', 'host-runner.mjs');
11
- const fakeBridge = join(fixture, 'fake-docker.mjs');
12
- const jobPath = join(fixture, 'bridge-job.json');
13
- const resultPath = join(fixture, 'bridge-result.json');
14
- const invocationPath = join(fixture, 'invocations.jsonl');
15
- const ackPath = join(fixture, 'acked-marker');
16
- let runner;
17
- let runnerStdout = '';
18
-
19
- const fakeSource = String.raw`
20
- import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
21
- appendFileSync(process.env.FAKE_INVOCATIONS, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + '\n');
22
- const messages = [];
23
- let waiter, ended = false, input = '';
24
- process.stdin.setEncoding('utf8');
25
- process.stdin.on('data', chunk => {
26
- input += chunk;
27
- let newline;
28
- while ((newline = input.indexOf('\n')) >= 0) {
29
- const line = input.slice(0, newline); input = input.slice(newline + 1);
30
- if (line) messages.push(JSON.parse(line));
31
- if (waiter) { const resolve = waiter; waiter = null; resolve(); }
32
- }
33
- });
34
- process.stdin.on('end', () => { ended = true; if (waiter) waiter(); });
35
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
36
- async function receive() {
37
- while (!messages.length && !ended) await new Promise(resolve => waiter = resolve);
38
- if (!messages.length) return null;
39
- return messages.shift();
40
- }
41
- function send(value) { process.stdout.write(JSON.stringify({ protocolVersion: 1, ...value }) + '\n'); }
42
- const hello = await receive();
43
- if (hello?.type !== 'hello') process.exit(2);
44
- if (hello.retainedResult) {
45
- if (hello.retainedResult.jobId === 'job-rejected') {
46
- writeFileSync(process.env.FAKE_REJECTED, 'terminal');
47
- send({ type: 'result.rejected', jobId: hello.retainedResult.jobId, error: 'conflicting host result replay' });
48
- } else {
49
- if (existsSync(process.env.FAKE_JOB)) unlinkSync(process.env.FAKE_JOB);
50
- writeFileSync(process.env.FAKE_RESULT, JSON.stringify(hello.retainedResult));
51
- send({ type: 'result.ack', jobId: hello.retainedResult.jobId, duplicate: true });
52
- await sleep(25);
53
- writeFileSync(process.env.FAKE_RECONCILED, hello.retainedResult.jobId);
54
- writeFileSync(process.env.FAKE_ACKED + '.' + hello.retainedResult.jobId, hello.retainedResult.jobId);
55
- }
56
- }
57
- send({ type: 'ready' });
58
- while (!ended) {
59
- if (!existsSync(process.env.FAKE_JOB)) { send({ type: 'idle' }); await sleep(50); continue; }
60
- const job = JSON.parse(readFileSync(process.env.FAKE_JOB, 'utf8'));
61
- send({ type: 'job', job });
62
- if (job.jobId === 'job-inflight' && !existsSync(process.env.FAKE_INFLIGHT)) {
63
- writeFileSync(process.env.FAKE_INFLIGHT, 'disconnected');
64
- process.exit(76);
65
- }
66
- const message = await receive();
67
- if (!message) break;
68
- if (message.type !== 'result' || message.result?.jobId !== job.jobId) process.exit(3);
69
- if (job.jobId === 'job-retry' && !existsSync(process.env.FAKE_RETRY)) {
70
- writeFileSync(process.env.FAKE_RETRY, 'attempted');
71
- writeFileSync(process.env.FAKE_RESULT, JSON.stringify(message.result));
72
- unlinkSync(process.env.FAKE_JOB);
73
- process.exit(75);
74
- }
75
- if (job.jobId === 'job-rejected' && !existsSync(process.env.FAKE_REJECT)) {
76
- writeFileSync(process.env.FAKE_REJECT, 'accepted-without-ack');
77
- unlinkSync(process.env.FAKE_JOB);
78
- process.exit(77);
79
- }
80
- unlinkSync(process.env.FAKE_JOB);
81
- writeFileSync(process.env.FAKE_RESULT, JSON.stringify(message.result));
82
- send({ type: 'result.ack', jobId: job.jobId });
83
- await sleep(25);
84
- writeFileSync(process.env.FAKE_ACKED + '.' + job.jobId, job.jobId);
85
- }
86
- `;
87
-
88
- async function waitFor(path, timeout = 10000) {
89
- const deadline = Date.now() + timeout;
90
- while (Date.now() < deadline) {
91
- if (existsSync(path)) return;
92
- await new Promise(resolveWait => setTimeout(resolveWait, 25));
93
- }
94
- throw new Error(`Timed out waiting for ${path}`);
95
- }
96
-
97
- async function submit(job) {
98
- const jobAckPath = `${ackPath}.${job.jobId}`;
99
- await rm(resultPath, { force: true });
100
- await rm(jobAckPath, { force: true });
101
- await writeFile(jobPath, JSON.stringify({ schemaVersion: 1, ...job }));
102
- await waitFor(resultPath);
103
- const result = JSON.parse(await readFile(resultPath, 'utf8'));
104
- if (job.jobId !== 'job-retry') {
105
- await waitFor(jobAckPath);
106
- if (await readFile(jobAckPath, 'utf8') !== job.jobId) throw new Error(`Unexpected acknowledgement for ${job.jobId}.`);
107
- }
108
- return result;
109
- }
110
-
111
- async function waitForEvent(type, requestId, timeout = 10000) {
112
- const deadline = Date.now() + timeout;
113
- while (Date.now() < deadline) {
114
- for (const line of runnerStdout.split(/\r?\n/).filter(Boolean)) {
115
- const event = JSON.parse(line);
116
- if (event.type === type && (!requestId || event.requestId === requestId || event.jobId === requestId)) return event;
117
- }
118
- await new Promise(resolveWait => setTimeout(resolveWait, 25));
119
- }
120
- throw new Error(`Timed out waiting for event ${type}`);
121
- }
122
-
123
- try {
124
- await writeFile(fakeBridge, fakeSource);
125
- await writeFile(join(fixture, 'package.json'), JSON.stringify({
126
- private: true,
127
- scripts: {
128
- host: 'node host-runner.mjs',
129
- ok: `node -e "process.stdout.write('host-ok')"`,
130
- fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
131
- delayed: `node -e "setTimeout(() => require('fs').appendFileSync('executions', 'x'), 300)"`,
132
- },
133
- }));
134
- const environment = {
135
- ...process.env,
136
- npm_execpath: '',
137
- NESTBOX_HOST_WORKSPACE: fixture,
138
- NESTBOX_HOST_DOCKER: process.execPath,
139
- NESTBOX_HOST_DOCKER_ARGS: JSON.stringify([fakeBridge]),
140
- FAKE_JOB: jobPath,
141
- FAKE_RESULT: resultPath,
142
- FAKE_RETRY: join(fixture, 'retry-marker'),
143
- FAKE_RECONCILED: join(fixture, 'reconciled-marker'),
144
- FAKE_REJECT: join(fixture, 'reject-marker'),
145
- FAKE_REJECTED: join(fixture, 'rejected-marker'),
146
- FAKE_ACKED: ackPath,
147
- FAKE_INFLIGHT: join(fixture, 'inflight-marker'),
148
- FAKE_INVOCATIONS: invocationPath,
149
- };
150
- runner = spawn(process.execPath, [runnerPath, '--json'], { env: environment, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
151
- let runnerStderr = '';
152
- runner.stdout.on('data', chunk => runnerStdout += chunk); runner.stderr.on('data', chunk => runnerStderr += chunk);
153
- await waitForEvent('runner.ready');
154
-
155
- const success = await submit({ jobId: 'job-ok', type: 'npm.run', script: 'ok', args: [] });
156
- const failure = await submit({ jobId: 'job-fail', type: 'npm.run', script: 'fail', args: [] });
157
- if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
158
- if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
159
- const retried = await submit({ jobId: 'job-retry', type: 'npm.run', script: 'ok', args: [] });
160
- if (retried.exitCode !== 0) throw new Error('Pending result was not replayed after bridge disconnect.');
161
- await waitFor(environment.FAKE_RECONCILED);
162
- if (await readFile(environment.FAKE_RECONCILED, 'utf8') !== 'job-retry') throw new Error('Ack-lost result was not reconciled before polling.');
163
- const inFlight = await submit({ jobId: 'job-inflight', type: 'npm.run', script: 'delayed', args: [] });
164
- const executions = await readFile(join(fixture, 'executions'), 'utf8');
165
- if (inFlight.exitCode !== 0 || executions !== 'x') throw new Error(`In-flight host job was duplicated across bridge reconnect: exit=${inFlight.exitCode} executions=${executions}.`);
166
-
167
- await writeFile(jobPath, JSON.stringify({ schemaVersion: 1, jobId: 'job-rejected', type: 'npm.run', script: 'ok', args: [] }));
168
- try { await waitFor(environment.FAKE_REJECTED); }
169
- catch (error) { throw new Error(`${error.message}; accepted=${existsSync(environment.FAKE_REJECT)} job=${existsSync(jobPath)} result=${existsSync(resultPath) ? await readFile(resultPath, 'utf8') : 'none'}\n${runnerStdout}\n${runnerStderr}`); }
170
- const rejected = await waitForEvent('runner.error', 'job-rejected');
171
- if (!rejected.error.includes('conflicting host result replay')) throw new Error('Terminal result rejection was not reported.');
172
- const afterRejected = await submit({ jobId: 'job-after-rejected', type: 'npm.run', script: 'ok', args: [] });
173
- if (afterRejected.exitCode !== 0) throw new Error('Terminal result rejection was retried instead of cleared.');
174
-
175
- const requestId = 'npm-script-request-test-add';
176
- await rm(resultPath, { force: true });
177
- await writeFile(jobPath, JSON.stringify({ schemaVersion: 1, jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` }));
178
- const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
179
- await waitFor(resultPath);
180
- const requested = JSON.parse(await readFile(resultPath, 'utf8'));
181
- if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
182
- const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
183
- const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
184
- if (confirmed.status !== 'success' || !changed.scripts.approved) throw new Error('Approved script was not added.');
185
-
186
- const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
187
- if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
188
-
189
- const invocations = (await readFile(invocationPath, 'utf8')).trim().split(/\r?\n/).map(JSON.parse);
190
- if (invocations.length < 2 || invocations.some(value => value.cwd !== dirname(runnerPath))) throw new Error('Docker CLI did not run from the installation directory.');
191
- const expected = ['compose', 'exec', '-T', 'control', '/usr/local/bin/nestbox-host-bridge'];
192
- if (!invocations.every(value => expected.every((part, index) => value.args[index] === part))) throw new Error('Docker Compose bridge arguments were incorrect.');
193
-
194
- 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}`))); });
195
- runner.kill('SIGTERM'); await exit; runner = undefined;
196
-
197
- const onceRunner = spawn(process.execPath, [runnerPath, '--once', '--json'], { env: environment, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
198
- const onceExit = await Promise.race([
199
- new Promise(resolveExit => onceRunner.once('close', resolveExit)),
200
- new Promise((_, reject) => setTimeout(() => reject(new Error('--once runner did not exit after an empty poll.')), 5000)),
201
- ]);
202
- if (onceExit !== 0) throw new Error(`--once runner exited ${onceExit}.`);
203
-
204
- const stale = spawnSync(process.execPath, [runnerPath, '--once'], {
205
- env: { ...environment, NESTBOX_HOST_DOCKER_ARGS: JSON.stringify(['-e', `process.stderr.write('nestbox-host-bridge: not found\\n'); process.exit(127)`]) },
206
- encoding: 'utf8',
207
- windowsHide: true,
208
- timeout: 5000,
209
- });
210
- if (stale.status !== 1 || !stale.stderr.includes('docker compose build control')) throw new Error('Stale control image error was not actionable or terminal.');
211
- console.log('Host runner stdio transport tests passed.');
212
- } finally {
213
- runner?.kill('SIGTERM');
214
- await rm(fixture, { recursive: true, force: true });
215
- }