@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.
- package/.dockerignore +34 -0
- package/.env.example +35 -0
- package/.gitattributes +10 -0
- package/INSTALL.md +258 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/bin/create-nestbox.js +184 -0
- package/docker/nginx/Dockerfile +31 -0
- package/docker/opencode/Dockerfile +36 -0
- package/docker/opencode/entrypoint.sh +9 -0
- package/docker/php-fpm/Dockerfile +43 -0
- package/docker/rollup/Dockerfile +16 -0
- package/docker-compose.yaml +139 -0
- package/home/code/.user.ini +1 -0
- package/home/code/404.php +104 -0
- package/home/code/__data/.gitignore +2 -0
- package/home/code/__includes/footer.php +3 -0
- package/home/code/__includes/functions.php +325 -0
- package/home/code/__includes/header.php +20 -0
- package/home/code/__templates/super-document.php +47 -0
- package/home/code/scripts-bundle.css +13295 -0
- package/home/code/scripts-bundle.js +6277 -0
- package/home/code/scripts.js +3 -0
- package/home/code/styles.css +36 -0
- package/home/configs/nginx/nginx.conf +199 -0
- package/home/configs/opencode/instance.example.md +16 -0
- package/home/configs/opencode/instructions.md +308 -0
- package/home/configs/opencode/opencode.json +15 -0
- package/home/configs/php/cli.php +33 -0
- package/home/configs/php/composer.json +10 -0
- package/home/configs/php/composer.lock +805 -0
- package/home/configs/php/index.php +105 -0
- package/home/configs/php/init.php +3 -0
- package/home/configs/php/php.ini +5 -0
- package/home/configs/php/preload.php +5 -0
- package/home/configs/rollup/package-lock.json +2089 -0
- package/home/configs/rollup/package.json +16 -0
- package/home/configs/rollup/rollup.config.js +28 -0
- package/package.json +42 -0
- package/scripts/check-package.js +31 -0
- package/template.gitignore +20 -0
- package/tests/nestbox.ps1 +322 -0
- 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
|