zooid 0.7.0 → 0.7.2
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/dist/bin.js +124 -15
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-N5POSZX5.js → chunk-O6E4CDTV.js} +805 -218
- package/dist/chunk-O6E4CDTV.js.map +1 -0
- package/dist/index.d.ts +27 -3
- package/dist/index.js +1 -1
- package/dist/web/assets/{index-BT_v3DKu.js → index-1pU3tgkr.js} +85 -75
- package/dist/web/assets/index-C-ZtBp7U.css +1 -0
- package/dist/web/assets/{index-JZMMlqDP.js → index-CXOPATwH.js} +1 -1
- package/dist/web/assets/{reaction-picker-emoji-Kh7emgFa.js → reaction-picker-emoji-DrlHZ5qt.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +8 -7
- package/src/bin.ts +5 -2
- package/src/build-registry.context.test.ts +1 -1
- package/src/build-registry.ts +211 -10
- package/src/build-registry.zod043.test.ts +1 -1
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev.ts +15 -7
- package/src/commands/init/generators.ts +5 -0
- package/src/commands/status.ts +7 -2
- package/src/daemon/start-daemon.ts +45 -3
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/dist/chunk-N5POSZX5.js.map +0 -1
- package/dist/web/assets/index-DJTghnF-.css +0 -1
|
@@ -35,6 +35,11 @@ You are the zooid setup assistant. Your job is to help the operator get a workfo
|
|
|
35
35
|
- Zooid documentation: https://zooid.dev/docs/
|
|
36
36
|
- Use your web fetch tool to read documentation pages when answering questions.
|
|
37
37
|
|
|
38
|
+
## Stack facts
|
|
39
|
+
|
|
40
|
+
- **Tuwunel** is a lightweight Rust Matrix homeserver (Conduit fork) backed by RocksDB. Single binary, low resource floor — not "heavy" for typical workforces.
|
|
41
|
+
- **Logs land on disk** in \`./data/\` inside this zooid home dir: tuwunel logs, the daemon, and each agent's ACP stream. Use \`zooid logs <source>\` to tail them (sources: \`tuwunel\`, \`daemon\`, \`dev\`, \`agent-<name>\`, \`agent-<name>.acp\`). Debugging is mostly "read the log file" — don't tell the operator it's opaque.
|
|
42
|
+
|
|
38
43
|
## Behavior
|
|
39
44
|
|
|
40
45
|
- Be terse. Give the answer; link to the doc page; stop.
|
package/src/commands/status.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
2
3
|
import chalk from 'chalk'
|
|
3
4
|
import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
|
|
4
5
|
import { deriveHomeserverShape } from '../bootstrap/derive.js'
|
|
@@ -43,7 +44,9 @@ export async function collectStatus(opts: {
|
|
|
43
44
|
agents: [],
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8')
|
|
47
|
+
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
|
|
48
|
+
configDir: dirname(found.path),
|
|
49
|
+
})
|
|
47
50
|
const matrixEntry = Object.entries(cfg.transports).find(
|
|
48
51
|
([, t]) => t.type === 'matrix',
|
|
49
52
|
) as [string, { port?: number }] | undefined
|
|
@@ -73,7 +76,9 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
|
|
|
73
76
|
if (port === undefined) {
|
|
74
77
|
const found = findConfigFile(cwd)
|
|
75
78
|
if (found) {
|
|
76
|
-
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8')
|
|
79
|
+
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
|
|
80
|
+
configDir: dirname(found.path),
|
|
81
|
+
})
|
|
77
82
|
const matrix = findMatrixTransport(cfg)
|
|
78
83
|
if (matrix) {
|
|
79
84
|
const userIds = Object.values(cfg.agents)
|
|
@@ -18,6 +18,7 @@ import { createApp } from '@zooid/transport-http'
|
|
|
18
18
|
import {
|
|
19
19
|
MatrixClient,
|
|
20
20
|
createMatrixTransport,
|
|
21
|
+
ensureDefaultChannel,
|
|
21
22
|
ensureWorkforceSpace,
|
|
22
23
|
startWorkforcePublisher,
|
|
23
24
|
type AgentBinding,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
type DaemonSocketHandle,
|
|
30
31
|
} from '@zooid/context-mcp'
|
|
31
32
|
import { buildAcpRegistry } from '../build-registry.js'
|
|
33
|
+
import { prepullImages } from '../prepull-images.js'
|
|
32
34
|
|
|
33
35
|
export interface StartDaemonOpts {
|
|
34
36
|
configPath?: string
|
|
@@ -45,6 +47,17 @@ export interface StartDaemonOpts {
|
|
|
45
47
|
* daemon restarts.
|
|
46
48
|
*/
|
|
47
49
|
agentsDir?: string
|
|
50
|
+
/** Skip the startup image-prepull pass entirely. */
|
|
51
|
+
noPrepull?: boolean
|
|
52
|
+
/** Force a re-pull of every resolved image at startup (no inspect check). */
|
|
53
|
+
refreshImages?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Per-line progress hook for the image-prepull pass. Called once per
|
|
56
|
+
* top-level summary plus once per image (start + completion). Used by
|
|
57
|
+
* `zooid dev` to surface pull progress under the "Start daemon" listr
|
|
58
|
+
* task instead of leaving the user staring at an unmoving spinner.
|
|
59
|
+
*/
|
|
60
|
+
prepullLog?: (line: string) => void
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
export interface DaemonHandle {
|
|
@@ -75,7 +88,8 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
75
88
|
const cwd = opts.cwd ?? process.cwd()
|
|
76
89
|
const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
|
|
77
90
|
if (!found) throw new Error('zooid.yaml is required')
|
|
78
|
-
const
|
|
91
|
+
const configDir = dirname(found.path)
|
|
92
|
+
const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
|
|
79
93
|
const config = mergeCliFlags(base, opts.cliFlags ?? {})
|
|
80
94
|
|
|
81
95
|
const approvals = new ApprovalCorrelator()
|
|
@@ -95,15 +109,29 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
95
109
|
console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
|
|
98
113
|
const registry = buildAcpRegistry(config, {
|
|
99
114
|
approvals,
|
|
100
115
|
onTap: opts.onTap,
|
|
101
116
|
agentsDir: opts.agentsDir,
|
|
102
117
|
contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
|
|
103
118
|
daemonSockPath: contextSocket ? daemonSockPath : undefined,
|
|
119
|
+
configDir,
|
|
120
|
+
dataDir,
|
|
121
|
+
daemonHome: process.env.HOME,
|
|
104
122
|
})
|
|
105
123
|
const agentNames = Object.keys(config.agents)
|
|
106
124
|
|
|
125
|
+
if (config.runtime !== 'local') {
|
|
126
|
+
await prepullImages(registry, {
|
|
127
|
+
engine: config.runtime === 'podman' ? 'podman' : 'docker',
|
|
128
|
+
runtime: config.runtime,
|
|
129
|
+
skip: opts.noPrepull ?? process.env.ZOOID_NO_PREPULL === '1',
|
|
130
|
+
refresh: opts.refreshImages ?? process.env.ZOOID_REFRESH_IMAGES === '1',
|
|
131
|
+
log: opts.prepullLog,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
107
135
|
console.log(
|
|
108
136
|
`[context] socket=${daemonSockPath} ` +
|
|
109
137
|
`status=${contextSocket ? 'listening' : 'disabled'} ` +
|
|
@@ -147,7 +175,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
147
175
|
hsToken: matrix.transport.hs_token,
|
|
148
176
|
adminUserId: opts.adminUserId,
|
|
149
177
|
})
|
|
150
|
-
const requestedPort = matrix.transport.port ??
|
|
178
|
+
const requestedPort = matrix.transport.port ?? 9000
|
|
151
179
|
// Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
|
|
152
180
|
// macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
|
|
153
181
|
// events back to host.docker.internal:<port>.
|
|
@@ -162,6 +190,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
162
190
|
new URL(matrix.transport.homeserver).hostname
|
|
163
191
|
const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
|
|
164
192
|
const spaceLocalpart = matrix.transport.space ?? 'dev'
|
|
193
|
+
const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
|
|
165
194
|
let spaceRoomId: string | undefined
|
|
166
195
|
try {
|
|
167
196
|
spaceRoomId = await ensureWorkforceSpace({
|
|
@@ -170,6 +199,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
170
199
|
serverName,
|
|
171
200
|
spaceLocalpart,
|
|
172
201
|
preset: 'public_chat',
|
|
202
|
+
admins: adminUserIds,
|
|
173
203
|
})
|
|
174
204
|
console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
|
|
175
205
|
} catch (err) {
|
|
@@ -182,9 +212,21 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
182
212
|
// those pushes don't hit a connection-refused. Bootstrap also runs
|
|
183
213
|
// *after* the space exists so BotPool can attach each agent room as
|
|
184
214
|
// m.space.child while joining it.
|
|
185
|
-
await transport.bootstrap({ spaceRoomId, asUserId })
|
|
215
|
+
await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
|
|
186
216
|
|
|
187
217
|
if (spaceRoomId) {
|
|
218
|
+
try {
|
|
219
|
+
const generalRoomId = await ensureDefaultChannel({
|
|
220
|
+
client,
|
|
221
|
+
asUserId,
|
|
222
|
+
serverName,
|
|
223
|
+
spaceId: spaceRoomId,
|
|
224
|
+
admins: adminUserIds,
|
|
225
|
+
})
|
|
226
|
+
console.log(`[matrix] ensured default channel #general:${serverName} → ${generalRoomId}`)
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.warn('[matrix] default channel provisioning failed:', err)
|
|
229
|
+
}
|
|
188
230
|
try {
|
|
189
231
|
const publisher: PublisherHandle = await startWorkforcePublisher({
|
|
190
232
|
client,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { prepullImages } from './prepull-images.js'
|
|
3
|
+
|
|
4
|
+
type Call = { argv: string[] }
|
|
5
|
+
|
|
6
|
+
function mkExec(
|
|
7
|
+
plan: Record<
|
|
8
|
+
string,
|
|
9
|
+
{ inspectExit: number; pullExit?: number; pullStderr?: string }
|
|
10
|
+
>,
|
|
11
|
+
) {
|
|
12
|
+
const calls: Call[] = []
|
|
13
|
+
const exec = vi.fn(async (cmd: string, args: string[]) => {
|
|
14
|
+
calls.push({ argv: [cmd, ...args] })
|
|
15
|
+
const op = args[0] === 'image' ? 'inspect' : 'pull'
|
|
16
|
+
const image = op === 'inspect' ? args[2] : args[1]
|
|
17
|
+
const entry = plan[image!]
|
|
18
|
+
if (!entry) throw new Error(`unexpected image: ${image}`)
|
|
19
|
+
if (op === 'inspect') {
|
|
20
|
+
return { code: entry.inspectExit, stdout: '', stderr: '' }
|
|
21
|
+
}
|
|
22
|
+
return { code: entry.pullExit ?? 0, stdout: '', stderr: entry.pullStderr ?? '' }
|
|
23
|
+
})
|
|
24
|
+
return { exec, calls }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const mkRegistry = (imagesByAgent: Record<string, string | undefined>) => ({
|
|
28
|
+
resolveSpawnImage: (n: string) => imagesByAgent[n],
|
|
29
|
+
agentNames: () => Object.keys(imagesByAgent),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('prepullImages', () => {
|
|
33
|
+
it('skips locally cached images (inspect exit 0)', async () => {
|
|
34
|
+
const { exec, calls } = mkExec({
|
|
35
|
+
'ghcr.io/zooid-ai/agent-claude:latest': { inspectExit: 0 },
|
|
36
|
+
})
|
|
37
|
+
const lines: string[] = []
|
|
38
|
+
await prepullImages(
|
|
39
|
+
mkRegistry({ alice: 'ghcr.io/zooid-ai/agent-claude:latest' }) as any,
|
|
40
|
+
{
|
|
41
|
+
engine: 'docker',
|
|
42
|
+
runtime: 'docker',
|
|
43
|
+
exec,
|
|
44
|
+
log: (l) => lines.push(l),
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(0)
|
|
48
|
+
expect(lines.some((l) => /1 unique images \(1 cached, 0 to pull\)/.test(l))).toBe(
|
|
49
|
+
true,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('pulls only missing images (inspect exit non-zero → pull)', async () => {
|
|
54
|
+
const { exec, calls } = mkExec({
|
|
55
|
+
'cached:1': { inspectExit: 0 },
|
|
56
|
+
'missing:1': { inspectExit: 1, pullExit: 0 },
|
|
57
|
+
})
|
|
58
|
+
const lines: string[] = []
|
|
59
|
+
await prepullImages(
|
|
60
|
+
mkRegistry({ a: 'cached:1', b: 'missing:1' }) as any,
|
|
61
|
+
{ engine: 'docker', runtime: 'docker', exec, log: (l) => lines.push(l) },
|
|
62
|
+
)
|
|
63
|
+
const pulls = calls.filter((c) => c.argv.includes('pull')).map((c) => c.argv[2])
|
|
64
|
+
expect(pulls).toEqual(['missing:1'])
|
|
65
|
+
expect(lines.some((l) => /1 cached, 1 to pull/.test(l))).toBe(true)
|
|
66
|
+
expect(lines.some((l) => /missing:1.*✓/.test(l))).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('dedupes by image — five agents on the same image pull once', async () => {
|
|
70
|
+
const { exec, calls } = mkExec({
|
|
71
|
+
'shared:1': { inspectExit: 1, pullExit: 0 },
|
|
72
|
+
})
|
|
73
|
+
await prepullImages(
|
|
74
|
+
mkRegistry({
|
|
75
|
+
a: 'shared:1',
|
|
76
|
+
b: 'shared:1',
|
|
77
|
+
c: 'shared:1',
|
|
78
|
+
d: 'shared:1',
|
|
79
|
+
e: 'shared:1',
|
|
80
|
+
}) as any,
|
|
81
|
+
{ engine: 'docker', runtime: 'docker', exec, log: () => {} },
|
|
82
|
+
)
|
|
83
|
+
expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('refresh=true skips the inspect check and pulls every image', async () => {
|
|
87
|
+
const { exec, calls } = mkExec({
|
|
88
|
+
'a:1': { inspectExit: 0, pullExit: 0 },
|
|
89
|
+
'b:1': { inspectExit: 0, pullExit: 0 },
|
|
90
|
+
})
|
|
91
|
+
await prepullImages(mkRegistry({ x: 'a:1', y: 'b:1' }) as any, {
|
|
92
|
+
engine: 'docker',
|
|
93
|
+
runtime: 'docker',
|
|
94
|
+
exec,
|
|
95
|
+
refresh: true,
|
|
96
|
+
log: () => {},
|
|
97
|
+
})
|
|
98
|
+
expect(calls.filter((c) => c.argv.includes('inspect'))).toHaveLength(0)
|
|
99
|
+
expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(2)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('fail-fast: pull exit non-zero throws with engine stderr embedded', async () => {
|
|
103
|
+
const { exec } = mkExec({
|
|
104
|
+
'broken:1': { inspectExit: 1, pullExit: 1, pullStderr: 'manifest unknown' },
|
|
105
|
+
})
|
|
106
|
+
await expect(
|
|
107
|
+
prepullImages(mkRegistry({ a: 'broken:1' }) as any, {
|
|
108
|
+
engine: 'docker',
|
|
109
|
+
runtime: 'docker',
|
|
110
|
+
exec,
|
|
111
|
+
log: () => {},
|
|
112
|
+
}),
|
|
113
|
+
).rejects.toThrow(/broken:1[\s\S]*manifest unknown/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('skip=true is a no-op (returns immediately, no exec calls)', async () => {
|
|
117
|
+
const { exec, calls } = mkExec({})
|
|
118
|
+
await prepullImages(mkRegistry({ a: 'whatever:1' }) as any, {
|
|
119
|
+
engine: 'docker',
|
|
120
|
+
runtime: 'docker',
|
|
121
|
+
exec,
|
|
122
|
+
skip: true,
|
|
123
|
+
log: () => {},
|
|
124
|
+
})
|
|
125
|
+
expect(calls).toHaveLength(0)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('runtime: local is a no-op regardless of opts', async () => {
|
|
129
|
+
const { exec, calls } = mkExec({})
|
|
130
|
+
await prepullImages(mkRegistry({ a: 'whatever:1' }) as any, {
|
|
131
|
+
engine: 'docker',
|
|
132
|
+
runtime: 'local',
|
|
133
|
+
exec,
|
|
134
|
+
log: () => {},
|
|
135
|
+
})
|
|
136
|
+
expect(calls).toHaveLength(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('uses the configured engine (podman) in argv', async () => {
|
|
140
|
+
const { exec, calls } = mkExec({ 'x:1': { inspectExit: 1, pullExit: 0 } })
|
|
141
|
+
await prepullImages(mkRegistry({ a: 'x:1' }) as any, {
|
|
142
|
+
engine: 'podman',
|
|
143
|
+
runtime: 'podman',
|
|
144
|
+
exec,
|
|
145
|
+
log: () => {},
|
|
146
|
+
})
|
|
147
|
+
expect(calls.every((c) => c.argv[0] === 'podman')).toBe(true)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const pExecFile = promisify(execFile)
|
|
5
|
+
|
|
6
|
+
export interface PrepullExec {
|
|
7
|
+
(cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PrepullOptions {
|
|
11
|
+
engine: 'docker' | 'podman'
|
|
12
|
+
runtime: 'local' | 'docker' | 'podman'
|
|
13
|
+
/** Injectable for tests. Defaults to a child_process.execFile-backed exec. */
|
|
14
|
+
exec?: PrepullExec
|
|
15
|
+
/** When true, skip the inspect check and pull every image. */
|
|
16
|
+
refresh?: boolean
|
|
17
|
+
/** When true, the function returns immediately without touching the engine. */
|
|
18
|
+
skip?: boolean
|
|
19
|
+
log?: (line: string) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PrepullableRegistry {
|
|
23
|
+
agentNames(): string[]
|
|
24
|
+
resolveSpawnImage(name: string): string | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walk the registry's unique resolved-image set, `<engine> image inspect`
|
|
29
|
+
* each one, and `<engine> pull` the ones not cached locally. Parallel and
|
|
30
|
+
* fail-fast: any pull exit ≠ 0 throws with the engine's stderr embedded.
|
|
31
|
+
* No-op when `opts.skip` or `opts.runtime === 'local'`.
|
|
32
|
+
*/
|
|
33
|
+
export async function prepullImages(
|
|
34
|
+
registry: PrepullableRegistry,
|
|
35
|
+
opts: PrepullOptions,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
if (opts.skip || opts.runtime === 'local') return
|
|
38
|
+
const exec = opts.exec ?? defaultExec
|
|
39
|
+
const log = opts.log ?? ((l: string) => console.log(l))
|
|
40
|
+
|
|
41
|
+
const images = new Set<string>()
|
|
42
|
+
for (const name of registry.agentNames()) {
|
|
43
|
+
const img = registry.resolveSpawnImage(name)
|
|
44
|
+
if (img) images.add(img)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const toPull: string[] = []
|
|
48
|
+
let cached = 0
|
|
49
|
+
if (opts.refresh) {
|
|
50
|
+
toPull.push(...images)
|
|
51
|
+
} else {
|
|
52
|
+
await Promise.all(
|
|
53
|
+
[...images].map(async (img) => {
|
|
54
|
+
const r = await exec(opts.engine, ['image', 'inspect', img])
|
|
55
|
+
if (r.code === 0) cached++
|
|
56
|
+
else toPull.push(img)
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
log(
|
|
62
|
+
`[zooid] image prepull: ${images.size} unique images (${cached} cached, ${toPull.length} to pull)`,
|
|
63
|
+
)
|
|
64
|
+
if (toPull.length === 0) return
|
|
65
|
+
|
|
66
|
+
await Promise.all(
|
|
67
|
+
toPull.map(async (img) => {
|
|
68
|
+
log(`[zooid] ${img} pulling…`)
|
|
69
|
+
const start = Date.now()
|
|
70
|
+
const r = await exec(opts.engine, ['pull', img])
|
|
71
|
+
const secs = ((Date.now() - start) / 1000).toFixed(1)
|
|
72
|
+
if (r.code !== 0) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`image prepull failed for ${img}:\n${r.stderr.trim() || '(no stderr)'}`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
log(`[zooid] ${img} ✓ ${secs}s`)
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const defaultExec: PrepullExec = async (cmd, args) => {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout, stderr } = await pExecFile(cmd, args, {
|
|
85
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
86
|
+
})
|
|
87
|
+
return { code: 0, stdout, stderr }
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const e = err as NodeJS.ErrnoException & {
|
|
90
|
+
code?: number | string
|
|
91
|
+
stdout?: string
|
|
92
|
+
stderr?: string
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
code: typeof e.code === 'number' ? e.code : 1,
|
|
96
|
+
stdout: e.stdout ?? '',
|
|
97
|
+
stderr: e.stderr ?? String(e),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|