@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,43 @@
1
+ # syntax=docker/dockerfile:1.7
2
+
3
+ # ---- Stage 1: install PHP dependencies with Composer ----
4
+ FROM composer:latest AS vendor
5
+
6
+ WORKDIR /build
7
+ COPY ./home/configs/php/composer.json ./composer.json
8
+ COPY ./home/configs/php/composer.lock ./composer.lock
9
+ RUN composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
10
+
11
+ # ---- Stage 2: runtime ----
12
+ FROM php:fpm-alpine
13
+
14
+ ARG APP_UID=1000
15
+ ARG APP_GID=1000
16
+
17
+ # Required system + PHP extensions.
18
+ RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS libzip-dev && \
19
+ apk add --no-cache libzip && \
20
+ docker-php-ext-install zip && \
21
+ apk del .build-deps
22
+
23
+ # PHP config.
24
+ COPY ./home/configs/php/php.ini /usr/local/etc/php/conf.d/custom.ini
25
+
26
+ # Vendor dir from stage 1.
27
+ COPY --from=vendor /build/vendor /vendor
28
+ COPY ./home/configs/php/composer.json /composer.json
29
+ COPY ./home/configs/php/composer.lock /composer.lock
30
+
31
+ # Application source.
32
+ COPY ./home/configs/php /sources/php
33
+ COPY ./home/code /home/code
34
+
35
+ # Run as a non-root, non-default user. UID/GID are passed as build args so
36
+ # they can match the host user when bind-mounting in development, avoiding
37
+ # the classic "files owned by root after compose down" annoyance on Linux.
38
+ RUN addgroup -g "${APP_GID}" -S nestbox && \
39
+ adduser -u "${APP_UID}" -G nestbox -S -H nestbox && \
40
+ sed -i "s/^user = .*/user = nestbox/; s/^group = .*/group = nestbox/" /usr/local/etc/php-fpm.d/www.conf && \
41
+ chown -R nestbox:nestbox /home/code /sources/php /vendor
42
+
43
+ USER nestbox
@@ -0,0 +1,16 @@
1
+ FROM node:alpine
2
+
3
+ ARG APP_UID=1000
4
+ ARG APP_GID=1000
5
+
6
+ RUN apk add --no-cache shadow && \
7
+ if [ "$(id -u node)" != "$APP_UID" ]; then usermod -u "$APP_UID" node; fi && \
8
+ if [ "$(id -g node)" != "$APP_GID" ]; then groupmod -g "$APP_GID" node; fi
9
+
10
+ COPY ./home/code /home/code
11
+ COPY ./home/configs/rollup /sources/rollup
12
+
13
+ WORKDIR /sources/rollup
14
+ RUN npm ci --no-audit --no-fund && chown -R node:node /home/code /sources/rollup
15
+
16
+ USER node
@@ -0,0 +1,139 @@
1
+ # Complete local Nestbox runtime. The installer configures paths, ownership,
2
+ # credentials, and the shared browser port through .env.
3
+ services:
4
+ # Single browser gateway. The ordinary host serves Nestbox, while agent.*
5
+ # virtual hosts proxy the internal OpenCode server.
6
+ nginx:
7
+ build:
8
+ context: .
9
+ dockerfile: ./docker/nginx/Dockerfile
10
+ depends_on:
11
+ - php-fpm
12
+ - opencode
13
+ ports:
14
+ # Nestbox and OpenCode share one confirmed host port.
15
+ - "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-4180}:80"
16
+ expose:
17
+ # Internal Nchan publisher only; never publish this port on the host.
18
+ - "8080"
19
+ volumes:
20
+ # Nginx only reads pages and generated browser assets.
21
+ - type: bind
22
+ source: ${NESTBOX_PAGE_PATH:-./home/code}
23
+ target: /var/www/html
24
+ read_only: true
25
+ bind:
26
+ create_host_path: false
27
+ - ./home/configs/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
28
+ healthcheck:
29
+ # The endpoint passes through PHP, checking both Nginx and PHP-FPM.
30
+ test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/_nestbox/health"]
31
+ interval: 10s
32
+ timeout: 3s
33
+ retries: 5
34
+ start_period: 10s
35
+ restart: unless-stopped
36
+
37
+ # Executes super-document HTTP and CLI directions. Trusted pages may update
38
+ # page state and intentionally operate on the mounted workspace.
39
+ php-fpm:
40
+ build:
41
+ context: .
42
+ dockerfile: ./docker/php-fpm/Dockerfile
43
+ args:
44
+ APP_UID: ${APP_UID:-1000}
45
+ APP_GID: ${APP_GID:-1000}
46
+ volumes:
47
+ # Routers and PHP configuration are source, not writable state.
48
+ - ./home/configs/php:/sources/php:ro
49
+ - type: bind
50
+ source: ${NESTBOX_PAGE_PATH:-./home/code}
51
+ target: /home/code
52
+ bind:
53
+ create_host_path: false
54
+ - type: bind
55
+ source: ${WORKSPACE_PATH:-.}
56
+ target: /workspace
57
+ bind:
58
+ create_host_path: false
59
+ environment:
60
+ # Event publication and OpenCode automation stay inside Compose.
61
+ - NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
62
+ - NESTBOX_URL_PREFIX=${NESTBOX_URL_PREFIX:-}
63
+ - NESTBOX_OPENCODE_URL=http://opencode:4096
64
+ - NESTBOX_OPENCODE_PUBLIC_URL=${NESTBOX_OPENCODE_PUBLIC_URL:-}
65
+ - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
66
+ - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
67
+ restart: unless-stopped
68
+
69
+ # Watches shared browser source and refreshes tracked bundle baselines.
70
+ # Dependencies are installed in the image, so startup does not need npm ci.
71
+ rollup:
72
+ build:
73
+ context: .
74
+ dockerfile: ./docker/rollup/Dockerfile
75
+ args:
76
+ APP_UID: ${APP_UID:-1000}
77
+ APP_GID: ${APP_GID:-1000}
78
+ user: "${APP_UID:-1000}:${APP_GID:-1000}"
79
+ working_dir: /sources/rollup
80
+ command: npm run rollup -- --watch
81
+ volumes:
82
+ # Configuration changes are valid agent work but require an image rebuild.
83
+ - ./home/configs/rollup:/sources/rollup
84
+ - type: bind
85
+ source: ${NESTBOX_PAGE_PATH:-./home/code}
86
+ target: /home/code
87
+ bind:
88
+ create_host_path: false
89
+ # Keep dependencies out of the host page tree.
90
+ - rollup-node-modules:/sources/rollup/node_modules
91
+ restart: unless-stopped
92
+
93
+ # Internal implementation agent. It can edit Nestbox and the workspace but
94
+ # deliberately receives no Docker socket or direct host control.
95
+ opencode:
96
+ build:
97
+ context: .
98
+ dockerfile: ./docker/opencode/Dockerfile
99
+ args:
100
+ APP_UID: ${APP_UID:-1000}
101
+ APP_GID: ${APP_GID:-1000}
102
+ env_file:
103
+ # Provider and OpenCode credentials are supplied to this service.
104
+ - .env
105
+ volumes:
106
+ # Agent and PHP tooling configuration remain read-only at runtime.
107
+ - ./home/configs/opencode:/etc/opencode:ro
108
+ - ./home/configs/php:/sources/php:ro
109
+ # Read-only page view used by PHP tooling inside the agent container.
110
+ - type: bind
111
+ source: ${NESTBOX_PAGE_PATH:-./home/code}
112
+ target: /home/code
113
+ read_only: true
114
+ bind:
115
+ create_host_path: false
116
+ # Writable installation and stable page-authoring paths.
117
+ - .:/nestbox
118
+ - type: bind
119
+ source: ${NESTBOX_PAGE_PATH:-./home/code}
120
+ target: /nestbox/home/code
121
+ bind:
122
+ create_host_path: false
123
+ - type: bind
124
+ source: ${WORKSPACE_PATH:-.}
125
+ target: /workspace
126
+ bind:
127
+ create_host_path: false
128
+ # The entrypoint fixes this volume's ownership before dropping privileges.
129
+ - opencode-data:/home/nestbox/.local/share/opencode
130
+ working_dir: /workspace
131
+ environment:
132
+ - NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
133
+ restart: unless-stopped
134
+
135
+ volumes:
136
+ # Disposable dependency cache reconstructed from package-lock.json.
137
+ rollup-node-modules:
138
+ # OpenCode sessions retained across container recreation.
139
+ opencode-data:
@@ -0,0 +1 @@
1
+ auto_prepend_file = /sources/php/init.php
@@ -0,0 +1,104 @@
1
+ <?php
2
+ declare(strict_types=1);
3
+
4
+ // HTTP direction: preserve and validate the unresolved URI across the form POST.
5
+ $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
6
+ $rawRequestedUri = $method === 'POST'
7
+ ? (string) ($_POST['requested_uri'] ?? '')
8
+ : (string) ($_SERVER['NESTBOX_ORIGINAL_URI'] ?? $_SERVER['REQUEST_URI'] ?? '/');
9
+ $pageContext = nestbox_page_context($rawRequestedUri);
10
+ $requestedUri = $pageContext['requestedUri'] ?? '';
11
+ $requestedPath = $pageContext['requestedPath'] ?? '';
12
+ $agentDirectory = $pageContext['agentDirectory'] ?? '';
13
+
14
+ if (PHP_SAPI === 'cli') {
15
+ echo "Implementation sessions are managed by OpenCode.\n";
16
+ exit;
17
+ }
18
+
19
+ $error = '';
20
+ $submittedPrompt = '';
21
+
22
+ if ($method === 'POST') {
23
+ if ($pageContext === null) {
24
+ http_response_code(422);
25
+ $error = 'This URI cannot map to a Nestbox super-document.';
26
+ } elseif (($_POST['action'] ?? '') !== 'start') {
27
+ http_response_code(400);
28
+ $error = 'Unknown form action.';
29
+ } else {
30
+ $submittedPrompt = trim((string) ($_POST['prompt'] ?? ''));
31
+ $singleLinePrompt = preg_replace('/\s+/u', ' ', $submittedPrompt);
32
+ $submittedPrompt = is_string($singleLinePrompt) ? $singleLinePrompt : '';
33
+
34
+ if (!is_string($singleLinePrompt)
35
+ || $singleLinePrompt === ''
36
+ || strlen($singleLinePrompt) > 4000
37
+ || preg_match('//u', $singleLinePrompt) !== 1
38
+ ) {
39
+ http_response_code(422);
40
+ $error = 'Enter a valid one-line UTF-8 request between 1 and 4,000 bytes.';
41
+ } else {
42
+ try {
43
+ // OpenCode scopes the new session to the directory where the
44
+ // requested super-document will live.
45
+ $localDirectory = '/home/code' . substr($agentDirectory, strlen('/nestbox/home/code'));
46
+ if (!is_dir($localDirectory) && !mkdir($localDirectory, 0775, true) && !is_dir($localDirectory)) {
47
+ throw new RuntimeException('Nestbox could not create the super-document directory.');
48
+ }
49
+
50
+ $title = preg_match('/^.{0,72}/us', $singleLinePrompt, $titleMatch) === 1
51
+ ? $titleMatch[0]
52
+ : 'New page';
53
+ $session = nestbox_opencode_create_session('Nestbox ' . $requestedPath . ': ' . $title, $agentDirectory);
54
+ if (isset($session['directory'])
55
+ && (!is_string($session['directory']) || $session['directory'] !== $agentDirectory)
56
+ ) {
57
+ throw new RuntimeException('OpenCode created the session in an unexpected directory.');
58
+ }
59
+
60
+ $sessionId = (string) $session['id'];
61
+ $agentPrompt = 'Requested Nestbox page URI: ' . $requestedUri
62
+ . '. Implement this route as a cohesive super-document under /nestbox/home/code, following /nestbox/home/configs/opencode/instructions.md. Do not modify unrelated projects under /workspace. Request: '
63
+ . $singleLinePrompt;
64
+ nestbox_opencode_prompt_async($sessionId, $agentPrompt, '', $agentDirectory);
65
+
66
+ header('Location: ' . nestbox_opencode_session_url($sessionId, $agentDirectory), true, 303);
67
+ exit;
68
+ } catch (Throwable $exception) {
69
+ http_response_code(502);
70
+ $error = $exception->getMessage();
71
+ }
72
+ }
73
+ }
74
+ } else {
75
+ http_response_code(404);
76
+ }
77
+
78
+ $formAction = nestbox_url_path('404');
79
+ ?>
80
+ ---
81
+ title: Page not found
82
+ ---
83
+
84
+ # Page not found
85
+
86
+ Nestbox could not resolve `<?= nestbox_escape($requestedUri); ?>`.
87
+
88
+ <?php if ($error !== ''): ?>
89
+ <div class="alert alert-danger" role="alert"><?= nestbox_escape($error); ?></div>
90
+ <?php endif; ?>
91
+
92
+ <?php if ($pageContext !== null): ?>
93
+ <form class="card card-body" method="post" action="<?= nestbox_escape($formAction); ?>" target="nestbox-opencode">
94
+ <input type="hidden" name="action" value="start">
95
+ <input type="hidden" name="requested_uri" value="<?= nestbox_escape($requestedUri); ?>">
96
+ <label class="form-label" for="prompt">Ask OpenCode to implement this page</label>
97
+ <input class="form-control" id="prompt" name="prompt" type="text" maxlength="4000" value="<?= nestbox_escape($submittedPrompt); ?>" required>
98
+ <div class="mt-3">
99
+ <button class="btn btn-primary" type="submit">Create and open session</button>
100
+ </div>
101
+ </form>
102
+ <?php else: ?>
103
+ <div class="alert alert-secondary" role="status">This URI cannot map to a Nestbox super-document.</div>
104
+ <?php endif; ?>
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
@@ -0,0 +1,3 @@
1
+ </article>
2
+ </body>
3
+ </html>
@@ -0,0 +1,325 @@
1
+ <?php
2
+ declare(strict_types=1);
3
+
4
+ /** Escape a value for HTML text or attribute output. */
5
+ function nestbox_escape(mixed $value): string
6
+ {
7
+ return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
8
+ }
9
+
10
+ /** Encode a PHP value as a JSON literal that cannot terminate an inline script. */
11
+ function nestbox_script_json(mixed $value): string
12
+ {
13
+ return json_encode(
14
+ $value,
15
+ JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
16
+ );
17
+ }
18
+
19
+ /** Return the configured normalized public URL prefix. */
20
+ function nestbox_url_prefix(): string
21
+ {
22
+ $value = getenv('NESTBOX_URL_PREFIX') ?: '';
23
+ $prefix = '/' . trim((string) $value, '/');
24
+
25
+ if ($prefix === '/') {
26
+ return '';
27
+ }
28
+ if (!preg_match('#^/(?:[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*)$#', $prefix)) {
29
+ throw new UnexpectedValueException('NESTBOX_URL_PREFIX must contain only URL-safe path segments.');
30
+ }
31
+
32
+ return $prefix;
33
+ }
34
+
35
+ /** Build a prefix-aware URL path by encoding each path segment independently. */
36
+ function nestbox_url_path(string ...$segments): string
37
+ {
38
+ $path = implode('/', array_map('rawurlencode', $segments));
39
+ return nestbox_url_prefix() . '/' . $path;
40
+ }
41
+
42
+ /** Map a public URI to its page data and agent directory. */
43
+ function nestbox_page_context(string $requestUri): ?array
44
+ {
45
+ if ($requestUri === '' || preg_match('/[\r\n]/', $requestUri)) {
46
+ return null;
47
+ }
48
+ $path = parse_url($requestUri, PHP_URL_PATH);
49
+ $query = parse_url($requestUri, PHP_URL_QUERY);
50
+ $path = is_string($path) ? preg_replace('#/+#', '/', $path) : null;
51
+ if (!is_string($path) || !str_starts_with($path, '/') || str_starts_with($path, '//')) {
52
+ return null;
53
+ }
54
+
55
+ $route = trim($path, '/');
56
+ if ($route === '') {
57
+ $route = 'index';
58
+ }
59
+ $markdownDirection = str_ends_with(strtolower($route), '.md');
60
+ if ($markdownDirection) {
61
+ $route = substr($route, 0, -3);
62
+ }
63
+ if (!preg_match('#^[A-Za-z0-9/_-]+$#', $route)) {
64
+ return null;
65
+ }
66
+ foreach (explode('/', $route) as $segment) {
67
+ if (str_starts_with($segment, '__')) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ $routeDirectory = dirname($route);
73
+ $relativeDirectory = $routeDirectory === '.' ? '' : '/' . $routeDirectory;
74
+ return [
75
+ 'requestedUri' => $path . (is_string($query) ? '?' . $query : ''),
76
+ 'requestedPath' => $path,
77
+ 'route' => $route,
78
+ 'statePath' => '/home/code' . $relativeDirectory . '/__data/' . basename($route) . '.json',
79
+ 'agentDirectory' => '/nestbox/home/code' . $relativeDirectory,
80
+ ];
81
+ }
82
+
83
+ /** Return the public OpenCode origin that corresponds to the current Nestbox host. */
84
+ function nestbox_opencode_origin(): string
85
+ {
86
+ $configuredOrigin = rtrim((string) getenv('NESTBOX_OPENCODE_PUBLIC_URL'), '/');
87
+ if ($configuredOrigin !== '') {
88
+ $parts = parse_url($configuredOrigin);
89
+ if (!is_array($parts)
90
+ || !isset($parts['scheme'], $parts['host'])
91
+ || !in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true)
92
+ || isset($parts['user'])
93
+ || isset($parts['pass'])
94
+ || isset($parts['query'])
95
+ || isset($parts['fragment'])
96
+ || (isset($parts['path']) && $parts['path'] !== '')
97
+ ) {
98
+ throw new UnexpectedValueException('NESTBOX_OPENCODE_PUBLIC_URL must be an HTTP origin without credentials or a path.');
99
+ }
100
+
101
+ return $configuredOrigin;
102
+ }
103
+
104
+ $scheme = strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? 'http'));
105
+ if (!in_array($scheme, ['http', 'https'], true)) {
106
+ throw new UnexpectedValueException('The request scheme is not valid for an OpenCode URL.');
107
+ }
108
+
109
+ $host = trim((string) ($_SERVER['HTTP_HOST'] ?? 'localhost'));
110
+ if (!preg_match('#^([A-Za-z0-9.-]+)(:\d{1,5})?$#', $host, $matches)) {
111
+ throw new UnexpectedValueException('The request host is not valid for an OpenCode URL.');
112
+ }
113
+
114
+ $hostname = strtolower($matches[1]);
115
+ $port = $matches[2] ?? '';
116
+ if (in_array($hostname, ['localhost', '127.0.0.1'], true)) {
117
+ $hostname = 'agent.localhost';
118
+ } elseif (!str_starts_with($hostname, 'agent.')) {
119
+ $hostname = 'agent.' . $hostname;
120
+ }
121
+
122
+ return "$scheme://$hostname$port";
123
+ }
124
+
125
+ /** Build the current OpenCode web UI's server-scoped session deep link. */
126
+ function nestbox_opencode_session_url(string $sessionId, string $directory): string
127
+ {
128
+ if (!preg_match('#^[A-Za-z0-9_-]{1,128}$#', $sessionId)) {
129
+ throw new InvalidArgumentException('Invalid OpenCode session ID.');
130
+ }
131
+ if (!str_starts_with($directory, '/') || str_contains($directory, "\0")) {
132
+ throw new InvalidArgumentException('OpenCode session directories must be absolute paths.');
133
+ }
134
+
135
+ $origin = nestbox_opencode_origin();
136
+ $serverKey = rtrim(strtr(base64_encode($origin), '+/', '-_'), '=');
137
+ return $origin . '/server/' . $serverKey . '/session/' . rawurlencode($sessionId);
138
+ }
139
+
140
+ /** Send a JSON request to the internal OpenCode server for one mounted directory. */
141
+ function nestbox_opencode_request(string $method, string $path, array $body, string $directory): mixed
142
+ {
143
+ $baseUrl = rtrim((string) getenv('NESTBOX_OPENCODE_URL'), '/');
144
+ if ($baseUrl === '') {
145
+ throw new RuntimeException('NESTBOX_OPENCODE_URL is not configured.');
146
+ }
147
+ if (!str_starts_with($path, '/')
148
+ || !str_starts_with($directory, '/')
149
+ || preg_match('/[\0\r\n]/', $path . $directory)
150
+ ) {
151
+ throw new InvalidArgumentException('Invalid OpenCode request path or directory.');
152
+ }
153
+
154
+ // OpenCode scopes every non-global API operation with the documented
155
+ // directory query parameter. Older custom headers are ignored by the SDK.
156
+ $requestUrl = $baseUrl . $path
157
+ . (str_contains($path, '?') ? '&' : '?')
158
+ . 'directory=' . rawurlencode($directory);
159
+ $payload = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
160
+ $headers = [
161
+ 'Accept: application/json',
162
+ 'Content-Type: application/json',
163
+ 'Content-Length: ' . strlen($payload),
164
+ ];
165
+ $password = (string) getenv('OPENCODE_SERVER_PASSWORD');
166
+ if ($password !== '') {
167
+ $username = (string) (getenv('OPENCODE_SERVER_USERNAME') ?: 'opencode');
168
+ $headers[] = 'Authorization: Basic ' . base64_encode("$username:$password");
169
+ }
170
+
171
+ $context = stream_context_create([
172
+ 'http' => [
173
+ 'method' => strtoupper($method),
174
+ 'header' => implode("\r\n", $headers),
175
+ 'content' => $payload,
176
+ 'ignore_errors' => true,
177
+ 'timeout' => 10,
178
+ ],
179
+ ]);
180
+ $response = @file_get_contents($requestUrl, false, $context);
181
+ $statusLine = $http_response_header[0] ?? '';
182
+ preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches);
183
+ $status = isset($matches[1]) ? (int) $matches[1] : 0;
184
+
185
+ if ($response === false || $status < 200 || $status >= 300) {
186
+ throw new RuntimeException("OpenCode request failed with HTTP status $status.");
187
+ }
188
+ if ($response === '' || $status === 204) {
189
+ return null;
190
+ }
191
+
192
+ return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
193
+ }
194
+
195
+ /** Create an OpenCode session synchronously so its ID is available immediately. */
196
+ function nestbox_opencode_create_session(string $title, string $directory = '/workspace'): array
197
+ {
198
+ $session = nestbox_opencode_request('POST', '/session', ['title' => $title], $directory);
199
+ if (!is_array($session)
200
+ || !isset($session['id'])
201
+ || !is_string($session['id'])
202
+ || !preg_match('#^[A-Za-z0-9_-]{1,128}$#', $session['id'])
203
+ ) {
204
+ throw new RuntimeException('OpenCode did not return a session ID.');
205
+ }
206
+
207
+ return $session;
208
+ }
209
+
210
+ /** Queue a prompt, with optional system context, without waiting for the response. */
211
+ function nestbox_opencode_prompt_async(
212
+ string $sessionId,
213
+ string $prompt,
214
+ string $system,
215
+ string $directory = '/workspace',
216
+ ): void {
217
+ if (!preg_match('#^[A-Za-z0-9_-]{1,128}$#', $sessionId)) {
218
+ throw new InvalidArgumentException('Invalid OpenCode session ID.');
219
+ }
220
+
221
+ $payload = [
222
+ 'parts' => [[
223
+ 'type' => 'text',
224
+ 'text' => $prompt,
225
+ ]],
226
+ ];
227
+ if ($system !== '') {
228
+ $payload['system'] = $system;
229
+ }
230
+
231
+ nestbox_opencode_request('POST', '/session/' . rawurlencode($sessionId) . '/prompt_async', $payload, $directory);
232
+ }
233
+
234
+ /** Validate and normalize an exact or suffix-wildcard event topic. */
235
+ function nestbox_bus_topic(string $topic, bool $allowWildcard = true): string
236
+ {
237
+ $topic = trim($topic);
238
+ $segment = '[A-Za-z0-9][A-Za-z0-9._/-]{0,63}';
239
+ $pattern = $allowWildcard
240
+ ? "#^(?:$segment)(?::$segment)*(?::\\*)?$#"
241
+ : "#^(?:$segment)(?::$segment)*$#";
242
+
243
+ if (strlen($topic) > 128 || !preg_match($pattern, $topic)) {
244
+ throw new InvalidArgumentException("Invalid Nestbox event topic: $topic");
245
+ }
246
+
247
+ return $topic;
248
+ }
249
+
250
+ /** Build a same-origin, prefix-aware EventSource URL for one or more topics. */
251
+ function nestbox_bus_url(string ...$topics): string
252
+ {
253
+ if ($topics === []) {
254
+ throw new InvalidArgumentException('At least one Nestbox event topic is required.');
255
+ }
256
+
257
+ $topics = array_values(array_unique(array_map(
258
+ static fn (string $topic): string => nestbox_bus_topic($topic),
259
+ $topics,
260
+ )));
261
+ if (count($topics) > 16) {
262
+ throw new LengthException('A Nestbox EventSource can subscribe to at most 16 topics.');
263
+ }
264
+
265
+ return nestbox_url_path('_nestbox', 'events') . '?topics=' . implode(',', $topics);
266
+ }
267
+
268
+ /** Best-effort publication to an exact topic and its suffix wildcard. */
269
+ function nestbox_bus_publish(string $topic, string $event = 'changed', array $data = []): bool
270
+ {
271
+ $topic = nestbox_bus_topic($topic, false);
272
+ if (!preg_match('#^[A-Za-z][A-Za-z0-9_.-]{0,63}$#', $event)) {
273
+ throw new InvalidArgumentException("Invalid Nestbox event name: $event");
274
+ }
275
+
276
+ $topics = [$topic];
277
+ $separator = strrpos($topic, ':');
278
+ if ($separator !== false) {
279
+ $topics[] = substr($topic, 0, $separator + 1) . '*';
280
+ }
281
+
282
+ $payload = json_encode([
283
+ 'topic' => $topic,
284
+ 'event' => $event,
285
+ 'data' => $data,
286
+ ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
287
+
288
+ if (strlen($payload) > 64 * 1024) {
289
+ throw new LengthException('Nestbox event payloads cannot exceed 64 KiB.');
290
+ }
291
+
292
+ $publisher = getenv('NESTBOX_BUS_PUBLISH_URL');
293
+ if (!$publisher) {
294
+ error_log('Nestbox event publication skipped: NESTBOX_BUS_PUBLISH_URL is not configured.');
295
+ return false;
296
+ }
297
+
298
+ $url = $publisher . (str_contains($publisher, '?') ? '&' : '?')
299
+ . 'topics=' . implode(',', $topics);
300
+ $context = stream_context_create([
301
+ 'http' => [
302
+ 'method' => 'POST',
303
+ 'header' => implode("\r\n", [
304
+ 'Content-Type: application/json',
305
+ 'X-EventSource-Event: ' . $event,
306
+ 'Content-Length: ' . strlen($payload),
307
+ ]),
308
+ 'content' => $payload,
309
+ 'ignore_errors' => true,
310
+ 'timeout' => 2,
311
+ ],
312
+ ]);
313
+
314
+ $response = @file_get_contents($url, false, $context);
315
+ $statusLine = $http_response_header[0] ?? '';
316
+ preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches);
317
+ $status = isset($matches[1]) ? (int) $matches[1] : 0;
318
+
319
+ if ($response === false || !in_array($status, [201, 202], true)) {
320
+ error_log("Nestbox event publication failed with HTTP status $status.");
321
+ return false;
322
+ }
323
+
324
+ return true;
325
+ }
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><?= htmlspecialchars($title ?? 'Nestbox', ENT_QUOTES, 'UTF-8'); ?></title>
7
+ <!-- Pre-built bundle of every shared dependency (Bootstrap, GitHub Markdown CSS, …): -->
8
+ <link rel="stylesheet" href="<?= nestbox_escape(nestbox_url_path('scripts-bundle.css')); ?>">
9
+ <script defer src="<?= nestbox_escape(nestbox_url_path('scripts-bundle.js')); ?>"></script>
10
+ <!-- Site-wide overrides: -->
11
+ <link rel="stylesheet" href="<?= nestbox_escape(nestbox_url_path('styles.css')); ?>">
12
+ </head>
13
+ <body>
14
+ <header class="nestbox-toolbar navbar fixed-top bg-body-tertiary border-bottom px-3" aria-label="Page tools">
15
+ <a class="navbar-brand fs-6 fw-semibold mb-0" href="<?= nestbox_escape(nestbox_url_path()); ?>">Nestbox</a>
16
+ <div class="d-flex align-items-center gap-2 ms-auto">
17
+ <a class="btn btn-sm btn-secondary" href="<?= nestbox_escape(nestbox_opencode_origin()); ?>" target="nestbox-opencode">Open Chat</a>
18
+ </div>
19
+ </header>
20
+ <article class="markdown-body">