@frontera-sdk/cli 1.52.23 → 1.52.24

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 (45) hide show
  1. package/package.json +4 -4
  2. package/src/api/dataset-api.ts +8 -4
  3. package/src/automation-template.ts +3 -0
  4. package/src/blueprint/tree.ts +27 -0
  5. package/src/commands/local/check.ts +64 -0
  6. package/src/commands/local/dev.ts +301 -0
  7. package/src/commands/local/docs-mcp.ts +36 -0
  8. package/src/commands/local/index-commands.ts +477 -0
  9. package/src/commands/local/init.ts +78 -0
  10. package/src/commands/local/key.ts +108 -0
  11. package/src/commands/local/providers.ts +245 -0
  12. package/src/commands/local/schema.ts +46 -0
  13. package/src/commands/registry.ts +6 -4
  14. package/src/commands/types.ts +7 -0
  15. package/src/flag-help.ts +18 -0
  16. package/src/harness.ts +1 -1
  17. package/src/local/local-agent-files.ts +122 -0
  18. package/src/local/local-applied.ts +135 -0
  19. package/src/local/local-apply-error.ts +35 -0
  20. package/src/local/local-control.ts +143 -0
  21. package/src/local/local-dev-sync.ts +256 -0
  22. package/src/local/local-docker.ts +146 -0
  23. package/src/local/local-docs-search.ts +126 -0
  24. package/src/local/local-docs.ts +241 -0
  25. package/src/local/local-embeddings.ts +79 -0
  26. package/src/local/local-env.ts +225 -0
  27. package/src/local/local-keys.ts +115 -0
  28. package/src/local/local-operating.ts +82 -0
  29. package/src/local/local-ports.ts +111 -0
  30. package/src/local/local-process.ts +106 -0
  31. package/src/local/local-project.ts +54 -0
  32. package/src/local/local-providers.ts +268 -0
  33. package/src/local/local-scaffold.ts +404 -0
  34. package/src/local/local-schemas.ts +301 -0
  35. package/src/local/local-seed.ts +332 -0
  36. package/src/local/local-state.ts +165 -0
  37. package/src/local/local-studio-assets.ts +68 -0
  38. package/src/local/local-studio-server.ts +304 -0
  39. package/src/local/local-supervisor.ts +561 -0
  40. package/src/local/local-test-cases.ts +58 -0
  41. package/src/local/local-test-runner.ts +191 -0
  42. package/src/local/local-test-runs.ts +75 -0
  43. package/src/local/local-traces.ts +322 -0
  44. package/src/main.ts +1 -1
  45. package/src/packaging.ts +5 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A failed apply command's error, as `frontera local dev` prints it: the error
3
+ * line and the detail lines under it (validation issues, the hint), at most
4
+ * seven.
5
+ *
6
+ * A Blueprint apply that fails part-way first lists what it applied before
7
+ * failing, one line per step. That list is counted rather than shown, so a
8
+ * long one cannot push the reason it failed out of the seven lines.
9
+ */
10
+ export function applyErrorText(output: string): string {
11
+ const lines = output.split('\n').filter((l) => l.trim())
12
+ const at = Math.max(0, lines.findIndex((l) => /^error/i.test(l.trim())))
13
+ const kept: string[] = []
14
+ let applied = 0
15
+ let inList = false
16
+ for (const raw of lines.slice(at)) {
17
+ const line = raw.trim()
18
+ const header = /^(.*?)\s*Applied before failing:\s*$/i.exec(line)
19
+ if (header) {
20
+ inList = true
21
+ if (header[1]) kept.push(header[1])
22
+ continue
23
+ }
24
+ if (inList && /^(create|update|delete|publish|grant)\s/i.test(line)) {
25
+ applied += 1
26
+ continue
27
+ }
28
+ inList = false
29
+ kept.push(line)
30
+ }
31
+ if (applied) kept.push(`(${applied} ${applied === 1 ? 'step' : 'steps'} applied before it failed)`)
32
+ const shown = kept.slice(0, 7)
33
+ if (!shown.length) return 'failed with no output'
34
+ return [shown[0], ...shown.slice(1).map((l) => ` ${l}`)].join('\n')
35
+ }
@@ -0,0 +1,143 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, openSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import { credentialStore } from '../credential-store'
6
+ import { findContext, writeContext } from '../project-context'
7
+ import { fingerprint, putProfile } from '../profiles'
8
+ import { containersForProject, removeContainer, removeVolume, stopContainer } from './local-docker'
9
+ import { stopProcess } from './local-process'
10
+ import type { LocalProject } from './local-project'
11
+ import { isAlive, logPath, readState, type LocalState } from './local-state'
12
+ import { volumeName } from './local-supervisor'
13
+
14
+ /**
15
+ * What the `frontera local` commands share: finding the platform source,
16
+ * spawning and stopping the supervisor, and the CLI profile that points every
17
+ * other command at the local runtime.
18
+ */
19
+
20
+ /** The platform checkout this CLI runs from, when it runs from one. */
21
+ export function detectPlatformSource(): string | null {
22
+ // packages/frontera-cli/src/local → repository root
23
+ const root = resolve(import.meta.dir, '..', '..', '..', '..')
24
+ return existsSync(join(root, 'packages', 'service', 'src', 'index.ts')) ? root : null
25
+ }
26
+
27
+ export function isPlatformSource(dir: string): boolean {
28
+ return existsSync(join(dir, 'packages', 'service', 'src', 'index.ts'))
29
+ }
30
+
31
+ /** Spawn the supervisor detached, so it outlives the terminal that started it. */
32
+ export function spawnSupervisor(project: LocalProject, sessionToken: string): number {
33
+ const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
34
+ const args = compiled ? ['local', 'daemon'] : [Bun.main, 'local', 'daemon']
35
+ const out = openSync(logPath(project, 'studio'), 'a')
36
+ const child = spawn(process.execPath, args, {
37
+ cwd: project.root,
38
+ detached: true,
39
+ stdio: ['ignore', out, out],
40
+ env: { ...process.env, FRONTERA_LOCAL_SESSION: sessionToken },
41
+ })
42
+ child.unref()
43
+ return child.pid!
44
+ }
45
+
46
+ export async function waitForPhase(
47
+ project: LocalProject,
48
+ supervisorPid: number,
49
+ onPhase: (state: LocalState) => void,
50
+ timeoutMs = 10 * 60_000,
51
+ ): Promise<LocalState> {
52
+ const since = Date.now() - 1000
53
+ const deadline = Date.now() + timeoutMs
54
+ let last = ''
55
+ while (Date.now() < deadline) {
56
+ const state = readState(project)
57
+ // Only the supervisor just spawned speaks for this start; an earlier run's
58
+ // state file still says `stopped` until the new one rewrites it.
59
+ if (state && state.supervisorPid !== supervisorPid && state.phase !== 'failed') {
60
+ if (!isAlive(supervisorPid)) {
61
+ // A supervisor that failed and shut down clears its pid but leaves its error.
62
+ const fresh = state.error && state.updatedAt && Date.parse(state.updatedAt) >= since
63
+ return { ...state, phase: 'failed', error: fresh ? state.error : 'the supervisor exited before writing its state; see `frontera local logs studio`' }
64
+ }
65
+ await Bun.sleep(300)
66
+ continue
67
+ }
68
+ if (state) {
69
+ const signature = `${state.phase}:${state.components.map((c) => `${c.name}=${c.status}`).join(',')}`
70
+ if (signature !== last) {
71
+ last = signature
72
+ onPhase(state)
73
+ }
74
+ if (state.phase === 'ready' || state.phase === 'failed' || state.phase === 'stopped') return state
75
+ if (state.supervisorPid && !isAlive(state.supervisorPid)) return { ...state, phase: 'failed', error: state.error ?? 'the supervisor exited; see `frontera local logs studio`' }
76
+ }
77
+ await Bun.sleep(400)
78
+ }
79
+ throw new Error('the local runtime did not finish starting within 10 minutes')
80
+ }
81
+
82
+ export async function stopRuntime(project: LocalProject): Promise<void> {
83
+ const state = readState(project)
84
+ if (state?.supervisorPid && isAlive(state.supervisorPid)) {
85
+ await stopProcess(state.supervisorPid, 30_000)
86
+ }
87
+ // Belt and braces: anything labelled for this project that is still up.
88
+ for (const name of containersForProject(project.id)) stopContainer(name)
89
+ }
90
+
91
+ export function destroyRuntimeData(project: LocalProject): void {
92
+ for (const name of containersForProject(project.id)) removeContainer(name)
93
+ removeVolume(volumeName(project))
94
+ removeVolume(`frontera-${project.id}-storage`)
95
+ }
96
+
97
+ export const localProfileName = (project: LocalProject) => `local-${project.id}`.slice(0, 64)
98
+
99
+ /**
100
+ * Register the seeded organization key as a CLI profile, and bind this
101
+ * directory to it when the directory is not already bound to something else.
102
+ *
103
+ * Binding is what makes `frontera agent list` in the solution directory talk
104
+ * to the local runtime with no flags. An existing binding (a real deployment)
105
+ * is left alone: overwriting it silently would point the FDE's next `publish`
106
+ * at the wrong place. They pass `--profile` instead, which `status` prints.
107
+ */
108
+ export async function registerLocalProfile(project: LocalProject, state: LocalState): Promise<{ profile: string; bound: boolean }> {
109
+ const profile = localProfileName(project)
110
+ const creds = state.credentials
111
+ if (!creds) return { profile, bound: false }
112
+ const now = new Date().toISOString()
113
+ const store = credentialStore(process.env, { allowPlaintext: true })
114
+ // The workspace key (`mcp` scope) reaches every workspace command; the
115
+ // organization key is a second profile for Blueprint authoring.
116
+ if (creds.workspaceKey) {
117
+ putProfile(profile, {
118
+ apiUrl: `http://127.0.0.1:${state.ports.service}`,
119
+ credentialKind: 'workspace',
120
+ workspaceId: creds.workspaceId,
121
+ orgId: creds.organizationId,
122
+ fingerprint: fingerprint(creds.workspaceKey),
123
+ createdAt: now,
124
+ lastVerifiedAt: now,
125
+ })
126
+ await store.set(profile, creds.workspaceKey)
127
+ }
128
+ const orgProfile = `${profile}-org`.slice(0, 64)
129
+ putProfile(orgProfile, {
130
+ apiUrl: `http://127.0.0.1:${state.ports.service}`,
131
+ credentialKind: 'organization',
132
+ workspaceId: null,
133
+ orgId: creds.organizationId,
134
+ fingerprint: fingerprint(creds.orgKey),
135
+ createdAt: now,
136
+ lastVerifiedAt: now,
137
+ })
138
+ await store.set(orgProfile, creds.orgKey)
139
+ const existing = findContext(project.root)
140
+ if (existing && existing.profile !== profile) return { profile, bound: false }
141
+ writeContext(project.root, profile)
142
+ return { profile, bound: true }
143
+ }
@@ -0,0 +1,256 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3
+
4
+ /**
5
+ * What `frontera local dev` applies when a file in the solution changes.
6
+ *
7
+ * The solution layout from the delivery design, mapped onto the commands that
8
+ * exist today, one target per change:
9
+ *
10
+ * datasets/<apiName>.json + .csv → dataset create -f … (dataset upload once it exists)
11
+ * agents/<slug>/agent.json → agent apply <slug> -f … (agent create first when new)
12
+ * agents/<slug>/operating.json → the Agent's behavior settings, staged over the API (no CLI command)
13
+ * workflows/<slug>.yaml|.yml|.json → workflow push <slug> … (`<slug>.workflow.yaml` too)
14
+ * functions/<name>.ts → function deploy …
15
+ * blueprint/** → blueprint apply --yes, then publish and grant (organization profile)
16
+ *
17
+ * Evaluation cases (`agents/<slug>/evals/`), fixtures and anything else are
18
+ * not applied: they are read by `local test` and Studio, not stored on the
19
+ * runtime.
20
+ */
21
+
22
+ export type SyncTarget =
23
+ | { kind: 'dataset'; key: string; apiName: string; file: string }
24
+ | { kind: 'agent'; key: string; slug: string; file: string }
25
+ | { kind: 'operating'; key: string; slug: string; file: string }
26
+ | { kind: 'workflow'; key: string; slug: string; file: string }
27
+ | { kind: 'function'; key: string; file: string }
28
+ | { kind: 'blueprint'; key: string }
29
+
30
+ const IGNORED_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'fixtures'])
31
+
32
+ /** Hidden files and editor leftovers: `.#x.yaml` locks, `#x#` autosaves, `x~` backups, `.swp`. */
33
+ export function isIgnoredName(name: string): boolean {
34
+ return name.startsWith('.') || name.startsWith('#') || name.endsWith('~') || name.endsWith('.swp')
35
+ }
36
+
37
+ export function targetFor(root: string, absolute: string): SyncTarget | null {
38
+ const rel = relative(root, absolute)
39
+ if (!rel || rel.startsWith('..')) return null
40
+ const parts = rel.split(sep)
41
+ if (parts.some((p) => IGNORED_DIRS.has(p) || isIgnoredName(p))) return null
42
+ const [top, second, third] = parts
43
+
44
+ // A dataset is its definition and the CSV beside it; either changing applies both.
45
+ if (top === 'datasets' && parts.length === 2 && second && /\.(json|csv)$/.test(second)) {
46
+ const apiName = second.replace(/\.(json|csv)$/, '')
47
+ return { kind: 'dataset', key: `dataset:${apiName}`, apiName, file: join('datasets', `${apiName}.json`) }
48
+ }
49
+ if (top === 'agents' && second && third === 'agent.json' && parts.length === 3) {
50
+ return { kind: 'agent', key: `agent:${second}`, slug: second, file: rel }
51
+ }
52
+ if (top === 'agents' && second && third === 'operating.json' && parts.length === 3) {
53
+ return { kind: 'operating', key: `operating:${second}`, slug: second, file: rel }
54
+ }
55
+ if (top === 'workflows' && parts.length === 2 && second && /\.(ya?ml|json)$/.test(second)) {
56
+ const slug = second.replace(/\.workflow\.(ya?ml|json)$/, '').replace(/\.(ya?ml|json)$/, '')
57
+ return { kind: 'workflow', key: `workflow:${slug}`, slug, file: rel }
58
+ }
59
+ if (top === 'functions' && parts.length === 2 && second && /\.ts$/.test(second) && !second.endsWith('.d.ts') && !second.endsWith('.test.ts')) {
60
+ return { kind: 'function', key: `function:${second}`, file: rel }
61
+ }
62
+ if (top === 'blueprint' && parts.length >= 2 && /\.(ya?ml|json)$/.test(basename(rel))) {
63
+ return { kind: 'blueprint', key: 'blueprint' }
64
+ }
65
+ return null
66
+ }
67
+
68
+ /** Every applicable target in the solution, for the initial sync. */
69
+ export function allTargets(root: string): SyncTarget[] {
70
+ const out = new Map<string, SyncTarget>()
71
+ const walk = (dir: string, depth: number) => {
72
+ if (depth > 6 || !existsSync(dir)) return
73
+ let names: string[]
74
+ try {
75
+ names = readdirSync(dir)
76
+ } catch {
77
+ return
78
+ }
79
+ for (const name of names) {
80
+ if (IGNORED_DIRS.has(name) || isIgnoredName(name)) continue
81
+ const path = join(dir, name)
82
+ let stat
83
+ try {
84
+ stat = lstatSync(path)
85
+ } catch {
86
+ continue
87
+ }
88
+ if (stat.isDirectory()) walk(path, depth + 1)
89
+ else if (stat.isFile()) {
90
+ const t = targetFor(root, path)
91
+ if (t && !out.has(t.key)) out.set(t.key, t)
92
+ }
93
+ }
94
+ }
95
+ for (const top of ['datasets', 'blueprint', 'functions', 'workflows', 'agents']) walk(join(root, top), 1)
96
+ // Datasets first (object types read them), then the Blueprint (Agents and
97
+ // workflows read its objects), then Functions (workflows and Agents call
98
+ // them), then workflows, then Agents, then their behavior settings.
99
+ return [...out.values()].sort(byApplyOrder)
100
+ }
101
+
102
+ const ORDER = { dataset: 0, blueprint: 1, function: 2, workflow: 3, agent: 4, operating: 5 }
103
+
104
+ /** Dependencies first: what a later kind reads or calls is applied before it. */
105
+ export function byApplyOrder(a: SyncTarget, b: SyncTarget): number {
106
+ return ORDER[a.kind] - ORDER[b.kind]
107
+ }
108
+
109
+ /**
110
+ * A dataset definition's own name, kind and CSV (resolved from the solution
111
+ * root). The file is `datasets/<apiName>.json` by convention, but the document
112
+ * is what `dataset create` reads, so it decides.
113
+ */
114
+ export function datasetDocument(root: string, target: SyncTarget): { apiName: string; kind: string; csv: string | null } | null {
115
+ if (target.kind !== 'dataset') return null
116
+ try {
117
+ const doc = JSON.parse(readFileSync(join(root, target.file), 'utf8')) as { apiName?: unknown; kind?: unknown; path?: unknown }
118
+ const apiName = typeof doc.apiName === 'string' && doc.apiName ? doc.apiName : target.apiName
119
+ const kind = typeof doc.kind === 'string' ? doc.kind : 'blank'
120
+ const csv = kind === 'file' && typeof doc.path === 'string' ? relative(root, resolve(join(root, dirname(target.file)), doc.path)) : null
121
+ return { apiName, kind, csv }
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ export interface SyncProfiles {
128
+ /** Workspace profile: Agents, workflows, Functions. */
129
+ workspace: string
130
+ /** Organization profile: datasets and Blueprint. */
131
+ organization: string
132
+ }
133
+
134
+ /**
135
+ * The CLI arguments that apply a target. Every command names its local
136
+ * profile explicitly, so a solution folder bound to a deployment is never
137
+ * written to by `local dev`.
138
+ */
139
+ export function commandFor(target: SyncTarget, profiles: SyncProfiles): string[] {
140
+ switch (target.kind) {
141
+ case 'dataset':
142
+ return ['dataset', 'create', '-f', target.file, '--profile', profiles.organization]
143
+ case 'agent':
144
+ return ['agent', 'apply', target.slug, '-f', target.file, '--profile', profiles.workspace]
145
+ case 'workflow':
146
+ return ['workflow', 'push', target.slug, target.file, '--profile', profiles.workspace]
147
+ case 'function':
148
+ return ['function', 'deploy', target.file, '--profile', profiles.workspace]
149
+ case 'blueprint':
150
+ return ['blueprint', 'apply', '--yes', '--profile', profiles.organization]
151
+ case 'operating':
152
+ // Session-only route; `local dev` calls it itself (see local-operating.ts).
153
+ throw new Error('behavior settings are applied over the API, not with a CLI command')
154
+ }
155
+ }
156
+
157
+ /** When the first command fails with `when`, run `then` instead. */
158
+ export interface Fallback {
159
+ when: RegExp
160
+ then: string[][]
161
+ }
162
+
163
+ /**
164
+ * The second attempt for a target the first command cannot handle alone: an
165
+ * Agent applied for the first time is created, then applied (`agent apply`
166
+ * stages onto an existing draft); a dataset that exists already gets its CSV
167
+ * as a new snapshot (`dataset create` refuses a name in use). A dataset with
168
+ * no CSV that exists already has nothing more to load: an empty `then`.
169
+ *
170
+ * Only rows reach an existing dataset. A changed definition (columns, keys)
171
+ * is a new revision of the dataset, which `local dev` does not make; after a
172
+ * `frontera local reset` the definition is created as written.
173
+ */
174
+ export function fallbackFor(target: SyncTarget, profiles: SyncProfiles, root: string): Fallback | null {
175
+ if (target.kind === 'agent') {
176
+ return { when: /NOT_FOUND/, then: [['agent', 'create', target.slug, '--profile', profiles.workspace], commandFor(target, profiles)] }
177
+ }
178
+ if (target.kind === 'dataset') {
179
+ const doc = datasetDocument(root, target)
180
+ const upload = doc?.csv ? [['dataset', 'upload', doc.apiName, doc.csv, '--profile', profiles.organization]] : []
181
+ return { when: /already in use/i, then: upload }
182
+ }
183
+ return null
184
+ }
185
+
186
+ export interface FollowUp {
187
+ args: string[]
188
+ /** Output that means the step had nothing to do, not that it failed. */
189
+ alreadyDone?: RegExp
190
+ }
191
+
192
+ /**
193
+ * After a Blueprint apply, what makes it usable at once: Agents, workflows
194
+ * and queries read the published Blueprint, and only the object types granted
195
+ * to their workspace. Locally there is one person and one workspace, so the
196
+ * draft is published and every object type in the tree granted to it.
197
+ */
198
+ export function followUpsFor(
199
+ target: SyncTarget,
200
+ profiles: SyncProfiles,
201
+ opts: { publish: boolean; workspaceId: string; objectTypes: string[] },
202
+ ): FollowUp[] {
203
+ if (target.kind !== 'blueprint' || !opts.publish) return []
204
+ const steps: FollowUp[] = [{
205
+ // The instruction is sent only when the service asks for one (a change that
206
+ // needs migrating); locally the data is rebuilt from the solution's files.
207
+ args: [
208
+ 'blueprint', 'publish',
209
+ '--label', `local-${Date.now().toString(36)}`,
210
+ '--notes', 'Published by frontera local dev.',
211
+ '--instruction', "Local development: rebuilt from the project's files.",
212
+ '--profile', profiles.organization,
213
+ ],
214
+ alreadyDone: /no unpublished changes/i,
215
+ }]
216
+ if (opts.objectTypes.length) {
217
+ steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...opts.objectTypes, '--profile', profiles.organization] })
218
+ }
219
+ return steps
220
+ }
221
+
222
+ /** The API names of the object types in the solution's Blueprint tree. */
223
+ export function blueprintObjectTypes(root: string): string[] {
224
+ const dir = join(root, 'blueprint', 'object-types')
225
+ if (!existsSync(dir)) return []
226
+ const names: string[] = []
227
+ for (const file of readdirSync(dir).sort()) {
228
+ if (!/\.(ya?ml|json)$/.test(file) || isIgnoredName(file)) continue
229
+ let text: string
230
+ try {
231
+ text = readFileSync(join(dir, file), 'utf8')
232
+ } catch {
233
+ continue
234
+ }
235
+ const name = /^\s*"?apiName"?\s*:\s*["']?([A-Za-z][A-Za-z0-9_]*)/m.exec(text)?.[1]
236
+ if (name && !names.includes(name)) names.push(name)
237
+ }
238
+ return names
239
+ }
240
+
241
+ export function describeTarget(target: SyncTarget): string {
242
+ switch (target.kind) {
243
+ case 'dataset':
244
+ return `dataset ${target.apiName}`
245
+ case 'agent':
246
+ return `Agent ${target.slug}`
247
+ case 'operating':
248
+ return `Agent ${target.slug} behavior`
249
+ case 'workflow':
250
+ return `workflow ${target.slug}`
251
+ case 'function':
252
+ return `Function ${basename(target.file).replace(/\.ts$/, '')}`
253
+ case 'blueprint':
254
+ return 'Blueprint'
255
+ }
256
+ }
@@ -0,0 +1,146 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import { openSync, readFileSync } from 'node:fs'
3
+
4
+ /**
5
+ * The container runtime, through its CLI.
6
+ *
7
+ * Supabase's CLI drives Docker the same way: one `docker run -d` per service,
8
+ * named and labelled per project, no Compose file. Shelling out keeps the CLI
9
+ * free of a Docker SDK and works unchanged on Docker Desktop, OrbStack, Colima
10
+ * and Podman's docker shim.
11
+ */
12
+
13
+ export const PROJECT_LABEL = 'dev.frontera.local.project'
14
+
15
+ export interface DockerRunSpec {
16
+ name: string
17
+ image: string
18
+ projectId: string
19
+ ports?: Array<{ host: number; container: number }>
20
+ env?: Record<string, string>
21
+ volumes?: Array<{ name: string; path: string }>
22
+ args?: string[]
23
+ /** Extra docker flags, e.g. `--add-host`. */
24
+ flags?: string[]
25
+ /** Memory ceiling, e.g. `512m`, so one runaway container cannot take the laptop. */
26
+ memory?: string
27
+ cpus?: string
28
+ }
29
+
30
+ export class DockerError extends Error {}
31
+
32
+ function docker(args: string[], opts: { input?: string } = {}): { ok: boolean; out: string; err: string } {
33
+ const res = spawnSync('docker', args, { encoding: 'utf8', input: opts.input })
34
+ if (res.error) {
35
+ throw new DockerError(
36
+ (res.error as NodeJS.ErrnoException).code === 'ENOENT'
37
+ ? 'docker was not found on PATH'
38
+ : res.error.message,
39
+ )
40
+ }
41
+ return { ok: res.status === 0, out: (res.stdout ?? '').trim(), err: (res.stderr ?? '').trim() }
42
+ }
43
+
44
+ /** Whether a container runtime answers. */
45
+ export function dockerAvailable(): { ok: true } | { ok: false; reason: string } {
46
+ try {
47
+ const r = docker(['info', '--format', '{{.ServerVersion}}'])
48
+ if (r.ok && r.out) return { ok: true }
49
+ return { ok: false, reason: r.err.split('\n')[0] || 'the docker daemon did not answer' }
50
+ } catch (err) {
51
+ return { ok: false, reason: (err as Error).message }
52
+ }
53
+ }
54
+
55
+ export function imagePresent(image: string): boolean {
56
+ return docker(['image', 'inspect', image, '--format', '{{.Id}}']).ok
57
+ }
58
+
59
+ /** Pull an image, writing progress to a log file instead of the terminal. */
60
+ export function pullImage(image: string, logFile: string): Promise<void> {
61
+ return new Promise((resolvePromise, reject) => {
62
+ const fd = openSync(logFile, 'a')
63
+ const child = spawn('docker', ['pull', image], { stdio: ['ignore', fd, fd] })
64
+ child.on('error', reject)
65
+ child.on('exit', (code) => {
66
+ if (code === 0) return resolvePromise()
67
+ // The private release images are the usual failure: say what grants access.
68
+ let tail = ''
69
+ try {
70
+ tail = readFileSync(logFile, 'utf8').slice(-2000)
71
+ } catch {
72
+ // The log is a convenience; the exit code is the fact.
73
+ }
74
+ const denied = /unauthorized|denied|authentication required/i.test(tail)
75
+ const host = image.split('/')[0]
76
+ const reason = denied
77
+ ? image.startsWith('ghcr.io/')
78
+ ? `the registry refused access to ${image}: run \`gh auth refresh -s read:packages\`, then \`gh auth token | docker login ghcr.io -u <github-user> --password-stdin\`, or run from a platform checkout without --image`
79
+ : `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
80
+ : /manifest unknown|not found/i.test(tail)
81
+ ? `${image} is not in the registry: check --registry and --version, or push that release there first`
82
+ : `docker pull ${image} failed (exit ${code}); see ${logFile}`
83
+ reject(new DockerError(reason))
84
+ })
85
+ })
86
+ }
87
+
88
+ export function containerState(name: string): 'running' | 'exited' | 'missing' | 'other' {
89
+ const r = docker(['inspect', name, '--format', '{{.State.Status}}'])
90
+ if (!r.ok) return 'missing'
91
+ if (r.out === 'running') return 'running'
92
+ if (r.out === 'exited' || r.out === 'created') return 'exited'
93
+ return 'other'
94
+ }
95
+
96
+ export function runContainer(spec: DockerRunSpec): void {
97
+ const args = ['run', '-d', '--name', spec.name, '--label', `${PROJECT_LABEL}=${spec.projectId}`]
98
+ for (const p of spec.ports ?? []) args.push('-p', `127.0.0.1:${p.host}:${p.container}`)
99
+ for (const [k, v] of Object.entries(spec.env ?? {})) args.push('-e', `${k}=${v}`)
100
+ for (const v of spec.volumes ?? []) args.push('-v', `${v.name}:${v.path}`)
101
+ if (spec.memory) args.push('--memory', spec.memory)
102
+ if (spec.cpus) args.push('--cpus', spec.cpus)
103
+ args.push(...(spec.flags ?? []))
104
+ args.push(spec.image, ...(spec.args ?? []))
105
+ const r = docker(args)
106
+ if (!r.ok) throw new DockerError(`could not start ${spec.name}: ${r.err.split('\n').slice(-1)[0]}`)
107
+ }
108
+
109
+ export function startContainer(name: string): void {
110
+ const r = docker(['start', name])
111
+ if (!r.ok) throw new DockerError(`could not start ${name}: ${r.err}`)
112
+ }
113
+
114
+ export function stopContainer(name: string): void {
115
+ docker(['stop', '-t', '10', name])
116
+ }
117
+
118
+ export function removeContainer(name: string): void {
119
+ docker(['rm', '-f', name])
120
+ }
121
+
122
+ export function removeVolume(name: string): void {
123
+ docker(['volume', 'rm', '-f', name])
124
+ }
125
+
126
+ /** Copy a file out of a container to a path on the host. */
127
+ export function copyFromContainer(name: string, path: string, dest: string): boolean {
128
+ return docker(['cp', `${name}:${path}`, dest]).ok
129
+ }
130
+
131
+ /** Run a command inside a running container; used for readiness probes. */
132
+ export function execInContainer(name: string, cmd: string[]): boolean {
133
+ return docker(['exec', name, ...cmd]).ok
134
+ }
135
+
136
+ /** Follow a container's logs into a file for as long as the container lives. */
137
+ export function followLogs(name: string, logFile: string): void {
138
+ const fd = openSync(logFile, 'a')
139
+ const child = spawn('docker', ['logs', '-f', '--since', '0s', name], { stdio: ['ignore', fd, fd], detached: false })
140
+ child.unref()
141
+ }
142
+
143
+ export function containersForProject(projectId: string): string[] {
144
+ const r = docker(['ps', '-a', '--filter', `label=${PROJECT_LABEL}=${projectId}`, '--format', '{{.Names}}'])
145
+ return r.ok && r.out ? r.out.split('\n') : []
146
+ }