@p10i/create-nestbox 0.1.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.
Files changed (43) hide show
  1. package/.dockerignore +34 -0
  2. package/.env.example +35 -0
  3. package/.gitattributes +10 -0
  4. package/INSTALL.md +258 -0
  5. package/LICENSE +21 -0
  6. package/README.md +41 -0
  7. package/bin/create-nestbox.js +184 -0
  8. package/docker/nginx/Dockerfile +31 -0
  9. package/docker/opencode/Dockerfile +36 -0
  10. package/docker/opencode/entrypoint.sh +9 -0
  11. package/docker/php-fpm/Dockerfile +43 -0
  12. package/docker/rollup/Dockerfile +16 -0
  13. package/docker-compose.yaml +139 -0
  14. package/home/code/.user.ini +1 -0
  15. package/home/code/404.php +104 -0
  16. package/home/code/__data/.gitignore +2 -0
  17. package/home/code/__includes/footer.php +3 -0
  18. package/home/code/__includes/functions.php +325 -0
  19. package/home/code/__includes/header.php +20 -0
  20. package/home/code/__templates/super-document.php +47 -0
  21. package/home/code/scripts-bundle.css +13295 -0
  22. package/home/code/scripts-bundle.js +6277 -0
  23. package/home/code/scripts.js +3 -0
  24. package/home/code/styles.css +36 -0
  25. package/home/configs/nginx/nginx.conf +199 -0
  26. package/home/configs/opencode/instance.example.md +16 -0
  27. package/home/configs/opencode/instructions.md +308 -0
  28. package/home/configs/opencode/opencode.json +15 -0
  29. package/home/configs/php/cli.php +33 -0
  30. package/home/configs/php/composer.json +10 -0
  31. package/home/configs/php/composer.lock +805 -0
  32. package/home/configs/php/index.php +105 -0
  33. package/home/configs/php/init.php +3 -0
  34. package/home/configs/php/php.ini +5 -0
  35. package/home/configs/php/preload.php +5 -0
  36. package/home/configs/rollup/package-lock.json +2089 -0
  37. package/home/configs/rollup/package.json +16 -0
  38. package/home/configs/rollup/rollup.config.js +28 -0
  39. package/package.json +42 -0
  40. package/scripts/check-package.js +31 -0
  41. package/template.gitignore +20 -0
  42. package/tests/nestbox.ps1 +322 -0
  43. package/tests/nestbox.sh +438 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "type": "module",
3
+ "private": true,
4
+ "dependencies": {
5
+ "@popperjs/core": "^2.11.8",
6
+ "@rollup/plugin-commonjs": "^28.0.2",
7
+ "@rollup/plugin-node-resolve": "^15.3.0",
8
+ "bootstrap": "^5.3.3",
9
+ "github-markdown-css": "^5.7.0",
10
+ "rollup": "^4.25.0",
11
+ "rollup-plugin-postcss": "^4.0.2"
12
+ },
13
+ "scripts": {
14
+ "rollup": "rollup -c"
15
+ }
16
+ }
@@ -0,0 +1,28 @@
1
+ import resolve from '@rollup/plugin-node-resolve';
2
+ import commonjs from '@rollup/plugin-commonjs';
3
+ import postcss from 'rollup-plugin-postcss';
4
+
5
+ export default {
6
+ input: [
7
+ '/home/code/scripts.js',
8
+ ],
9
+ output: {
10
+ file: '/home/code/scripts-bundle.js',
11
+ format: 'iife',
12
+ globals: {}
13
+ },
14
+ plugins: [
15
+ resolve({
16
+ modulePaths: ['./node_modules'],
17
+ browser: true,
18
+ preferBuiltins: false
19
+ }),
20
+ commonjs({
21
+ include: /node_modules/,
22
+ transformMixedEsModules: true
23
+ }),
24
+ postcss({
25
+ extract: true,
26
+ })
27
+ ]
28
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@p10i/create-nestbox",
3
+ "version": "0.1.0",
4
+ "description": "Create Nestbox projects.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-nestbox": "./bin/create-nestbox.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "scripts/",
13
+ "docker/",
14
+ "home/",
15
+ "tests/",
16
+ "docker-compose.yaml",
17
+ ".dockerignore",
18
+ ".env.example",
19
+ ".gitattributes",
20
+ "template.gitignore",
21
+ "INSTALL.md",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "node scripts/check-package.js"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/piontkovsk11andre1/nestbox.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/piontkovsk11andre1/nestbox/issues"
40
+ },
41
+ "homepage": "https://github.com/piontkovsk11andre1/nestbox#readme"
42
+ }
@@ -0,0 +1,31 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+
3
+ const requiredPaths = [
4
+ 'bin/create-nestbox.js',
5
+ 'docker-compose.yaml',
6
+ '.env.example',
7
+ 'template.gitignore',
8
+ 'docker/nginx/Dockerfile',
9
+ 'docker/php-fpm/Dockerfile',
10
+ 'docker/opencode/Dockerfile',
11
+ 'docker/rollup/Dockerfile',
12
+ 'home/configs/opencode/instance.example.md',
13
+ 'home/code/404.php',
14
+ 'tests/nestbox.sh',
15
+ 'tests/nestbox.ps1'
16
+ ];
17
+
18
+ for (const path of requiredPaths) {
19
+ await access(path);
20
+ }
21
+
22
+ const packageJson = JSON.parse(await readFile('package.json', 'utf8'));
23
+ if (packageJson.name !== '@p10i/create-nestbox') {
24
+ throw new Error('Unexpected package name.');
25
+ }
26
+
27
+ if (!packageJson.files || packageJson.files.includes('.env')) {
28
+ throw new Error('package.json files must not include .env.');
29
+ }
30
+
31
+ console.log('Package checks passed.');
@@ -0,0 +1,20 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+
5
+ .idea/
6
+ .vscode/
7
+ node_modules/
8
+ vendor/
9
+
10
+ # Generated by the Rollup watcher.
11
+ home/code/scripts-bundle.js
12
+ home/code/scripts-bundle.css
13
+
14
+ # Page-owned state is local by default. A page may add a deeper .gitignore
15
+ # with an explicit !filename rule for durable files that belong in history.
16
+ home/code/**/__data/*
17
+ !home/code/**/__data/.gitignore
18
+
19
+ .DS_Store
20
+ Thumbs.db
@@ -0,0 +1,322 @@
1
+ param(
2
+ [switch]$Start,
3
+ [ValidateRange(1, 600)]
4
+ [int]$TimeoutSeconds = 30,
5
+ [string]$PageRoot = ''
6
+ )
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+ $ProgressPreference = 'SilentlyContinue'
10
+ $Passes = 0
11
+ $Failures = 0
12
+ $Skips = 0
13
+ $Root = Split-Path -Parent $PSScriptRoot
14
+ if (-not $PageRoot) { $PageRoot = Join-Path $Root 'home\code' }
15
+ if (-not [System.IO.Path]::IsPathRooted($PageRoot)) { $PageRoot = Join-Path $Root $PageRoot }
16
+ $PageRoot = [System.IO.Path]::GetFullPath($PageRoot)
17
+ if ($PSBoundParameters.ContainsKey('PageRoot')) { $env:NESTBOX_PAGE_PATH = $PageRoot }
18
+ $TempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("nestbox-test-" + [guid]::NewGuid().ToString('N'))
19
+ $Client = $null
20
+ $SseJob = $null
21
+
22
+ function Pass([string]$Message) {
23
+ $script:Passes++
24
+ Write-Output "PASS $Message"
25
+ }
26
+
27
+ function Fail([string]$Message) {
28
+ $script:Failures++
29
+ [Console]::Error.WriteLine("FAIL $Message")
30
+ }
31
+
32
+ function Skip([string]$Message) {
33
+ $script:Skips++
34
+ Write-Output "SKIP $Message"
35
+ }
36
+
37
+ function Invoke-Compose([string[]]$Arguments, [switch]$Quiet) {
38
+ $previousErrorAction = $ErrorActionPreference
39
+ $ErrorActionPreference = 'Continue'
40
+ try {
41
+ $output = @(& docker compose --project-directory $Root @Arguments 2>&1 | ForEach-Object { $_.ToString() })
42
+ $code = $LASTEXITCODE
43
+ }
44
+ finally {
45
+ $ErrorActionPreference = $previousErrorAction
46
+ }
47
+ if (-not $Quiet -and $null -ne $output) {
48
+ $output | ForEach-Object { [Console]::Out.WriteLine($_.ToString()) }
49
+ }
50
+ return [pscustomobject]@{ Code = $code; Output = @($output) }
51
+ }
52
+
53
+ function Get-ContainerId([string]$Service) {
54
+ $result = Invoke-Compose @('ps', '-q', $Service) -Quiet
55
+ if ($result.Code -ne 0) { return '' }
56
+ return (($result.Output -join "`n").Trim())
57
+ }
58
+
59
+ function Get-RestartCount([string]$Service) {
60
+ $id = Get-ContainerId $Service
61
+ if (-not $id) { return $null }
62
+ $value = & docker inspect --format '{{.RestartCount}}' $id 2>$null
63
+ if ($LASTEXITCODE -ne 0) { return $null }
64
+ return [int]$value
65
+ }
66
+
67
+ function Test-ContainerRunning([string]$Service) {
68
+ $id = Get-ContainerId $Service
69
+ if (-not $id) { return $false }
70
+ $value = & docker inspect --format '{{.State.Running}}' $id 2>$null
71
+ return $LASTEXITCODE -eq 0 -and $value -eq 'true'
72
+ }
73
+
74
+ function Invoke-Http([string]$Path, [string]$HostHeader = '', [string]$Method = 'GET') {
75
+ $request = New-Object System.Net.Http.HttpRequestMessage((New-Object System.Net.Http.HttpMethod($Method)), "$script:BaseUrl$Path")
76
+ $response = $null
77
+ if ($HostHeader) { $request.Headers.Host = $HostHeader }
78
+ try {
79
+ $response = $script:Client.SendAsync($request).GetAwaiter().GetResult()
80
+ $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
81
+ $headers = @{}
82
+ foreach ($header in $response.Headers) { $headers[$header.Key.ToLowerInvariant()] = ($header.Value -join ', ') }
83
+ foreach ($header in $response.Content.Headers) { $headers[$header.Key.ToLowerInvariant()] = ($header.Value -join ', ') }
84
+ return [pscustomobject]@{ Status = [int]$response.StatusCode; Headers = $headers; Body = $body }
85
+ }
86
+ finally {
87
+ if ($null -ne $response) { $response.Dispose() }
88
+ $request.Dispose()
89
+ }
90
+ }
91
+
92
+ try {
93
+ New-Item -ItemType Directory -Path $TempDirectory | Out-Null
94
+
95
+ if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
96
+ if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
97
+ $composeVersion = & docker compose version 2>$null
98
+ if ($LASTEXITCODE -eq 0) { Pass 'Docker Compose v2 is available' } else { Fail 'Docker Compose v2 is required'; throw 'Missing Docker Compose' }
99
+
100
+ Add-Type -AssemblyName System.Net.Http
101
+ $handler = New-Object System.Net.Http.HttpClientHandler
102
+ $handler.AllowAutoRedirect = $false
103
+ $handler.UseProxy = $false
104
+ $Client = New-Object System.Net.Http.HttpClient($handler)
105
+ $Client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds)
106
+
107
+ if (Test-Path -LiteralPath (Join-Path $Root '.env') -PathType Leaf) { Pass '.env exists' } else { Fail '.env is missing' }
108
+ if (-not (Test-Path -LiteralPath (Join-Path $Root 'home\.git') -PathType Container)) { Pass 'Page and configuration tree has no embedded Git repository' } else { Fail 'home/.git must not be included in an installation' }
109
+ $indexPresent = Test-Path -LiteralPath (Join-Path $PageRoot 'index.php') -PathType Leaf
110
+ if ($indexPresent) { Pass 'Page tree has an implemented index.php' } else { Pass 'Fresh page tree intentionally has no index.php' }
111
+ $openCodeInstructions = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\instructions.md'))
112
+ if ($openCodeInstructions.Contains('free of empty lines between its nested or adjacent element tags')) { Pass 'OpenCode instructions enforce contiguous raw HTML blocks' } else { Fail 'OpenCode instructions omit the raw HTML blank-line invariant' }
113
+ if ($openCodeInstructions.Contains('## PHP To OpenCode API')) { Pass 'OpenCode instructions document the PHP API' } else { Fail 'OpenCode instructions omit the PHP API contract' }
114
+ if ($openCodeInstructions.Contains('## Extending The Environment')) { Pass 'OpenCode instructions document environment extension' } else { Fail 'OpenCode instructions omit environment extension guidance' }
115
+ if ($openCodeInstructions.Contains('deliberately has no Docker socket')) { Pass 'OpenCode instructions preserve the host container boundary' } else { Fail 'OpenCode instructions omit the host container boundary' }
116
+ if (Test-Path -LiteralPath (Join-Path $Root 'home\configs\opencode\instance.example.md') -PathType Leaf) { Pass 'OpenCode instance policy template exists' } else { Fail 'OpenCode instance policy template is missing' }
117
+ if (-not (Test-Path -LiteralPath (Join-Path $Root 'home\configs\opencode\instance.md') -PathType Leaf)) { Fail 'Copy instance.example.md to instance.md before startup'; throw 'Missing OpenCode instance policy' }
118
+ $openCodeInstance = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\instance.md'))
119
+ if ($openCodeInstance.Contains('Commit policy:')) { Pass 'OpenCode instance policy records commit behavior' } else { Fail 'OpenCode instance policy omits commit behavior' }
120
+ if ($openCodeInstance.Contains('Communication language:')) { Pass 'OpenCode instance policy records communication language' } else { Fail 'OpenCode instance policy omits communication language' }
121
+ $openCodeConfig = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\opencode.json'))
122
+ if ($openCodeConfig.Contains('"instance.md"')) { Pass 'OpenCode loads instance policy directly' } else { Fail 'OpenCode does not load instance policy' }
123
+
124
+ $result = Invoke-Compose @('config', '--quiet') -Quiet
125
+ if ($result.Code -eq 0) { Pass 'Compose configuration is valid' } else { Fail 'Compose configuration is invalid' }
126
+
127
+ $result = Invoke-Compose @('config', '--services') -Quiet
128
+ $services = @($result.Output | ForEach-Object { $_.ToString().Trim() })
129
+ foreach ($service in @('nginx', 'php-fpm', 'rollup', 'opencode')) {
130
+ if ($services -contains $service) { Pass "Compose defines $service" } else { Fail "Compose does not define $service" }
131
+ }
132
+ $result = Invoke-Compose @('ps', '--services') -Quiet
133
+ foreach ($service in @($result.Output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ })) {
134
+ if ($services -notcontains $service) { Fail "Compose project contains unexpected service $service; choose a unique COMPOSE_PROJECT_NAME" }
135
+ }
136
+
137
+ if ($Start) {
138
+ $result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'opencode')
139
+ if ($result.Code -eq 0) { Pass 'All service images build' } else { Fail 'One or more service images failed to build' }
140
+
141
+ # Do not start the bind-mounted Rollup watcher: it intentionally writes
142
+ # generated bundles. Its image and output are validated separately.
143
+ $result = Invoke-Compose @('up', '-d', 'nginx', 'php-fpm', 'opencode')
144
+ if ($result.Code -eq 0) { Pass 'Application services started' } else { Fail 'Application services failed to start' }
145
+ }
146
+
147
+ foreach ($service in @('nginx', 'php-fpm', 'opencode')) {
148
+ if (Test-ContainerRunning $service) { Pass "$service is running" } else { Fail "$service is not running" }
149
+ }
150
+ $rollupId = Get-ContainerId 'rollup'
151
+ if ($rollupId) {
152
+ if (Test-ContainerRunning 'rollup') { Pass 'rollup is running' } else { Fail 'rollup exists but is not running' }
153
+ } else {
154
+ Skip 'rollup watcher is not running; generated bundles are tested from the image'
155
+ }
156
+
157
+ $restartCounts = @{}
158
+ foreach ($service in @('nginx', 'php-fpm', 'opencode')) { $restartCounts[$service] = Get-RestartCount $service }
159
+
160
+ $result = Invoke-Compose @('port', 'nginx', '80') -Quiet
161
+ $published = ($result.Output -join "`n").Trim()
162
+ if ($published -notmatch '^(?:\[(?<ipv6>.+)\]|(?<host>[^:]+)):(?<port>\d+)$') {
163
+ Fail 'Could not resolve the published Nginx port'
164
+ throw 'Missing published port'
165
+ }
166
+ $webHost = if ($Matches.ipv6) { $Matches.ipv6 } else { $Matches.host }
167
+ $webPort = [int]$Matches.port
168
+ if ($webHost -eq '0.0.0.0' -or $webHost -eq '::') { $webHost = '127.0.0.1' }
169
+ $urlHost = if ($webHost.Contains(':')) { "[$webHost]" } else { $webHost }
170
+ $BaseUrl = "http://${urlHost}:$webPort"
171
+ Pass "Nginx is published at $BaseUrl"
172
+
173
+ $health = $null
174
+ $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
175
+ while ([DateTime]::UtcNow -lt $deadline) {
176
+ try { $health = Invoke-Http '/_nestbox/health' } catch { $health = $null }
177
+ if ($null -ne $health -and $health.Status -eq 200) { break }
178
+ Start-Sleep -Seconds 1
179
+ }
180
+ if ($null -ne $health -and $health.Status -eq 200) { Pass 'Nginx and PHP health endpoint responds' } else { Fail 'Health endpoint did not become ready' }
181
+
182
+ $result = Invoke-Compose @('exec', '-T', 'nginx', 'nginx', '-t') -Quiet
183
+ if ($result.Code -eq 0) { Pass 'Nginx configuration is valid' } else { Fail 'Nginx configuration is invalid' }
184
+ $result = Invoke-Compose @('exec', '-T', 'nginx', 'apk', 'info', '-e', 'nginx') -Quiet
185
+ if ($result.Code -eq 0) { Pass 'Alpine Nginx package is installed' } else { Fail 'Alpine Nginx package is missing' }
186
+ $result = Invoke-Compose @('exec', '-T', 'nginx', 'apk', 'info', '-e', 'nginx-mod-http-nchan') -Quiet
187
+ if ($result.Code -eq 0) { Pass 'Alpine Nchan package is installed' } else { Fail 'Alpine Nchan package is missing' }
188
+ $result = Invoke-Compose @('exec', '-T', 'nginx', 'nginx', '-T') -Quiet
189
+ if (($result.Output -join "`n") -match 'ngx_nchan_module\.so') { Pass 'Nchan dynamic module is loaded' } else { Fail 'Nchan dynamic module is not loaded' }
190
+
191
+ if ($indexPresent) {
192
+ $response = Invoke-Http '/'
193
+ if ($response.Status -eq 200) { Pass 'Main super-document returns HTTP 200' } else { Fail "Main super-document returned HTTP $($response.Status)" }
194
+ if ($response.Headers['content-type'] -like 'text/html*') { Pass 'Main super-document is HTML' } else { Fail 'Main super-document has the wrong content type' }
195
+ } else {
196
+ $response = Invoke-Http '/'
197
+ if ($response.Status -eq 404 -and $response.Body.Contains('Create and open session')) { Pass 'Fresh root renders the OpenCode implementation form with HTTP 404' } else { Fail 'Fresh root did not render the OpenCode implementation form with HTTP 404' }
198
+ }
199
+ foreach ($header in @('content-security-policy', 'x-content-type-options', 'referrer-policy', 'cache-control')) {
200
+ if ($response.Headers.ContainsKey($header)) { Pass "$header is present" } else { Fail "$header is missing" }
201
+ }
202
+ if ($response.Headers['cache-control'] -like '*no-store*') { Pass 'Nestbox responses disable browser caching' } else { Fail 'Nestbox responses permit browser caching' }
203
+
204
+ if ($indexPresent) {
205
+ $response = Invoke-Http '/index.md'
206
+ if ($response.Status -eq 200) { Pass 'Markdown direction returns HTTP 200' } else { Fail "Markdown direction returned HTTP $($response.Status)" }
207
+ if ($response.Headers['content-type'] -like 'text/markdown*') { Pass 'Markdown direction has text/markdown content type' } else { Fail 'Markdown direction has the wrong content type' }
208
+ } else {
209
+ $response = Invoke-Http '/index.md'
210
+ if ($response.Status -eq 404) { Pass 'Fresh index Markdown direction remains unresolved' } else { Fail "Fresh index Markdown direction returned HTTP $($response.Status)" }
211
+ }
212
+
213
+ foreach ($asset in @('scripts-bundle.js', 'scripts-bundle.css', 'styles.css')) {
214
+ $response = Invoke-Http "/$asset"
215
+ if ($response.Status -eq 200 -and $response.Body.Length -gt 0) { Pass "$asset is served and nonempty" } else { Fail "$asset is unavailable or empty" }
216
+ if ($response.Headers['cache-control'] -like '*no-store*') { Pass "$asset disables browser caching" } else { Fail "$asset permits browser caching" }
217
+ }
218
+
219
+ foreach ($path in @('/index.php', '/__templates/super-document', '/__includes/functions.php', '/__data/example.json', '/docs/__data/install.json')) {
220
+ $response = Invoke-Http $path
221
+ if ($response.Status -eq 404) { Pass "$path is protected" } else { Fail "$path returned HTTP $($response.Status) instead of 404" }
222
+ }
223
+ $response = Invoke-Http '/.user.ini'
224
+ if ($response.Status -eq 403 -or $response.Status -eq 404) { Pass 'Dotfiles are protected' } else { Fail "Dotfile returned HTTP $($response.Status)" }
225
+
226
+ $missingPath = "/missing-$([guid]::NewGuid().ToString('N'))"
227
+ $missingUri = "$missingPath`?view=full"
228
+ $response = Invoke-Http $missingUri
229
+ $expected404Body = $response.Body.Contains('Create and open session')
230
+ if ($response.Status -eq 404 -and $expected404Body) { Pass 'Missing routes render the interactive PHP 404 document' } else { Fail 'Missing route did not render the interactive PHP 404 document' }
231
+ if ($response.Body.Contains("name=`"requested_uri`" value=`"$missingUri`"")) { Pass 'Interactive 404 preserves the requested page URI' } else { Fail 'Interactive 404 lost the requested page URI' }
232
+ if ($response.Body.Contains('name="prompt" type="text"') -and $response.Body.Contains('target="nestbox-opencode"')) { Pass 'Interactive 404 uses a one-line prompt and the shared OpenCode window' } else { Fail 'Interactive 404 prompt form has the wrong behavior' }
233
+ if ($response.Body.Contains('nestbox-toolbar') -and $response.Body.Contains('Open Chat') -and -not $response.Body.Contains('Continue in OpenCode')) { Pass 'Shared header opens the common OpenCode chat' } else { Fail 'Shared header contains page-specific session controls' }
234
+ $response = Invoke-Http '/404' '' 'POST'
235
+ if ($response.Status -eq 422) { Pass 'Interactive 404 rejects invalid requests' } else { Fail "Interactive 404 accepted an invalid request with HTTP $($response.Status)" }
236
+
237
+ $nginxId = Get-ContainerId 'nginx'
238
+ $inspectOutput = & docker inspect $nginxId 2>$null
239
+ $inspectCode = $LASTEXITCODE
240
+ $publisherBinding = if ($inspectCode -eq 0) { (@($inspectOutput | ConvertFrom-Json)[0].NetworkSettings.Ports).'8080/tcp' } else { 'inspection-failed' }
241
+ if ($inspectCode -eq 0 -and $null -eq $publisherBinding) { Pass 'Internal publisher port is not published on the host' } else { Fail 'Internal publisher port is published on the host' }
242
+ $response = Invoke-Http '/_nestbox/publish?topics=nestbox-test:public' '' 'POST'
243
+ if ($response.Status -eq 404) { Pass 'Publisher endpoint is unavailable on the public port' } else { Fail "Public publisher endpoint returned HTTP $($response.Status)" }
244
+
245
+ if ($indexPresent) {
246
+ $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
247
+ if ($result.Code -eq 0) { Pass 'Installed super-document passes PHP lint' } else { Fail 'Installed super-document failed PHP lint' }
248
+ } else {
249
+ Pass 'Fresh page tree has no index.php to lint'
250
+ }
251
+ $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/404.php') -Quiet
252
+ if ($result.Code -eq 0) { Pass 'Interactive 404 super-document passes PHP lint' } else { Fail 'Interactive 404 super-document failed PHP lint' }
253
+ $contextCode = "require '/home/code/__includes/functions.php'; `$context = nestbox_page_context('/docs/install?tab=all'); exit((`$context['statePath'] === '/home/code/docs/__data/install.json' && `$context['agentDirectory'] === '/nestbox/home/code/docs') ? 0 : 1);"
254
+ $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-r', $contextCode) -Quiet
255
+ if ($result.Code -eq 0) { Pass 'Nested pages map to directory-local data and agent scope' } else { Fail 'Nested page data or agent context is incorrect' }
256
+ $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '/sources/php/cli.php', '__templates/super-document') -Quiet
257
+ if ($result.Code -ne 0) { Pass 'CLI router rejects protected super-documents' } else { Fail 'Protected super-document executed through the CLI router' }
258
+
259
+ $openCodeHealthCode = "require '/home/code/__includes/functions.php'; `$health = nestbox_opencode_request('GET', '/global/health', [], '/workspace'); exit((`$health['healthy'] ?? false) ? 0 : 1);"
260
+ $result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-r', $openCodeHealthCode) -Quiet
261
+ if ($result.Code -eq 0) { Pass 'PHP reaches the authenticated internal OpenCode API' } else { Fail 'PHP cannot reach the authenticated internal OpenCode API' }
262
+
263
+ $response = Invoke-Http '/Agent' "localhost:$webPort"
264
+ $location = $response.Headers['location']
265
+ if ($response.Status -eq 302 -and $location -eq "http://agent.localhost:$webPort/") { Pass '/Agent redirects to the same-port OpenCode origin' } else { Fail "/Agent redirect was HTTP $($response.Status) with Location $location" }
266
+
267
+ $response = Invoke-Http '/doc' "agent.localhost:$webPort"
268
+ if ($response.Status -eq 401 -or ($response.Status -ge 200 -and $response.Status -lt 400)) { Pass 'OpenCode host routing responds' } else { Fail "OpenCode host routing returned HTTP $($response.Status)" }
269
+
270
+ $channel = "nestbox-test:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())-$PID"
271
+ $token = "event-$([guid]::NewGuid().ToString('N'))"
272
+ $sseUrl = "$BaseUrl/_nestbox/events?topics=nestbox-test:*"
273
+ $SseJob = Start-Job -ScriptBlock {
274
+ param($Url, $Timeout)
275
+ & curl.exe --silent --show-error --noproxy '*' --no-buffer --max-time $Timeout --header 'Accept: text/event-stream' $Url
276
+ } -ArgumentList $sseUrl, $TimeoutSeconds
277
+
278
+ $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
279
+ $sseText = ''
280
+ while ([DateTime]::UtcNow -lt $deadline) {
281
+ $sseText = @(Receive-Job -Job $SseJob -Keep) -join "`n"
282
+ if ($sseText.Contains(': hi')) { break }
283
+ Start-Sleep -Milliseconds 250
284
+ }
285
+ if ($sseText.Contains(': hi')) { Pass 'EventSource subscriber is connected' } else { Fail 'EventSource subscriber did not connect' }
286
+
287
+ $phpCode = "require '/home/code/__includes/functions.php'; exit(nestbox_bus_publish(getenv('TEST_TOPIC'), 'changed', ['token' => getenv('TEST_TOKEN')]) ? 0 : 1);"
288
+ $result = Invoke-Compose @('exec', '-T', '-e', "TEST_TOPIC=$channel", '-e', "TEST_TOKEN=$token", 'php-fpm', 'php', '-r', $phpCode) -Quiet
289
+ if ($result.Code -eq 0) { Pass 'PHP publishes through the internal Nchan endpoint' } else { Fail 'PHP failed to publish through the internal Nchan endpoint' }
290
+
291
+ $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
292
+ while ([DateTime]::UtcNow -lt $deadline) {
293
+ $sseText = @(Receive-Job -Job $SseJob -Keep) -join "`n"
294
+ if ($sseText.Contains($token)) { break }
295
+ Start-Sleep -Milliseconds 250
296
+ }
297
+ if ($sseText.Contains('event: changed') -and $sseText.Contains($token) -and $sseText.Contains($channel)) { Pass 'EventSource receives the wildcard Nchan event with its payload' } else { Fail 'EventSource did not receive the expected Nchan event' }
298
+ Stop-Job -Job $SseJob -ErrorAction SilentlyContinue
299
+ Remove-Job -Job $SseJob -Force -ErrorAction SilentlyContinue
300
+ $SseJob = $null
301
+
302
+ foreach ($service in @('nginx', 'php-fpm', 'opencode')) {
303
+ $after = Get-RestartCount $service
304
+ 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
+ }
306
+ }
307
+ catch {
308
+ Fail $_.Exception.Message
309
+ }
310
+ finally {
311
+ if ($null -ne $SseJob) {
312
+ Stop-Job -Job $SseJob -ErrorAction SilentlyContinue
313
+ Remove-Job -Job $SseJob -Force -ErrorAction SilentlyContinue
314
+ }
315
+ if ($null -ne $Client) { $Client.Dispose() }
316
+ if (Test-Path -LiteralPath $TempDirectory) { Remove-Item -LiteralPath $TempDirectory -Recurse -Force }
317
+ }
318
+
319
+ Write-Output ""
320
+ Write-Output "$Passes passed, $Failures failed, $Skips skipped"
321
+ if ($Failures -gt 0) { exit 1 }
322
+ exit 0