@p10i/create-nestbox 0.1.2 → 0.2.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.
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+
6
+ const fixture = await mkdtemp(join(tmpdir(), 'nestbox-creator-'));
7
+
8
+ function create(target, layout) {
9
+ const result = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), target, '--layout', layout, '--yes', '--no-git'], { encoding: 'utf8', env: { ...process.env, NESTBOX_CREATOR_STATE_ROOT: join(fixture, 'state') } });
10
+ if (result.status !== 0) throw new Error(`Creator failed for ${layout}: ${result.stderr || result.stdout}`);
11
+ }
12
+
13
+ try {
14
+ const nested = join(fixture, 'nested');
15
+ await mkdir(nested);
16
+ await writeFile(join(nested, 'package.json'), JSON.stringify({ name: 'existing', scripts: { build: 'node build.mjs' } }));
17
+ create(nested, 'nestbox');
18
+ const nestedPackage = JSON.parse(await readFile(join(nested, 'package.json'), 'utf8'));
19
+ if (nestedPackage.name !== 'existing' || nestedPackage.scripts.build !== 'node build.mjs') throw new Error('Creator replaced existing package metadata.');
20
+ if (nestedPackage.scripts.host !== 'node .nestbox/host-runner.mjs') throw new Error('Nested host script has the wrong path.');
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.');
23
+ const npmCommand = process.env.npm_execpath ? process.execPath : (process.platform === 'win32' ? 'npm.cmd' : 'npm');
24
+ const npmArgs = process.env.npm_execpath ? [process.env.npm_execpath, 'run', 'test:host'] : ['run', 'test:host'];
25
+ const nestedHostTest = spawnSync(npmCommand, npmArgs, { cwd: nested, encoding: 'utf8' });
26
+ if (nestedHostTest.status !== 0) throw new Error(`Nested host test script failed: ${nestedHostTest.error?.message || nestedHostTest.stderr || nestedHostTest.stdout}`);
27
+
28
+ const direct = join(fixture, 'direct');
29
+ create(direct, 'current');
30
+ const directPackage = JSON.parse(await readFile(join(direct, 'package.json'), 'utf8'));
31
+ if (directPackage.scripts.host !== 'node host-runner.mjs' || directPackage.scripts['test:host'] !== 'node tests/host-runner.mjs') throw new Error('Direct host scripts have the wrong paths.');
32
+
33
+ const conflicting = join(fixture, 'conflicting');
34
+ await mkdir(conflicting);
35
+ await writeFile(join(conflicting, 'package.json'), JSON.stringify({ scripts: { host: 'node something-else.mjs' } }));
36
+ const conflict = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), conflicting, '--layout', 'nestbox', '--yes', '--no-git'], { encoding: 'utf8', env: { ...process.env, NESTBOX_CREATOR_STATE_ROOT: join(fixture, 'state') } });
37
+ if (conflict.status === 0 || !conflict.stderr.includes('Refusing to overwrite')) throw new Error('Creator accepted a conflicting host script.');
38
+
39
+ console.log('Creator tests passed.');
40
+ } finally {
41
+ await rm(fixture, { recursive: true, force: true });
42
+ }
@@ -0,0 +1,103 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const fixture = await mkdtemp(join(tmpdir(), 'nestbox-host-runner-'));
8
+ const testDirectory = dirname(fileURLToPath(import.meta.url));
9
+ const runnerPath = resolve(testDirectory, '..', 'host-runner.mjs');
10
+ const queue = `${fixture}-queue`;
11
+ const requests = join(queue, 'requests');
12
+ const running = join(queue, 'running');
13
+ const results = join(queue, 'results');
14
+ let runner;
15
+ let stdout = '';
16
+
17
+ async function waitFor(path, timeout = 10000) {
18
+ const deadline = Date.now() + timeout;
19
+ while (Date.now() < deadline) {
20
+ try { return await readFile(path, 'utf8'); }
21
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
22
+ await new Promise(resolveWait => setTimeout(resolveWait, 25));
23
+ }
24
+ throw new Error(`Timed out waiting for ${path}`);
25
+ }
26
+
27
+ async function waitForEvent(type, requestId) {
28
+ const deadline = Date.now() + 10000;
29
+ while (Date.now() < deadline) {
30
+ for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
31
+ const event = JSON.parse(line);
32
+ if (event.type === type && (!requestId || event.requestId === requestId)) return event;
33
+ }
34
+ await new Promise(resolveWait => setTimeout(resolveWait, 25));
35
+ }
36
+ throw new Error(`Timed out waiting for ${type}`);
37
+ }
38
+
39
+ async function submit(job) {
40
+ await writeFile(join(requests, `${job.jobId}.json`), JSON.stringify({ schemaVersion: 1, ...job }));
41
+ return JSON.parse(await waitFor(join(results, `${job.jobId}.json`)));
42
+ }
43
+
44
+ try {
45
+ await Promise.all([mkdir(requests, { recursive: true }), mkdir(running, { recursive: true })]);
46
+ await writeFile(join(running, 'interrupted-job.json'), JSON.stringify({ schemaVersion: 1, jobId: 'interrupted-job', type: 'npm.run', script: 'ok', args: [] }));
47
+ await writeFile(join(fixture, 'package.json'), JSON.stringify({
48
+ private: true,
49
+ scripts: {
50
+ host: 'node host-runner.mjs',
51
+ ok: `node -e "process.stdout.write('host-ok')"`,
52
+ fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
53
+ },
54
+ }));
55
+ runner = spawn(process.execPath, [runnerPath, '--json'], {
56
+ env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_HOST_QUEUE: queue },
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ windowsHide: true,
59
+ });
60
+ let stderr = '';
61
+ runner.stdout.on('data', chunk => stdout += chunk);
62
+ runner.stderr.on('data', chunk => stderr += chunk);
63
+ await waitFor(join(queue, 'heartbeat.json'));
64
+ const interrupted = JSON.parse(await waitFor(join(results, 'interrupted-job.json')));
65
+ if (!interrupted.interrupted || interrupted.exitCode !== 1) throw new Error('Interrupted job was replayed instead of rejected.');
66
+
67
+ const success = await submit({ jobId: 'job-ok', type: 'npm.run', script: 'ok', args: [] });
68
+ const failure = await submit({ jobId: 'job-fail', type: 'npm.run', script: 'fail', args: [] });
69
+ if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
70
+ if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
71
+
72
+ const requestId = 'npm-script-request-test-add';
73
+ const requested = await submit({ jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` });
74
+ const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
75
+ if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
76
+ const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
77
+ const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
78
+ if (confirmed.status !== 'success' || !changed.scripts.approved) throw new Error('Approved script was not added.');
79
+
80
+ const raceId = 'npm-script-request-race-test';
81
+ await submit({ jobId: raceId, type: 'npm.scripts.request', requestId: raceId, operation: 'edit', name: 'approved', command: 'node changed.mjs' });
82
+ const raceEvent = await waitForEvent('npm.scripts.confirmation_required', raceId);
83
+ changed.scripts.concurrent = 'node concurrent.mjs';
84
+ await writeFile(join(fixture, 'package.json'), JSON.stringify(changed));
85
+ const raced = await submit({ jobId: 'confirm-race', type: 'npm.scripts.confirm', requestId: raceId, code: raceEvent.code });
86
+ if (raced.exitCode !== 1 || !raced.stderr.includes('changed after approval')) throw new Error('Concurrent package change was overwritten.');
87
+
88
+ const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
89
+ if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
90
+
91
+ const exit = new Promise((resolveExit, reject) => {
92
+ runner.on('error', reject);
93
+ runner.on('close', code => code === 0 || code === null ? resolveExit() : reject(new Error(`Runner exited ${code}: ${stderr}`)));
94
+ });
95
+ runner.kill('SIGTERM');
96
+ await exit;
97
+ runner = undefined;
98
+ console.log('Host runner tests passed.');
99
+ } finally {
100
+ runner?.kill('SIGTERM');
101
+ await rm(fixture, { recursive: true, force: true });
102
+ await rm(queue, { recursive: true, force: true });
103
+ }
package/tests/nestbox.ps1 CHANGED
@@ -91,6 +91,9 @@ function Invoke-Http([string]$Path, [string]$HostHeader = '', [string]$Method =
91
91
 
92
92
  try {
93
93
  New-Item -ItemType Directory -Path $TempDirectory | Out-Null
94
+ $configuredQueue = [regex]::Match([IO.File]::ReadAllText((Join-Path $Root '.env')), '(?m)^NESTBOX_HOST_QUEUE_PATH=(.+)$').Groups[1].Value.Trim()
95
+ if (-not $env:NESTBOX_HOST_QUEUE_PATH -and -not $configuredQueue) { $env:NESTBOX_HOST_QUEUE_PATH = Join-Path $TempDirectory 'host-runner' }
96
+ if ($env:NESTBOX_HOST_QUEUE_PATH) { New-Item -ItemType Directory -Path $env:NESTBOX_HOST_QUEUE_PATH -Force | Out-Null }
94
97
 
95
98
  if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
96
99
  if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
@@ -120,13 +123,15 @@ try {
120
123
  if ($openCodeInstance.Contains('Communication language:')) { Pass 'OpenCode instance policy records communication language' } else { Fail 'OpenCode instance policy omits communication language' }
121
124
  $openCodeConfig = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\opencode.json'))
122
125
  if ($openCodeConfig.Contains('"instance.md"')) { Pass 'OpenCode loads instance policy directly' } else { Fail 'OpenCode does not load instance policy' }
126
+ if ($openCodeConfig.Contains('"control"')) { Pass 'OpenCode registers the control MCP' } else { Fail 'OpenCode does not register the control MCP' }
127
+ if ((Test-Path -LiteralPath (Join-Path $Root 'docker\control\server.py')) -and (Test-Path -LiteralPath (Join-Path $Root 'host-runner.mjs'))) { Pass 'Control and host runner sources exist' } else { Fail 'Control or host runner source is missing' }
123
128
 
124
129
  $result = Invoke-Compose @('config', '--quiet') -Quiet
125
130
  if ($result.Code -eq 0) { Pass 'Compose configuration is valid' } else { Fail 'Compose configuration is invalid' }
126
131
 
127
132
  $result = Invoke-Compose @('config', '--services') -Quiet
128
133
  $services = @($result.Output | ForEach-Object { $_.ToString().Trim() })
129
- foreach ($service in @('nginx', 'php-fpm', 'rollup', 'opencode')) {
134
+ foreach ($service in @('nginx', 'php-fpm', 'rollup', 'control', 'opencode')) {
130
135
  if ($services -contains $service) { Pass "Compose defines $service" } else { Fail "Compose does not define $service" }
131
136
  }
132
137
  $result = Invoke-Compose @('ps', '--services') -Quiet
@@ -135,16 +140,16 @@ try {
135
140
  }
136
141
 
137
142
  if ($Start) {
138
- $result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'opencode')
143
+ $result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'control', 'opencode')
139
144
  if ($result.Code -eq 0) { Pass 'All service images build' } else { Fail 'One or more service images failed to build' }
140
145
 
141
146
  # Do not start the bind-mounted Rollup watcher: it intentionally writes
142
147
  # generated bundles. Its image and output are validated separately.
143
- $result = Invoke-Compose @('up', '-d', 'nginx', 'php-fpm', 'opencode')
148
+ $result = Invoke-Compose @('up', '-d', 'nginx', 'php-fpm', 'control', 'opencode')
144
149
  if ($result.Code -eq 0) { Pass 'Application services started' } else { Fail 'Application services failed to start' }
145
150
  }
146
151
 
147
- foreach ($service in @('nginx', 'php-fpm', 'opencode')) {
152
+ foreach ($service in @('nginx', 'php-fpm', 'control', 'opencode')) {
148
153
  if (Test-ContainerRunning $service) { Pass "$service is running" } else { Fail "$service is not running" }
149
154
  }
150
155
  $rollupId = Get-ContainerId 'rollup'
@@ -155,7 +160,7 @@ try {
155
160
  }
156
161
 
157
162
  $restartCounts = @{}
158
- foreach ($service in @('nginx', 'php-fpm', 'opencode')) { $restartCounts[$service] = Get-RestartCount $service }
163
+ foreach ($service in @('nginx', 'php-fpm', 'control', 'opencode')) { $restartCounts[$service] = Get-RestartCount $service }
159
164
 
160
165
  $result = Invoke-Compose @('port', 'nginx', '80') -Quiet
161
166
  $published = ($result.Output -join "`n").Trim()
@@ -242,6 +247,14 @@ try {
242
247
  $response = Invoke-Http '/_nestbox/publish?topics=nestbox-test:public' '' 'POST'
243
248
  if ($response.Status -eq 404) { Pass 'Publisher endpoint is unavailable on the public port' } else { Fail "Public publisher endpoint returned HTTP $($response.Status)" }
244
249
 
250
+ $controlId = Get-ContainerId 'control'
251
+ $controlInspect = @(& docker inspect $controlId 2>$null | ConvertFrom-Json)[0]
252
+ if ($null -eq $controlInspect.NetworkSettings.Ports.'4088/tcp') { Pass 'Control API is not published on the host' } else { Fail 'Control API is published on the host' }
253
+ $openCodeId = Get-ContainerId 'opencode'
254
+ $openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
255
+ if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
256
+ if (@($controlInspect.Mounts.Destination) -contains '/var/run/docker.sock') { Pass 'Control receives the Docker socket' } else { Fail 'Control does not receive the Docker socket' }
257
+
245
258
  if ($indexPresent) {
246
259
  $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
247
260
  if ($result.Code -eq 0) { Pass 'Installed super-document passes PHP lint' } else { Fail 'Installed super-document failed PHP lint' }
@@ -299,7 +312,7 @@ try {
299
312
  Remove-Job -Job $SseJob -Force -ErrorAction SilentlyContinue
300
313
  $SseJob = $null
301
314
 
302
- foreach ($service in @('nginx', 'php-fpm', 'opencode')) {
315
+ foreach ($service in @('nginx', 'php-fpm', 'control', 'opencode')) {
303
316
  $after = Get-RestartCount $service
304
317
  if ((Test-ContainerRunning $service) -and $null -ne $restartCounts[$service] -and $restartCounts[$service] -eq $after) { Pass "$service remained stable during the test" } else { Fail "$service restarted or stopped during the test" }
305
318
  }
package/tests/nestbox.sh CHANGED
@@ -141,6 +141,11 @@ else
141
141
  fi
142
142
 
143
143
  TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nestbox-test.XXXXXX")"
144
+ configured_queue="$(sed -n 's/^NESTBOX_HOST_QUEUE_PATH=//p' "$ROOT/.env" | tail -n 1)"
145
+ if [[ -z "${NESTBOX_HOST_QUEUE_PATH:-}" && -z "$configured_queue" ]]; then
146
+ export NESTBOX_HOST_QUEUE_PATH="$TEMP_DIR/host-runner"
147
+ fi
148
+ if [[ -n "${NESTBOX_HOST_QUEUE_PATH:-}" ]]; then mkdir -p "$NESTBOX_HOST_QUEUE_PATH"; fi
144
149
 
145
150
  [[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
146
151
  [[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
@@ -160,6 +165,8 @@ grep -Fq 'deliberately has no Docker socket' "$ROOT/home/configs/opencode/instru
160
165
  grep -Fq 'Commit policy:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records commit behavior' || fail 'OpenCode instance policy omits commit behavior'
161
166
  grep -Fq 'Communication language:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records communication language' || fail 'OpenCode instance policy omits communication language'
162
167
  grep -Fq '"instance.md"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode loads instance policy directly' || fail 'OpenCode does not load instance policy'
168
+ grep -Fq '"control"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode registers the control MCP' || fail 'OpenCode does not register the control MCP'
169
+ [[ -f "$ROOT/docker/control/server.py" && -f "$ROOT/host-runner.mjs" ]] && pass 'Control and host runner sources exist' || fail 'Control or host runner source is missing'
163
170
 
164
171
  if compose config --quiet; then
165
172
  pass 'Compose configuration is valid'
@@ -168,7 +175,7 @@ else
168
175
  fi
169
176
 
170
177
  services="$(compose config --services 2>/dev/null || true)"
171
- for service in nginx php-fpm rollup opencode; do
178
+ for service in nginx php-fpm rollup control opencode; do
172
179
  if grep -qx "$service" <<<"$services"; then
173
180
  pass "Compose defines $service"
174
181
  else
@@ -184,7 +191,7 @@ while IFS= read -r service; do
184
191
  done <<<"$running_services"
185
192
 
186
193
  if ((START)); then
187
- if compose build nginx php-fpm rollup opencode; then
194
+ if compose build nginx php-fpm rollup control opencode; then
188
195
  pass 'All service images build'
189
196
  else
190
197
  fail 'One or more service images failed to build'
@@ -193,14 +200,14 @@ if ((START)); then
193
200
  # The Rollup watcher intentionally writes generated bundles to the bind
194
201
  # mount, so lifecycle mode leaves it untouched and validates its image via
195
202
  # the build plus the bundles served by Nginx.
196
- if compose up -d nginx php-fpm opencode; then
203
+ if compose up -d nginx php-fpm control opencode; then
197
204
  pass 'Application services started'
198
205
  else
199
206
  fail 'Application services failed to start'
200
207
  fi
201
208
  fi
202
209
 
203
- for service in nginx php-fpm opencode; do
210
+ for service in nginx php-fpm control opencode; do
204
211
  if container_running "$service"; then
205
212
  pass "$service is running"
206
213
  else
@@ -217,6 +224,7 @@ fi
217
224
  NGINX_RESTARTS="$(restart_count nginx 2>/dev/null || printf '')"
218
225
  PHP_RESTARTS="$(restart_count php-fpm 2>/dev/null || printf '')"
219
226
  OPENCODE_RESTARTS="$(restart_count opencode 2>/dev/null || printf '')"
227
+ CONTROL_RESTARTS="$(restart_count control 2>/dev/null || printf '')"
220
228
 
221
229
  published="$(compose port nginx 80 2>/dev/null || true)"
222
230
  if [[ "$published" =~ ^\[(.*)\]:([0-9]+)$ ]]; then
@@ -339,6 +347,15 @@ fi
339
347
  status="$(http_request '/_nestbox/publish?topics=nestbox-test:public' '' 'POST' 2>/dev/null || printf '000')"
340
348
  [[ "$status" == "404" ]] && pass 'Publisher endpoint is unavailable on the public port' || fail "Public publisher endpoint returned HTTP $status"
341
349
 
350
+ control_id="$(container_id control)"
351
+ control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4088/tcp")}}' "$control_id" 2>/dev/null || true)"
352
+ [[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
353
+ opencode_id="$(container_id opencode)"
354
+ opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
355
+ grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'
356
+ control_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$control_id" 2>/dev/null || true)"
357
+ grep -Fq '/var/run/docker.sock' <<<"$control_mounts" && pass 'Control receives the Docker socket' || fail 'Control does not receive the Docker socket'
358
+
342
359
  if ((INDEX_PRESENT)); then
343
360
  if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then
344
361
  pass 'Installed super-document passes PHP lint'
@@ -423,7 +440,7 @@ else
423
440
  fail 'EventSource did not receive the expected Nchan event'
424
441
  fi
425
442
 
426
- for entry in "nginx:$NGINX_RESTARTS" "php-fpm:$PHP_RESTARTS" "opencode:$OPENCODE_RESTARTS"; do
443
+ for entry in "nginx:$NGINX_RESTARTS" "php-fpm:$PHP_RESTARTS" "control:$CONTROL_RESTARTS" "opencode:$OPENCODE_RESTARTS"; do
427
444
  service="${entry%%:*}"
428
445
  before="${entry#*:}"
429
446
  after="$(restart_count "$service" 2>/dev/null || printf '')"