velocious 1.0.570 → 1.0.571
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/README.md +44 -0
- package/build/configuration-types.js +17 -0
- package/build/configuration.js +70 -0
- package/build/http-server/client/index.js +77 -4
- package/build/http-server/client/response-compression.js +224 -0
- package/build/http-server/client/response.js +57 -0
- package/build/http-server/websocket-events-host.js +91 -48
- package/build/src/configuration-types.d.ts +55 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +16 -1
- package/build/src/configuration.d.ts +6 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +61 -1
- package/build/src/http-server/client/index.d.ts +17 -1
- package/build/src/http-server/client/index.d.ts.map +1 -1
- package/build/src/http-server/client/index.js +74 -5
- package/build/src/http-server/client/response-compression.d.ts +71 -0
- package/build/src/http-server/client/response-compression.d.ts.map +1 -0
- package/build/src/http-server/client/response-compression.js +197 -0
- package/build/src/http-server/client/response.d.ts +27 -0
- package/build/src/http-server/client/response.d.ts.map +1 -1
- package/build/src/http-server/client/response.js +49 -1
- package/build/src/http-server/websocket-events-host.d.ts +28 -11
- package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
- package/build/src/http-server/websocket-events-host.js +84 -46
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/scripts/docker-run.sh +20 -0
- package/scripts/verify-docker-dev-environment.js +579 -0
- package/src/configuration-types.js +17 -0
- package/src/configuration.js +70 -0
- package/src/http-server/client/index.js +77 -4
- package/src/http-server/client/response-compression.js +224 -0
- package/src/http-server/client/response.js +57 -0
- package/src/http-server/websocket-events-host.js +91 -48
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Static contract verifier for the canonical Docker development environment.
|
|
4
|
+
*
|
|
5
|
+
* Dependency-free: checks the tracked Dockerfile, compose.yml, .env.example,
|
|
6
|
+
* .dockerignore, .gitignore, and scripts/docker-run.sh against the canonical
|
|
7
|
+
* development-environment contract, then runs negative probes derived from
|
|
8
|
+
* the real files to prove the checks actually catch regressions (for example
|
|
9
|
+
* a standalone project `npm install` or a `COPY` of project source).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
import {fileURLToPath} from "node:url"
|
|
15
|
+
|
|
16
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
17
|
+
|
|
18
|
+
const APPROVED_BASE_DIGEST = "sha256:3131b4cc82a783df6c9df078f86e01819a13594b865c2cad47bd1bca2b7063bb"
|
|
19
|
+
const PROVIDER_PACKAGES = ["@moonshot-ai/kimi-code", "@openai/codex", "@anthropic-ai/claude-code", "opencode-ai"]
|
|
20
|
+
const COMPOSE_FORBIDDEN_PATTERN = /hermes|kimi|threadwire|codex|claude|opencode/iu
|
|
21
|
+
|
|
22
|
+
// The complete universal apt coding/debugging baseline shared with the
|
|
23
|
+
// current awesome_tasks canonical image.
|
|
24
|
+
const BASELINE_APT_PACKAGES = [
|
|
25
|
+
"bash",
|
|
26
|
+
"ca-certificates",
|
|
27
|
+
"curl",
|
|
28
|
+
"wget",
|
|
29
|
+
"git",
|
|
30
|
+
"git-lfs",
|
|
31
|
+
"gh",
|
|
32
|
+
"openssh-client",
|
|
33
|
+
"gnupg",
|
|
34
|
+
"jq",
|
|
35
|
+
"ripgrep",
|
|
36
|
+
"fd-find",
|
|
37
|
+
"fzf",
|
|
38
|
+
"less",
|
|
39
|
+
"file",
|
|
40
|
+
"tree",
|
|
41
|
+
"bat",
|
|
42
|
+
"nano",
|
|
43
|
+
"vim-tiny",
|
|
44
|
+
"unzip",
|
|
45
|
+
"zip",
|
|
46
|
+
"xz-utils",
|
|
47
|
+
"bzip2",
|
|
48
|
+
"tar",
|
|
49
|
+
"gzip",
|
|
50
|
+
"rsync",
|
|
51
|
+
"patch",
|
|
52
|
+
"diffutils",
|
|
53
|
+
"gawk",
|
|
54
|
+
"findutils",
|
|
55
|
+
"coreutils",
|
|
56
|
+
"procps",
|
|
57
|
+
"psmisc",
|
|
58
|
+
"lsof",
|
|
59
|
+
"iproute2",
|
|
60
|
+
"iputils-ping",
|
|
61
|
+
"dnsutils",
|
|
62
|
+
"netcat-openbsd",
|
|
63
|
+
"socat",
|
|
64
|
+
"util-linux",
|
|
65
|
+
"tini",
|
|
66
|
+
"python3",
|
|
67
|
+
"python3-venv",
|
|
68
|
+
"python3-pip",
|
|
69
|
+
"sqlite3",
|
|
70
|
+
"shellcheck",
|
|
71
|
+
"tmux",
|
|
72
|
+
"zsh",
|
|
73
|
+
"man-db",
|
|
74
|
+
"build-essential",
|
|
75
|
+
"pkg-config",
|
|
76
|
+
"libssl-dev"
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Escapes a string for use inside a regular expression.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} value - The literal to escape.
|
|
83
|
+
* @returns {string} - The escaped literal.
|
|
84
|
+
*/
|
|
85
|
+
function escapeRegExp(value) {
|
|
86
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Checks whether an apt package name appears as a whole token.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} content - The Dockerfile content.
|
|
93
|
+
* @param {string} packageName - The apt package name.
|
|
94
|
+
* @returns {boolean} - True when the package name is present.
|
|
95
|
+
*/
|
|
96
|
+
function containsAptPackage(content, packageName) {
|
|
97
|
+
const pattern = new RegExp(`(?<![\\w+.-])${escapeRegExp(packageName)}(?![\\w+.-])`, "u")
|
|
98
|
+
|
|
99
|
+
return pattern.test(content)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Joins backslash line continuations so per-instruction checks see each
|
|
104
|
+
* logical Dockerfile instruction as a single line.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} content - The Dockerfile content.
|
|
107
|
+
* @returns {string[]} - The logical lines.
|
|
108
|
+
*/
|
|
109
|
+
function dockerfileLogicalLines(content) {
|
|
110
|
+
return content.replace(/\\\r?\n[ \t]*/g, " ").split("\n")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verifies canonical artifact names at the repository root.
|
|
115
|
+
*
|
|
116
|
+
* @param {string[]} entries - Root directory entries.
|
|
117
|
+
* @returns {string[]} - The problems found.
|
|
118
|
+
*/
|
|
119
|
+
function verifyRootEntries(entries) {
|
|
120
|
+
const problems = []
|
|
121
|
+
|
|
122
|
+
for (const required of ["Dockerfile", "compose.yml", ".env.example", ".dockerignore"]) {
|
|
123
|
+
if (!entries.includes(required)) {
|
|
124
|
+
problems.push(`Missing required canonical artifact: ${required}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const allowedDockerNames = new Set(["Dockerfile", "compose.yml", ".dockerignore"])
|
|
129
|
+
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (/dockerfile|compose/iu.test(entry) && !allowedDockerNames.has(entry)) {
|
|
132
|
+
problems.push(`Forbidden Dockerfile/Compose variant basename: ${entry}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return problems
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verifies the source-independent canonical Dockerfile.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} content - The Dockerfile content.
|
|
143
|
+
* @returns {string[]} - The problems found.
|
|
144
|
+
*/
|
|
145
|
+
function verifyDockerfile(content) {
|
|
146
|
+
const problems = []
|
|
147
|
+
|
|
148
|
+
if (!content.includes(`FROM ubuntu:26.04@${APPROVED_BASE_DIGEST}`)) {
|
|
149
|
+
problems.push(`Dockerfile must be based on ubuntu:26.04 pinned by the approved digest ${APPROVED_BASE_DIGEST}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (/^COPY\s/imu.test(content)) {
|
|
153
|
+
problems.push("Dockerfile must be source-independent: COPY instructions are forbidden")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const match of content.matchAll(/^ADD\s+(\S+)/gmu)) {
|
|
157
|
+
if (!match[1].startsWith("https://registry.npmjs.org/")) {
|
|
158
|
+
problems.push(`Dockerfile must be source-independent: ADD source is not remote registry metadata: ${match[1]}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const providerPackage of PROVIDER_PACKAGES) {
|
|
163
|
+
if (!content.includes(`https://registry.npmjs.org/${providerPackage}/latest`)) {
|
|
164
|
+
problems.push(`Missing remote latest-metadata cache key for provider package: ${providerPackage}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (/npm\s+ci/iu.test(content)) {
|
|
169
|
+
problems.push("Dockerfile must not install project dependencies: npm ci is forbidden")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const line of dockerfileLogicalLines(content)) {
|
|
173
|
+
if (/npm\s+install/iu.test(line) && !line.includes("--global")) {
|
|
174
|
+
problems.push(`Dockerfile must not install project dependencies: npm install without --global: ${line.trim()}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const providerPackage of PROVIDER_PACKAGES) {
|
|
179
|
+
if (!content.includes(`"${providerPackage}"`)) {
|
|
180
|
+
problems.push(`Missing bare unversioned provider CLI install spec: ${providerPackage}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (content.includes(`${providerPackage}@`)) {
|
|
184
|
+
problems.push(`Provider CLI install spec must stay bare and unversioned: ${providerPackage}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (/^ARG\s+\w*(KIMI|CODEX|CLAUDE|OPENCODE|PROVIDER)\w*/imu.test(content)) {
|
|
189
|
+
problems.push("Provider version ARGs/pins are forbidden in the Dockerfile")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!content.includes("--strict-allow-scripts")) {
|
|
193
|
+
problems.push("Missing --strict-allow-scripts lifecycle-script allowlisting")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!content.includes("--allow-scripts")) {
|
|
197
|
+
problems.push("Missing --allow-scripts lifecycle-script allowlisting")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const probe of ["kimi --version", "codex --version", "claude --version", "opencode --version"]) {
|
|
201
|
+
if (!content.includes(probe)) {
|
|
202
|
+
problems.push(`Missing provider CLI command probe: ${probe}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!content.includes("deb.nodesource.com/gpgkey/nodesource-repo.gpg.key")) {
|
|
207
|
+
problems.push("Missing signed NodeSource gpg key download")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!/NODESOURCE_KEY_SHA256=[0-9a-f]{64}/u.test(content)) {
|
|
211
|
+
problems.push("Missing pinned NodeSource key checksum (NODESOURCE_KEY_SHA256)")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!/sha256sum\s+--check/u.test(content)) {
|
|
215
|
+
problems.push("Missing sha256sum verification of the NodeSource key")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!content.includes("node_24.x")) {
|
|
219
|
+
problems.push("Missing NodeSource Node.js 24.x repository")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const aptPackage of BASELINE_APT_PACKAGES) {
|
|
223
|
+
if (!containsAptPackage(content, aptPackage)) {
|
|
224
|
+
problems.push(`Missing baseline apt package: ${aptPackage}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (/threadwire/iu.test(content)) {
|
|
229
|
+
problems.push("Threadwire must not be installed in the image: it stays parent orchestration")
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!/usermod\s+--login\s+dev/u.test(content)) {
|
|
233
|
+
problems.push("Dockerfile must rename Ubuntu's existing UID/GID 1000 identity to dev")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!/^USER\s+dev\s*$/mu.test(content)) {
|
|
237
|
+
problems.push("Dockerfile must run as USER dev (UID/GID 1000)")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!/^ENV\s+HOME=\/home\/dev\s*$/mu.test(content)) {
|
|
241
|
+
problems.push("Dockerfile must set ENV HOME=/home/dev")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!/^WORKDIR\s+\/home\/dev\/velocious\s*$/mu.test(content)) {
|
|
245
|
+
problems.push("Dockerfile must set WORKDIR /home/dev/velocious")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (/chown/iu.test(content)) {
|
|
249
|
+
problems.push("Dockerfile must not contain source ownership fixups (chown)")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return problems
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Extracts the service names declared under the top-level services key.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} content - The compose.yml content.
|
|
259
|
+
* @returns {string[] | null} - The service names, or null when no services key exists.
|
|
260
|
+
*/
|
|
261
|
+
function composeServiceNames(content) {
|
|
262
|
+
const lines = content.split("\n")
|
|
263
|
+
const servicesIndex = lines.findIndex((line) => /^services:\s*$/u.test(line))
|
|
264
|
+
|
|
265
|
+
if (servicesIndex == -1) return null
|
|
266
|
+
|
|
267
|
+
const names = []
|
|
268
|
+
|
|
269
|
+
for (let i = servicesIndex + 1; i < lines.length; i++) {
|
|
270
|
+
const line = lines[i]
|
|
271
|
+
|
|
272
|
+
if (/^\S/u.test(line)) break
|
|
273
|
+
|
|
274
|
+
const match = line.match(/^ {2}([^\s:]+):\s*$/u)
|
|
275
|
+
|
|
276
|
+
if (match) names.push(match[1])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return names
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Extracts the volume entries of the first service-level volumes block.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} content - The compose.yml content.
|
|
286
|
+
* @returns {string[]} - The volume entries.
|
|
287
|
+
*/
|
|
288
|
+
function composeServiceVolumes(content) {
|
|
289
|
+
const lines = content.split("\n")
|
|
290
|
+
const volumes = []
|
|
291
|
+
let volumesIndent = null
|
|
292
|
+
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
if (volumesIndent === null) {
|
|
295
|
+
const volumesMatch = line.match(/^(\s+)volumes:\s*$/u)
|
|
296
|
+
|
|
297
|
+
if (volumesMatch) volumesIndent = volumesMatch[1].length
|
|
298
|
+
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const entryMatch = line.match(/^(\s+)-\s+(.+?)\s*$/u)
|
|
303
|
+
|
|
304
|
+
if (entryMatch && entryMatch[1].length > volumesIndent) {
|
|
305
|
+
volumes.push(entryMatch[2])
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (line.trim() == "" || line.trimStart().startsWith("#")) continue
|
|
310
|
+
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return volumes
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Verifies the canonical compose.yml.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} content - The compose.yml content.
|
|
321
|
+
* @returns {string[]} - The problems found.
|
|
322
|
+
*/
|
|
323
|
+
function verifyCompose(content) {
|
|
324
|
+
const problems = []
|
|
325
|
+
|
|
326
|
+
if (!/^name:\s*velocious\s*$/mu.test(content)) {
|
|
327
|
+
problems.push("compose.yml must declare the standard Compose project name: velocious")
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (COMPOSE_FORBIDDEN_PATTERN.test(content)) {
|
|
331
|
+
problems.push(`compose.yml must not contain provider/orchestrator coupling (matched: ${content.match(COMPOSE_FORBIDDEN_PATTERN)?.[0]})`)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const serviceNames = composeServiceNames(content)
|
|
335
|
+
|
|
336
|
+
if (!serviceNames || serviceNames.length != 1 || serviceNames[0] != "dev") {
|
|
337
|
+
problems.push(`compose.yml must declare exactly one dev service (found: ${serviceNames ? serviceNames.join(", ") : "none"})`)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!/^\s+init:\s*true\s*$/mu.test(content)) {
|
|
341
|
+
problems.push("The dev service must set init: true")
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!/^\s+user:\s*"?1000:1000"?\s*$/mu.test(content)) {
|
|
345
|
+
problems.push("The dev service must run as user 1000:1000")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!/^\s+working_dir:\s*\/home\/dev\/velocious\s*$/mu.test(content)) {
|
|
349
|
+
problems.push("The dev service must set working_dir /home/dev/velocious")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const envLine of ["GH_CONFIG_DIR: /home/dev/.config/gh", "HOME: /home/dev", "NODE_ENV: development"]) {
|
|
353
|
+
if (!content.includes(envLine)) {
|
|
354
|
+
problems.push(`The dev service is missing environment entry: ${envLine}`)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const expectedVolumes = [
|
|
359
|
+
"${DEV_HOME_PATH:-/home/dev}:/home/dev",
|
|
360
|
+
"${GH_CONFIG_SOURCE_PATH:?Set GH_CONFIG_SOURCE_PATH in .env}:/home/dev/.config/gh:ro"
|
|
361
|
+
]
|
|
362
|
+
const actualVolumes = composeServiceVolumes(content)
|
|
363
|
+
|
|
364
|
+
if (actualVolumes.length != expectedVolumes.length || !expectedVolumes.every((volume) => actualVolumes.includes(volume))) {
|
|
365
|
+
problems.push(`The dev service must mount exactly the development-home and read-only GH config binds (found: ${actualVolumes.join(", ") || "none"})`)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!/^\s+command:\s*(\[\s*"sleep",\s*"infinity"\s*\]|sleep\s+infinity)\s*$/mu.test(content)) {
|
|
369
|
+
problems.push("The dev service must run sleep infinity")
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (/container_name:/u.test(content)) {
|
|
373
|
+
problems.push("The dev service must not declare a fixed container_name")
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (/^\s+ports:\s*$/mu.test(content)) {
|
|
377
|
+
problems.push("The dev service must not publish fixed host ports")
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (/^volumes:\s*$/mu.test(content)) {
|
|
381
|
+
problems.push("compose.yml must not declare top-level named volumes")
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return problems
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Verifies the portable .env.example.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} content - The .env.example content.
|
|
391
|
+
* @returns {string[]} - The problems found.
|
|
392
|
+
*/
|
|
393
|
+
function verifyEnvExample(content) {
|
|
394
|
+
const problems = []
|
|
395
|
+
|
|
396
|
+
if (!content.includes("GH_CONFIG_SOURCE_PATH")) {
|
|
397
|
+
problems.push(".env.example must document GH_CONFIG_SOURCE_PATH")
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!content.includes("DEV_HOME_PATH")) {
|
|
401
|
+
problems.push(".env.example must document DEV_HOME_PATH")
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return problems
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Verifies the .dockerignore exclusions.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} content - The .dockerignore content.
|
|
411
|
+
* @returns {string[]} - The problems found.
|
|
412
|
+
*/
|
|
413
|
+
function verifyDockerignore(content) {
|
|
414
|
+
const problems = []
|
|
415
|
+
|
|
416
|
+
for (const required of [".git", ".env", "!.env.example", "node_modules", "build", "coverage", "tmp", "log"]) {
|
|
417
|
+
if (!content.split("\n").some((line) => line.trim() == required || line.trim() == `**/${required}`)) {
|
|
418
|
+
problems.push(`.dockerignore must exclude ${required}`)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return problems
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Verifies that the root .env is gitignored.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} content - The .gitignore content.
|
|
429
|
+
* @returns {string[]} - The problems found.
|
|
430
|
+
*/
|
|
431
|
+
function verifyGitignore(content) {
|
|
432
|
+
if (!/^\/?\.env\s*$/mu.test(content)) {
|
|
433
|
+
return [".gitignore must ignore the root .env file"]
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return []
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Verifies the canonical scripts/docker-run.sh helper.
|
|
441
|
+
*
|
|
442
|
+
* @param {string} content - The docker-run.sh content.
|
|
443
|
+
* @returns {string[]} - The problems found.
|
|
444
|
+
*/
|
|
445
|
+
function verifyDockerRunSh(content) {
|
|
446
|
+
const problems = []
|
|
447
|
+
|
|
448
|
+
for (const required of ["DEV_HOME_PATH", "velocious", "compose.yml", "--project-directory", "--rm"]) {
|
|
449
|
+
if (!content.includes(required)) {
|
|
450
|
+
problems.push(`scripts/docker-run.sh must reference ${required}`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!/compose[^\n]*\brun\b/iu.test(content)) {
|
|
455
|
+
problems.push("scripts/docker-run.sh must execute commands through docker compose run")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (/threadwire/iu.test(content)) {
|
|
459
|
+
problems.push("scripts/docker-run.sh must not reference Threadwire: it stays parent orchestration")
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return problems
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Builds negative probes derived from the real files. Each probe mutates the
|
|
467
|
+
* real content with a regression and must be caught by the matching verifier.
|
|
468
|
+
*
|
|
469
|
+
* @param {{dockerfile: string, compose: string}} contents - The real file contents.
|
|
470
|
+
* @returns {{name: string, problems: string[], expected: string}[]} - The probes.
|
|
471
|
+
*/
|
|
472
|
+
function negativeProbes(contents) {
|
|
473
|
+
return [
|
|
474
|
+
{
|
|
475
|
+
name: "Dockerfile COPY project source regression",
|
|
476
|
+
problems: verifyDockerfile(`${contents.dockerfile}\nCOPY package.json ./\n`),
|
|
477
|
+
expected: "COPY"
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
name: "Dockerfile standalone project npm ci regression",
|
|
481
|
+
problems: verifyDockerfile(`${contents.dockerfile}\nRUN npm ci\n`),
|
|
482
|
+
expected: "npm ci"
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: "Dockerfile multiline project npm install regression",
|
|
486
|
+
problems: verifyDockerfile(`${contents.dockerfile}\nRUN npm \\\n install left-pad\n`),
|
|
487
|
+
expected: "npm install"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: "Dockerfile unapproved base digest regression",
|
|
491
|
+
problems: verifyDockerfile(contents.dockerfile.replaceAll(APPROVED_BASE_DIGEST, `sha256:${"0".repeat(64)}`)),
|
|
492
|
+
expected: "digest"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: "compose fixed container_name regression",
|
|
496
|
+
problems: verifyCompose(contents.compose.replace(" init: true", " container_name: velocious-dev\n init: true")),
|
|
497
|
+
expected: "container_name"
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
name: "compose provider credential mount regression",
|
|
501
|
+
problems: verifyCompose(contents.compose.replace(" command:", " - ${HOME}/.kimi:/home/dev/.kimi\n command:")),
|
|
502
|
+
expected: "kimi"
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
name: "compose second service regression",
|
|
506
|
+
problems: verifyCompose(`${contents.compose}\n worker:\n image: ubuntu\n`),
|
|
507
|
+
expected: "dev"
|
|
508
|
+
}
|
|
509
|
+
]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Runs the full repository verification.
|
|
514
|
+
*
|
|
515
|
+
* @returns {number} - The process exit code.
|
|
516
|
+
*/
|
|
517
|
+
function main() {
|
|
518
|
+
const problems = []
|
|
519
|
+
|
|
520
|
+
problems.push(...verifyRootEntries(fs.readdirSync(repoRoot)))
|
|
521
|
+
|
|
522
|
+
const dockerfilePath = path.join(repoRoot, "Dockerfile")
|
|
523
|
+
const composePath = path.join(repoRoot, "compose.yml")
|
|
524
|
+
const dockerfile = fs.existsSync(dockerfilePath) ? fs.readFileSync(dockerfilePath, "utf8") : null
|
|
525
|
+
const compose = fs.existsSync(composePath) ? fs.readFileSync(composePath, "utf8") : null
|
|
526
|
+
|
|
527
|
+
if (dockerfile !== null) problems.push(...verifyDockerfile(dockerfile))
|
|
528
|
+
if (compose !== null) problems.push(...verifyCompose(compose))
|
|
529
|
+
|
|
530
|
+
for (const [relativePath, verify] of [
|
|
531
|
+
[".env.example", verifyEnvExample],
|
|
532
|
+
[".dockerignore", verifyDockerignore],
|
|
533
|
+
[".gitignore", verifyGitignore],
|
|
534
|
+
[path.join("scripts", "docker-run.sh"), verifyDockerRunSh]
|
|
535
|
+
]) {
|
|
536
|
+
const absolutePath = path.join(repoRoot, relativePath)
|
|
537
|
+
|
|
538
|
+
if (!fs.existsSync(absolutePath)) {
|
|
539
|
+
problems.push(`Missing required file: ${relativePath}`)
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
problems.push(...verify(fs.readFileSync(absolutePath, "utf8")))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const dockerRunPath = path.join(repoRoot, "scripts", "docker-run.sh")
|
|
547
|
+
|
|
548
|
+
if (fs.existsSync(dockerRunPath) && process.platform != "win32") {
|
|
549
|
+
const mode = fs.statSync(dockerRunPath).mode
|
|
550
|
+
|
|
551
|
+
if ((mode & 0o111) == 0) {
|
|
552
|
+
problems.push("scripts/docker-run.sh must be executable")
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (dockerfile !== null && compose !== null) {
|
|
557
|
+
for (const probe of negativeProbes({dockerfile, compose})) {
|
|
558
|
+
if (!probe.problems.some((problem) => problem.toLowerCase().includes(probe.expected.toLowerCase()))) {
|
|
559
|
+
problems.push(`Negative probe was not caught by the verifier: ${probe.name}`)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (problems.length > 0) {
|
|
565
|
+
console.error("Docker development environment verification failed:")
|
|
566
|
+
|
|
567
|
+
for (const problem of problems) {
|
|
568
|
+
console.error(`- ${problem}`)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return 1
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
console.log("Docker development environment verification passed (contract checks and negative probes).")
|
|
575
|
+
|
|
576
|
+
return 0
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
process.exitCode = main()
|
|
@@ -249,8 +249,25 @@
|
|
|
249
249
|
* @property {number} [unreachableReportMs] - Grace window (ms) a beacon connect/disconnect blip must persist before it is reported as a framework-error. Transient outages that recover within this window (e.g. a deploy restarting the broker) are not reported. Defaults to 30000.
|
|
250
250
|
*/
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* @typedef {object} HttpCompressionConfiguration
|
|
254
|
+
* @property {boolean} [enabled] - Whether buffered response compression is enabled. Defaults to true; set false to disable globally.
|
|
255
|
+
* @property {number} [threshold] - Minimum buffered body size in bytes before compression is applied. Defaults to 1024.
|
|
256
|
+
* @property {number} [brotliQuality] - Brotli encoder quality (0-11). Defaults to 4.
|
|
257
|
+
* @property {number} [gzipLevel] - Gzip compression level (0-9). Defaults to 6.
|
|
258
|
+
*/
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @typedef {object} NormalizedHttpCompressionConfiguration
|
|
262
|
+
* @property {boolean} enabled - Whether buffered HTTP response compression is enabled.
|
|
263
|
+
* @property {number} threshold - Minimum buffered body size in bytes before compression is applied.
|
|
264
|
+
* @property {number} brotliQuality - Brotli encoder quality (0-11).
|
|
265
|
+
* @property {number} gzipLevel - Gzip compression level (0-9).
|
|
266
|
+
*/
|
|
267
|
+
|
|
252
268
|
/**
|
|
253
269
|
* @typedef {object} HttpServerConfiguration
|
|
270
|
+
* @property {boolean | HttpCompressionConfiguration} [compression] - Buffered response compression. Enabled with documented defaults when absent; false or {enabled: false} disables it globally.
|
|
254
271
|
* @property {string} [host] - Hostname to bind the HTTP server to.
|
|
255
272
|
* @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
|
|
256
273
|
* @property {number} [maxWorkers] - Backward-compatible alias for workers.
|
package/src/configuration.js
CHANGED
|
@@ -120,6 +120,10 @@ const DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES = 256
|
|
|
120
120
|
const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_BYTES = 16 * 1024 * 1024
|
|
121
121
|
const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_FRAMES = 256
|
|
122
122
|
|
|
123
|
+
const DEFAULT_COMPRESSION_THRESHOLD = 1024
|
|
124
|
+
const DEFAULT_COMPRESSION_BROTLI_QUALITY = 4
|
|
125
|
+
const DEFAULT_COMPRESSION_GZIP_LEVEL = 6
|
|
126
|
+
|
|
123
127
|
/**
|
|
124
128
|
* Validates a positive safe integer configuration value.
|
|
125
129
|
* @param {?} value - Configured positive safe integer.
|
|
@@ -136,6 +140,63 @@ function positiveSafeInteger(value, name, defaultValue) {
|
|
|
136
140
|
return value
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Validates an integer configuration value inside an inclusive range.
|
|
145
|
+
* @param {?} value - Configured integer.
|
|
146
|
+
* @param {string} name - Configuration key.
|
|
147
|
+
* @param {number} min - Minimum accepted value (inclusive).
|
|
148
|
+
* @param {number} max - Maximum accepted value (inclusive).
|
|
149
|
+
* @param {number} defaultValue - Default value.
|
|
150
|
+
* @returns {number} - Validated configured or default value.
|
|
151
|
+
*/
|
|
152
|
+
function integerInRange(value, name, min, max, defaultValue) {
|
|
153
|
+
if (value === undefined) return defaultValue
|
|
154
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
155
|
+
throw new TypeError(`${name} must be an integer between ${min} and ${max}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return value
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalizes the buffered HTTP response compression configuration. Compression is
|
|
163
|
+
* enabled by default when the setting is absent; `false` or `{enabled: false}`
|
|
164
|
+
* disables it globally.
|
|
165
|
+
* @param {boolean | import("./configuration-types.js").HttpCompressionConfiguration | undefined} value - Configured compression value.
|
|
166
|
+
* @returns {import("./configuration-types.js").NormalizedHttpCompressionConfiguration} - Normalized compression configuration.
|
|
167
|
+
*/
|
|
168
|
+
function normalizeHttpCompression(value) {
|
|
169
|
+
if (value === undefined || value === true) {
|
|
170
|
+
return {enabled: true, threshold: DEFAULT_COMPRESSION_THRESHOLD, brotliQuality: DEFAULT_COMPRESSION_BROTLI_QUALITY, gzipLevel: DEFAULT_COMPRESSION_GZIP_LEVEL}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value === false) {
|
|
174
|
+
return {enabled: false, threshold: DEFAULT_COMPRESSION_THRESHOLD, brotliQuality: DEFAULT_COMPRESSION_BROTLI_QUALITY, gzipLevel: DEFAULT_COMPRESSION_GZIP_LEVEL}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
178
|
+
throw new TypeError(`httpServer.compression must be a boolean or an object, got: ${String(value)}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const {brotliQuality, enabled, gzipLevel, threshold, ...restCompression} = value
|
|
182
|
+
const restCompressionKeys = Object.keys(restCompression)
|
|
183
|
+
|
|
184
|
+
if (restCompressionKeys.length > 0) {
|
|
185
|
+
throw new TypeError(`httpServer.compression received unknown keys: ${restCompressionKeys.join(", ")} (supported: brotliQuality, enabled, gzipLevel, threshold)`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
189
|
+
throw new TypeError(`httpServer.compression.enabled must be a boolean, got: ${String(enabled)}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
enabled: enabled ?? true,
|
|
194
|
+
threshold: positiveSafeInteger(threshold, "httpServer.compression.threshold", DEFAULT_COMPRESSION_THRESHOLD),
|
|
195
|
+
brotliQuality: integerInRange(brotliQuality, "httpServer.compression.brotliQuality", 0, 11, DEFAULT_COMPRESSION_BROTLI_QUALITY),
|
|
196
|
+
gzipLevel: integerInRange(gzipLevel, "httpServer.compression.gzipLevel", 0, 9, DEFAULT_COMPRESSION_GZIP_LEVEL)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
139
200
|
export default class VelociousConfiguration {
|
|
140
201
|
/**
|
|
141
202
|
* Close database connections promise.
|
|
@@ -236,6 +297,7 @@ export default class VelociousConfiguration {
|
|
|
236
297
|
|
|
237
298
|
this.httpServer = {
|
|
238
299
|
...(httpServer || {}),
|
|
300
|
+
compression: normalizeHttpCompression(httpServer?.compression),
|
|
239
301
|
websocketInboundQueue: {
|
|
240
302
|
maxPendingBytes: positiveSafeInteger(websocketInboundQueue?.maxPendingBytes, "httpServer.websocketInboundQueue.maxPendingBytes", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_BYTES),
|
|
241
303
|
maxPendingMessages: positiveSafeInteger(websocketInboundQueue?.maxPendingMessages, "httpServer.websocketInboundQueue.maxPendingMessages", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES)
|
|
@@ -500,6 +562,14 @@ export default class VelociousConfiguration {
|
|
|
500
562
|
return this.cors
|
|
501
563
|
}
|
|
502
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Runs get http server compression.
|
|
567
|
+
* @returns {import("./configuration-types.js").NormalizedHttpCompressionConfiguration} - Normalized buffered response compression configuration.
|
|
568
|
+
*/
|
|
569
|
+
getHttpServerCompression() {
|
|
570
|
+
return this.httpServer.compression
|
|
571
|
+
}
|
|
572
|
+
|
|
503
573
|
/**
|
|
504
574
|
* Runs get cookie secret.
|
|
505
575
|
* @returns {string | undefined} - Cookie secret.
|