@zooid/runtime-docker 0.11.2 → 0.12.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/docker/agent-claude-code/Dockerfile +4 -1
- package/docker/agent-codex/Dockerfile +4 -1
- package/docker/agent-opencode/Dockerfile +4 -1
- package/docker/agent-pi/Dockerfile +17 -0
- package/docker/versions.json +15 -0
- package/package.json +3 -2
- package/src/image-versions-cli.ts +87 -0
- package/src/image-versions.manifest.test.ts +57 -0
- package/src/image-versions.test.ts +212 -0
- package/src/image-versions.ts +90 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
FROM ghcr.io/zooid-ai/agent-base:latest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ZOD074: the ARG default is the pin. docker/versions.json maps this ARG to its
|
|
4
|
+
# npm package; image-versions.manifest.test.ts fails if the two disagree.
|
|
5
|
+
ARG CLAUDE_AGENT_ACP_VERSION=0.74.0
|
|
6
|
+
RUN npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION}
|
|
4
7
|
|
|
5
8
|
# No CMD — DockerAcpRuntime sets the entrypoint to the resolved ACP shim
|
|
6
9
|
# command (e.g. `npx @agentclientprotocol/claude-agent-acp`). The npm install
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
FROM ghcr.io/zooid-ai/agent-base:latest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ZOD074: the ARG default is the pin. docker/versions.json maps this ARG to its
|
|
4
|
+
# npm package; image-versions.manifest.test.ts fails if the two disagree.
|
|
5
|
+
ARG CODEX_ACP_VERSION=0.16.0
|
|
6
|
+
RUN npm install -g @zed-industries/codex-acp@${CODEX_ACP_VERSION}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
FROM ghcr.io/zooid-ai/agent-base:latest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ZOD074: the ARG default is the pin. docker/versions.json maps this ARG to its
|
|
4
|
+
# npm package; image-versions.manifest.test.ts fails if the two disagree.
|
|
5
|
+
ARG OPENCODE_VERSION=1.18.27
|
|
6
|
+
RUN npm install -g opencode-ai@${OPENCODE_VERSION}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
FROM ghcr.io/zooid-ai/agent-base:latest
|
|
2
|
+
|
|
3
|
+
# pi-acp is an adapter: it spawns `pi --mode rpc` as a child process, so BOTH
|
|
4
|
+
# packages must be on PATH. Installing only pi-acp yields an image that starts,
|
|
5
|
+
# completes the ACP handshake, and then fails on the first turn.
|
|
6
|
+
#
|
|
7
|
+
# ZOD073 pins these inline, unlike the three older agent images, which install
|
|
8
|
+
# unpinned and therefore freeze at whatever was current when someone last ran
|
|
9
|
+
# the publish workflow by hand. [[ZOD074]] hoists all four into
|
|
10
|
+
# docker/versions.json and automates the refresh.
|
|
11
|
+
ARG PI_VERSION=0.85.0
|
|
12
|
+
ARG PI_ACP_VERSION=0.0.33
|
|
13
|
+
RUN npm install -g \
|
|
14
|
+
@earendil-works/pi-coding-agent@${PI_VERSION} \
|
|
15
|
+
pi-acp@${PI_ACP_VERSION}
|
|
16
|
+
|
|
17
|
+
# No CMD — DockerAcpRuntime sets the entrypoint to the resolved ACP shim command.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"agent-claude-code": {
|
|
3
|
+
"CLAUDE_AGENT_ACP_VERSION": "@agentclientprotocol/claude-agent-acp"
|
|
4
|
+
},
|
|
5
|
+
"agent-codex": {
|
|
6
|
+
"CODEX_ACP_VERSION": "@zed-industries/codex-acp"
|
|
7
|
+
},
|
|
8
|
+
"agent-opencode": {
|
|
9
|
+
"OPENCODE_VERSION": "opencode-ai"
|
|
10
|
+
},
|
|
11
|
+
"agent-pi": {
|
|
12
|
+
"PI_VERSION": "@earendil-works/pi-coding-agent",
|
|
13
|
+
"PI_ACP_VERSION": "pi-acp"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/runtime-docker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Docker runtime for zooid. Spawns the agent CLI inside a container with a workspace mount and an env allowlist.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,11 +25,12 @@
|
|
|
25
25
|
"node": ">=22"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@zooid/core": "^0.
|
|
28
|
+
"@zooid/core": "^0.12.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^22.0.0",
|
|
32
32
|
"tsup": "^8.5.1",
|
|
33
|
+
"tsx": "^4.21.0",
|
|
33
34
|
"typescript": "^5.5.0",
|
|
34
35
|
"vitest": "^3.2.0"
|
|
35
36
|
},
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ZOD074. `pnpm agent-images:check [--write]`
|
|
3
|
+
//
|
|
4
|
+
// Reads docker/versions.json + the Dockerfiles, asks the npm registry what is
|
|
5
|
+
// current, and reports (or rewrites) stale pins. Exits 1 when anything is out
|
|
6
|
+
// of date so the workflow can branch on it.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join, dirname } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import {
|
|
12
|
+
collectPins,
|
|
13
|
+
findOutdated,
|
|
14
|
+
applyPin,
|
|
15
|
+
npmLatestUrl,
|
|
16
|
+
type ImageManifest,
|
|
17
|
+
} from './image-versions.js'
|
|
18
|
+
|
|
19
|
+
const DOCKER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docker')
|
|
20
|
+
const dockerfilePath = (dir: string) => join(DOCKER_DIR, dir, 'Dockerfile')
|
|
21
|
+
|
|
22
|
+
async function fetchLatest(packageName: string): Promise<string | undefined> {
|
|
23
|
+
const res = await fetch(npmLatestUrl(packageName), {
|
|
24
|
+
headers: { accept: 'application/json' },
|
|
25
|
+
})
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
console.error(` ! ${packageName}: registry returned ${res.status}`)
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
return ((await res.json()) as { version?: string }).version
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main(): Promise<number> {
|
|
34
|
+
const write = process.argv.includes('--write')
|
|
35
|
+
|
|
36
|
+
const manifest: ImageManifest = JSON.parse(
|
|
37
|
+
readFileSync(join(DOCKER_DIR, 'versions.json'), 'utf8'),
|
|
38
|
+
)
|
|
39
|
+
const dockerfiles = Object.fromEntries(
|
|
40
|
+
Object.keys(manifest).map((dir) => [dir, readFileSync(dockerfilePath(dir), 'utf8')]),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Throws on drift — a bad manifest is a hard stop, not a warning.
|
|
44
|
+
const pins = collectPins(manifest, dockerfiles)
|
|
45
|
+
|
|
46
|
+
const names = [...new Set(pins.map((p) => p.packageName))]
|
|
47
|
+
const resolved = await Promise.all(names.map(fetchLatest))
|
|
48
|
+
const latest: Record<string, string> = {}
|
|
49
|
+
names.forEach((n, i) => {
|
|
50
|
+
const v = resolved[i]
|
|
51
|
+
if (v) latest[n] = v
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const outdated = findOutdated(pins, latest)
|
|
55
|
+
if (outdated.length === 0) {
|
|
56
|
+
console.log(`All ${pins.length} agent-image pins are current.`)
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const p of outdated) {
|
|
61
|
+
console.log(`${p.imageDir}: ${p.packageName} ${p.version} -> ${p.latest}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (write) {
|
|
65
|
+
// Group by file so an image with two pins is written once.
|
|
66
|
+
const byDir = new Map<string, typeof outdated>()
|
|
67
|
+
for (const p of outdated) {
|
|
68
|
+
byDir.set(p.imageDir, [...(byDir.get(p.imageDir) ?? []), p])
|
|
69
|
+
}
|
|
70
|
+
for (const [dir, ps] of byDir) {
|
|
71
|
+
let text = dockerfiles[dir]
|
|
72
|
+
for (const p of ps) text = applyPin(text, p.argName, p.latest)
|
|
73
|
+
writeFileSync(dockerfilePath(dir), text)
|
|
74
|
+
console.log(` wrote ${dir}/Dockerfile`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().then(
|
|
82
|
+
(code) => process.exit(code),
|
|
83
|
+
(err) => {
|
|
84
|
+
console.error(err instanceof Error ? err.message : err)
|
|
85
|
+
process.exit(2)
|
|
86
|
+
},
|
|
87
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { collectPins, type ImageManifest } from './image-versions.js'
|
|
6
|
+
|
|
7
|
+
const DOCKER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docker')
|
|
8
|
+
|
|
9
|
+
function loadManifest(): ImageManifest {
|
|
10
|
+
return JSON.parse(readFileSync(join(DOCKER_DIR, 'versions.json'), 'utf8'))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function loadDockerfiles(manifest: ImageManifest): Record<string, string> {
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
Object.keys(manifest).map((dir) => [
|
|
16
|
+
dir,
|
|
17
|
+
readFileSync(join(DOCKER_DIR, dir, 'Dockerfile'), 'utf8'),
|
|
18
|
+
]),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('docker/versions.json ⇄ Dockerfiles (ZOD074)', () => {
|
|
23
|
+
it('collectPins accepts the committed tree — no drift in either direction', () => {
|
|
24
|
+
const manifest = loadManifest()
|
|
25
|
+
expect(() => collectPins(manifest, loadDockerfiles(manifest))).not.toThrow()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('every pin resolves to a non-empty version', () => {
|
|
29
|
+
const manifest = loadManifest()
|
|
30
|
+
for (const pin of collectPins(manifest, loadDockerfiles(manifest))) {
|
|
31
|
+
expect(pin.version, `${pin.imageDir}/${pin.argName}`).toMatch(/^\d+\.\d+\.\d+/)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Catches the other forgetting: a new agent-* image dir that pins packages
|
|
36
|
+
// but never gets a manifest entry, so the bumper never sees it.
|
|
37
|
+
it('every agent-* image dir with a *_VERSION ARG is in the manifest', () => {
|
|
38
|
+
const manifest = loadManifest()
|
|
39
|
+
const dirs = readdirSync(DOCKER_DIR, { withFileTypes: true })
|
|
40
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('agent-'))
|
|
41
|
+
.map((e) => e.name)
|
|
42
|
+
.filter((d) => existsSync(join(DOCKER_DIR, d, 'Dockerfile')))
|
|
43
|
+
|
|
44
|
+
for (const dir of dirs) {
|
|
45
|
+
const text = readFileSync(join(DOCKER_DIR, dir, 'Dockerfile'), 'utf8')
|
|
46
|
+
if (/^ARG\s+\w*_VERSION=/m.test(text)) {
|
|
47
|
+
expect(Object.keys(manifest), `${dir} pins versions but is not in versions.json`)
|
|
48
|
+
.toContain(dir)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// agent-base installs from apt, not npm — it must stay out (see Non-goals).
|
|
54
|
+
it('does not claim agent-base', () => {
|
|
55
|
+
expect(Object.keys(loadManifest())).not.toContain('agent-base')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseDockerfileArgs,
|
|
4
|
+
collectPins,
|
|
5
|
+
findOutdated,
|
|
6
|
+
applyPin,
|
|
7
|
+
npmLatestUrl,
|
|
8
|
+
type ImageManifest,
|
|
9
|
+
} from './image-versions.js'
|
|
10
|
+
|
|
11
|
+
const OPENCODE_DOCKERFILE = `FROM ghcr.io/zooid-ai/agent-base:latest
|
|
12
|
+
|
|
13
|
+
ARG OPENCODE_VERSION=1.18.27
|
|
14
|
+
RUN npm install -g opencode-ai@\${OPENCODE_VERSION}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
const PI_DOCKERFILE = `FROM ghcr.io/zooid-ai/agent-base:latest
|
|
18
|
+
|
|
19
|
+
ARG PI_VERSION=0.85.0
|
|
20
|
+
ARG PI_ACP_VERSION=0.0.33
|
|
21
|
+
RUN npm install -g \\
|
|
22
|
+
@earendil-works/pi-coding-agent@\${PI_VERSION} \\
|
|
23
|
+
pi-acp@\${PI_ACP_VERSION}
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
describe('parseDockerfileArgs', () => {
|
|
27
|
+
it('reads ARG NAME=value pairs', () => {
|
|
28
|
+
expect(parseDockerfileArgs(OPENCODE_DOCKERFILE)).toEqual({
|
|
29
|
+
OPENCODE_VERSION: '1.18.27',
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reads several ARGs from one file', () => {
|
|
34
|
+
expect(parseDockerfileArgs(PI_DOCKERFILE)).toEqual({
|
|
35
|
+
PI_VERSION: '0.85.0',
|
|
36
|
+
PI_ACP_VERSION: '0.0.33',
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('tolerates extra whitespace', () => {
|
|
41
|
+
expect(parseDockerfileArgs('ARG FOO_VERSION=1.2.3 \n')).toEqual({
|
|
42
|
+
FOO_VERSION: '1.2.3',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('records a defaultless ARG as an empty string (collectPins rejects it)', () => {
|
|
47
|
+
expect(parseDockerfileArgs('ARG BARE_ARG\n')).toEqual({ BARE_ARG: '' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('ignores ARG-like text inside a comment', () => {
|
|
51
|
+
expect(parseDockerfileArgs('# ARG COMMENTED_VERSION=9.9.9\n')).toEqual({})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns an empty object for a Dockerfile with no ARGs', () => {
|
|
55
|
+
expect(parseDockerfileArgs('FROM node:22-slim\nRUN echo hi\n')).toEqual({})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('collectPins', () => {
|
|
60
|
+
const manifest: ImageManifest = {
|
|
61
|
+
'agent-opencode': { OPENCODE_VERSION: 'opencode-ai' },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('joins manifest ARG names to Dockerfile ARG defaults', () => {
|
|
65
|
+
expect(collectPins(manifest, { 'agent-opencode': OPENCODE_DOCKERFILE })).toEqual([
|
|
66
|
+
{
|
|
67
|
+
imageDir: 'agent-opencode',
|
|
68
|
+
argName: 'OPENCODE_VERSION',
|
|
69
|
+
packageName: 'opencode-ai',
|
|
70
|
+
version: '1.18.27',
|
|
71
|
+
},
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('flattens several images into one list', () => {
|
|
76
|
+
const pins = collectPins(
|
|
77
|
+
{
|
|
78
|
+
'agent-opencode': { OPENCODE_VERSION: 'opencode-ai' },
|
|
79
|
+
'agent-pi': {
|
|
80
|
+
PI_VERSION: '@earendil-works/pi-coding-agent',
|
|
81
|
+
PI_ACP_VERSION: 'pi-acp',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{ 'agent-opencode': OPENCODE_DOCKERFILE, 'agent-pi': PI_DOCKERFILE },
|
|
85
|
+
)
|
|
86
|
+
expect(pins).toHaveLength(3)
|
|
87
|
+
expect(pins.map((p) => p.packageName).sort()).toEqual([
|
|
88
|
+
'@earendil-works/pi-coding-agent',
|
|
89
|
+
'opencode-ai',
|
|
90
|
+
'pi-acp',
|
|
91
|
+
])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Drift, direction 1: the manifest claims an ARG the Dockerfile does not have.
|
|
95
|
+
it('throws when the manifest names an ARG the Dockerfile lacks', () => {
|
|
96
|
+
expect(() =>
|
|
97
|
+
collectPins(
|
|
98
|
+
{ 'agent-opencode': { MISSING_VERSION: 'opencode-ai' } },
|
|
99
|
+
{ 'agent-opencode': OPENCODE_DOCKERFILE },
|
|
100
|
+
),
|
|
101
|
+
).toThrow(/MISSING_VERSION/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Drift, direction 2: a version-bearing ARG nobody claims. This is the one
|
|
105
|
+
// that matters — it catches adding a pin and forgetting versions.json, which
|
|
106
|
+
// would leave the bumper silently ignoring it forever.
|
|
107
|
+
it('throws when a *_VERSION ARG is absent from the manifest', () => {
|
|
108
|
+
expect(() =>
|
|
109
|
+
collectPins({ 'agent-pi': { PI_VERSION: '@earendil-works/pi-coding-agent' } }, {
|
|
110
|
+
'agent-pi': PI_DOCKERFILE,
|
|
111
|
+
}),
|
|
112
|
+
).toThrow(/PI_ACP_VERSION/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('throws when the manifest names an image with no Dockerfile', () => {
|
|
116
|
+
expect(() => collectPins({ 'agent-ghost': { X_VERSION: 'x' } }, {})).toThrow(
|
|
117
|
+
/agent-ghost/,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('throws when a manifest ARG has no default value', () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
collectPins({ 'agent-x': { BARE_ARG: 'x' } }, { 'agent-x': 'ARG BARE_ARG\n' }),
|
|
124
|
+
).toThrow(/BARE_ARG/)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('findOutdated', () => {
|
|
129
|
+
const pins = [
|
|
130
|
+
{
|
|
131
|
+
imageDir: 'agent-opencode',
|
|
132
|
+
argName: 'OPENCODE_VERSION',
|
|
133
|
+
packageName: 'opencode-ai',
|
|
134
|
+
version: '1.15.7',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
imageDir: 'agent-pi',
|
|
138
|
+
argName: 'PI_ACP_VERSION',
|
|
139
|
+
packageName: 'pi-acp',
|
|
140
|
+
version: '0.0.33',
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
it('reports pins whose version differs from latest', () => {
|
|
145
|
+
const out = findOutdated(pins, { 'opencode-ai': '1.18.27', 'pi-acp': '0.0.33' })
|
|
146
|
+
expect(out).toEqual([{ ...pins[0], latest: '1.18.27' }])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('reports nothing when every pin matches', () => {
|
|
150
|
+
expect(findOutdated(pins, { 'opencode-ai': '1.15.7', 'pi-acp': '0.0.33' })).toEqual(
|
|
151
|
+
[],
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Inequality, not semver ordering: the registry's `latest` dist-tag is
|
|
156
|
+
// authoritative, including when upstream yanks and goes backwards.
|
|
157
|
+
it('treats a lower latest as outdated too', () => {
|
|
158
|
+
const out = findOutdated([pins[0]], { 'opencode-ai': '1.15.6' })
|
|
159
|
+
expect(out).toHaveLength(1)
|
|
160
|
+
expect(out[0].latest).toBe('1.15.6')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('skips packages with no lookup result rather than guessing', () => {
|
|
164
|
+
expect(findOutdated(pins, {})).toEqual([])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('applyPin', () => {
|
|
169
|
+
it('rewrites the ARG default in place', () => {
|
|
170
|
+
const next = applyPin(OPENCODE_DOCKERFILE, 'OPENCODE_VERSION', '1.18.27')
|
|
171
|
+
expect(next).toContain('ARG OPENCODE_VERSION=1.18.27')
|
|
172
|
+
expect(next).not.toContain('1.15.7')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('leaves the rest of the file byte-identical', () => {
|
|
176
|
+
const next = applyPin(OPENCODE_DOCKERFILE, 'OPENCODE_VERSION', '2.0.0')
|
|
177
|
+
expect(next.split('\n').length).toBe(OPENCODE_DOCKERFILE.split('\n').length)
|
|
178
|
+
expect(next).toContain('RUN npm install -g opencode-ai@${OPENCODE_VERSION}')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('rewrites only the named ARG', () => {
|
|
182
|
+
const next = applyPin(PI_DOCKERFILE, 'PI_ACP_VERSION', '0.1.0')
|
|
183
|
+
expect(next).toContain('ARG PI_VERSION=0.85.0')
|
|
184
|
+
expect(next).toContain('ARG PI_ACP_VERSION=0.1.0')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('throws when the ARG is not present', () => {
|
|
188
|
+
expect(() => applyPin(OPENCODE_DOCKERFILE, 'NOPE_VERSION', '1.0.0')).toThrow(
|
|
189
|
+
/NOPE_VERSION/,
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('npmLatestUrl', () => {
|
|
195
|
+
it('builds the latest-dist-tag URL for an unscoped package', () => {
|
|
196
|
+
expect(npmLatestUrl('opencode-ai')).toBe(
|
|
197
|
+
'https://registry.npmjs.org/opencode-ai/latest',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// The slash in a scoped name must be percent-encoded or the registry 404s
|
|
202
|
+
// silently — the single easiest thing to get wrong here.
|
|
203
|
+
it('percent-encodes the slash in a scoped package', () => {
|
|
204
|
+
expect(npmLatestUrl('@zed-industries/codex-acp')).toBe(
|
|
205
|
+
'https://registry.npmjs.org/@zed-industries%2Fcodex-acp/latest',
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('does not encode the leading @', () => {
|
|
210
|
+
expect(npmLatestUrl('@scope/pkg')).toContain('/@scope%2Fpkg/')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// ZOD074. The Dockerfile ARG default IS the pin; docker/versions.json maps each
|
|
2
|
+
// ARG name to the npm package it pins (which the Dockerfile only expresses in a
|
|
3
|
+
// shell line). collectPins joins the two and refuses to proceed if they disagree.
|
|
4
|
+
|
|
5
|
+
export interface ImageManifest {
|
|
6
|
+
/** image dir (e.g. "agent-opencode") -> ARG name -> npm package name */
|
|
7
|
+
[imageDir: string]: { [argName: string]: string }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Pin {
|
|
11
|
+
imageDir: string
|
|
12
|
+
argName: string
|
|
13
|
+
packageName: string
|
|
14
|
+
version: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OutdatedPin extends Pin {
|
|
18
|
+
latest: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ARG_LINE = /^[ \t]*ARG[ \t]+([A-Za-z_][A-Za-z0-9_]*)(?:[ \t]*=[ \t]*(.*?))?[ \t]*$/
|
|
22
|
+
|
|
23
|
+
export function parseDockerfileArgs(text: string): Record<string, string> {
|
|
24
|
+
const args: Record<string, string> = {}
|
|
25
|
+
for (const line of text.split('\n')) {
|
|
26
|
+
const m = ARG_LINE.exec(line)
|
|
27
|
+
if (m) args[m[1]] = m[2] ?? ''
|
|
28
|
+
}
|
|
29
|
+
return args
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collectPins(
|
|
33
|
+
manifest: ImageManifest,
|
|
34
|
+
dockerfiles: Record<string, string>,
|
|
35
|
+
): Pin[] {
|
|
36
|
+
const pins: Pin[] = []
|
|
37
|
+
for (const [imageDir, argMap] of Object.entries(manifest)) {
|
|
38
|
+
const text = dockerfiles[imageDir]
|
|
39
|
+
if (text === undefined) {
|
|
40
|
+
throw new Error(`versions.json names "${imageDir}", which has no Dockerfile`)
|
|
41
|
+
}
|
|
42
|
+
const args = parseDockerfileArgs(text)
|
|
43
|
+
|
|
44
|
+
for (const [argName, packageName] of Object.entries(argMap)) {
|
|
45
|
+
const version = args[argName]
|
|
46
|
+
if (version === undefined) {
|
|
47
|
+
throw new Error(`${imageDir}: versions.json names ARG ${argName}, absent from its Dockerfile`)
|
|
48
|
+
}
|
|
49
|
+
if (version === '') {
|
|
50
|
+
throw new Error(`${imageDir}: ARG ${argName} has no default value, so there is no pin`)
|
|
51
|
+
}
|
|
52
|
+
pins.push({ imageDir, argName, packageName, version })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The direction that actually bites: a pin nobody indexed is a pin the
|
|
56
|
+
// bumper will never refresh.
|
|
57
|
+
for (const argName of Object.keys(args)) {
|
|
58
|
+
if (argName.endsWith('_VERSION') && !(argName in argMap)) {
|
|
59
|
+
throw new Error(`${imageDir}: ARG ${argName} is not in versions.json`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return pins
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function findOutdated(pins: Pin[], latest: Record<string, string>): OutdatedPin[] {
|
|
67
|
+
const out: OutdatedPin[] = []
|
|
68
|
+
for (const pin of pins) {
|
|
69
|
+
const current = latest[pin.packageName]
|
|
70
|
+
// No lookup result means the fetch failed or the name is wrong. Staying
|
|
71
|
+
// silent beats reporting a bump we cannot substantiate.
|
|
72
|
+
if (current === undefined) continue
|
|
73
|
+
// Inequality, not ordering: the registry's `latest` dist-tag is definitional.
|
|
74
|
+
if (current !== pin.version) out.push({ ...pin, latest: current })
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function applyPin(text: string, argName: string, version: string): string {
|
|
80
|
+
const pattern = new RegExp(`^([ \\t]*ARG[ \\t]+${argName}[ \\t]*=[ \\t]*).*$`, 'm')
|
|
81
|
+
if (!pattern.test(text)) {
|
|
82
|
+
throw new Error(`ARG ${argName} not found`)
|
|
83
|
+
}
|
|
84
|
+
return text.replace(pattern, `$1${version}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function npmLatestUrl(packageName: string): string {
|
|
88
|
+
// A scoped name's slash must be percent-encoded; the @ must not be.
|
|
89
|
+
return `https://registry.npmjs.org/${packageName.replace('/', '%2F')}/latest`
|
|
90
|
+
}
|