@frontera-sdk/cli 1.52.22 → 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.
- package/package.json +4 -4
- package/src/api/dataset-api.ts +8 -4
- package/src/automation-template.ts +3 -0
- package/src/blueprint/tree.ts +27 -0
- package/src/commands/local/check.ts +64 -0
- package/src/commands/local/dev.ts +301 -0
- package/src/commands/local/docs-mcp.ts +36 -0
- package/src/commands/local/index-commands.ts +477 -0
- package/src/commands/local/init.ts +78 -0
- package/src/commands/local/key.ts +108 -0
- package/src/commands/local/providers.ts +245 -0
- package/src/commands/local/schema.ts +46 -0
- package/src/commands/registry.ts +6 -4
- package/src/commands/types.ts +7 -0
- package/src/flag-help.ts +18 -0
- package/src/harness.ts +1 -1
- package/src/local/local-agent-files.ts +122 -0
- package/src/local/local-applied.ts +135 -0
- package/src/local/local-apply-error.ts +35 -0
- package/src/local/local-control.ts +143 -0
- package/src/local/local-dev-sync.ts +256 -0
- package/src/local/local-docker.ts +146 -0
- package/src/local/local-docs-search.ts +126 -0
- package/src/local/local-docs.ts +241 -0
- package/src/local/local-embeddings.ts +79 -0
- package/src/local/local-env.ts +225 -0
- package/src/local/local-keys.ts +115 -0
- package/src/local/local-operating.ts +82 -0
- package/src/local/local-ports.ts +111 -0
- package/src/local/local-process.ts +106 -0
- package/src/local/local-project.ts +54 -0
- package/src/local/local-providers.ts +268 -0
- package/src/local/local-scaffold.ts +404 -0
- package/src/local/local-schemas.ts +301 -0
- package/src/local/local-seed.ts +332 -0
- package/src/local/local-state.ts +165 -0
- package/src/local/local-studio-assets.ts +68 -0
- package/src/local/local-studio-server.ts +304 -0
- package/src/local/local-supervisor.ts +561 -0
- package/src/local/local-test-cases.ts +58 -0
- package/src/local/local-test-runner.ts +191 -0
- package/src/local/local-test-runs.ts +75 -0
- package/src/local/local-traces.ts +322 -0
- package/src/main.ts +1 -1
- 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
|
+
}
|