@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,115 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { writeFileAtomic } from '../fs-atomic'
6
+ import { configDir } from '../paths'
7
+
8
+ /**
9
+ * Each FDE's own model-provider keys for the local runtime.
10
+ *
11
+ * They live in the OS keychain on macOS (`security`), so a key never sits in a
12
+ * solution repo, a shell profile or the runtime's state directory. Elsewhere
13
+ * they fall back to a 0600 file under the CLI's config directory. The local
14
+ * service receives them as the same environment variables a deployment's
15
+ * service reads, so a local turn calls the provider exactly the way a deployed
16
+ * one does.
17
+ */
18
+
19
+ const SERVICE = 'frontera-local-model-key'
20
+
21
+ export interface ProviderSpec {
22
+ id: string
23
+ label: string
24
+ /** The variable the service reads for this provider. */
25
+ envVar: string
26
+ }
27
+
28
+ /** Providers the local runtime can pass a key to; mirrors the service's built-in providers. */
29
+ export const LOCAL_PROVIDERS: readonly ProviderSpec[] = [
30
+ { id: 'anthropic', label: 'Anthropic', envVar: 'ANTHROPIC_API_KEY' },
31
+ { id: 'openai', label: 'OpenAI', envVar: 'OPENAI_API_KEY' },
32
+ { id: 'openrouter', label: 'OpenRouter', envVar: 'OPENROUTER_API_KEY' },
33
+ { id: 'google', label: 'Google Gemini', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY' },
34
+ { id: 'cohere', label: 'Cohere (reranking)', envVar: 'COHERE_API_KEY' },
35
+ ]
36
+
37
+ export function findProvider(id: string): ProviderSpec | undefined {
38
+ return LOCAL_PROVIDERS.find((p) => p.id === id)
39
+ }
40
+
41
+ function useKeychain(): boolean {
42
+ return process.platform === 'darwin' && !process.env.FRONTERA_LOCAL_KEYS_FILE
43
+ }
44
+
45
+ function filePath(): string {
46
+ return process.env.FRONTERA_LOCAL_KEYS_FILE ?? join(configDir(), 'local-model-keys.json')
47
+ }
48
+
49
+ function readFile(): Record<string, string> {
50
+ const path = filePath()
51
+ if (!existsSync(path)) return {}
52
+ try {
53
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, string>
54
+ } catch {
55
+ return {}
56
+ }
57
+ }
58
+
59
+ export function setKey(provider: string, key: string): void {
60
+ if (useKeychain()) {
61
+ // -U updates an existing item instead of failing on a duplicate.
62
+ const res = spawnSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', provider, '-w', key], {
63
+ stdio: 'ignore',
64
+ })
65
+ if (res.status !== 0) throw new Error('the macOS keychain refused the key')
66
+ return
67
+ }
68
+ writeFileAtomic(filePath(), `${JSON.stringify({ ...readFile(), [provider]: key }, null, 2)}\n`, 0o600)
69
+ }
70
+
71
+ export function getKey(provider: string): string | null {
72
+ if (useKeychain()) {
73
+ const res = spawnSync('security', ['find-generic-password', '-s', SERVICE, '-a', provider, '-w'], {
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'ignore'],
76
+ })
77
+ return res.status === 0 ? res.stdout.trim() || null : null
78
+ }
79
+ return readFile()[provider] ?? null
80
+ }
81
+
82
+ export function removeKey(provider: string): boolean {
83
+ if (useKeychain()) {
84
+ return spawnSync('security', ['delete-generic-password', '-s', SERVICE, '-a', provider], { stdio: 'ignore' }).status === 0
85
+ }
86
+ const keys = readFile()
87
+ if (!(provider in keys)) return false
88
+ delete keys[provider]
89
+ writeFileAtomic(filePath(), `${JSON.stringify(keys, null, 2)}\n`, 0o600)
90
+ return true
91
+ }
92
+
93
+ /** Which providers have a key, without reading the keys themselves into output. */
94
+ export function configuredProviders(): string[] {
95
+ return LOCAL_PROVIDERS.filter((p) => getKey(p.id) !== null).map((p) => p.id)
96
+ }
97
+
98
+ /** The environment variables for every configured provider. */
99
+ export function providerEnv(): Record<string, string> {
100
+ const env: Record<string, string> = {}
101
+ for (const p of LOCAL_PROVIDERS) {
102
+ const key = getKey(p.id)
103
+ if (key) env[p.envVar] = key
104
+ }
105
+ return env
106
+ }
107
+
108
+ /**
109
+ * With no OpenAI or OpenRouter key, the runtime embeds with the local model:
110
+ * the service's OpenAI client is pointed at it, and the organization gets a
111
+ * placeholder OpenAI credential. A real key of either turns this off.
112
+ */
113
+ export function keylessEmbeddings(): boolean {
114
+ return !getKey('openai') && !getKey('openrouter')
115
+ }
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import type { LocalCredentials } from './local-state'
4
+
5
+ /**
6
+ * An Agent's behavior settings (`agents/<slug>/operating.json`: harness,
7
+ * procedures, variables, focus, reply checks) staged onto its draft.
8
+ *
9
+ * There is no CLI command for these: the route takes a person's session, not
10
+ * an API key. The local runtime has the owner's session, as Studio does, so
11
+ * `local dev` stages them over the API directly. Publishing applies them,
12
+ * like the rest of the draft.
13
+ */
14
+
15
+ interface Result {
16
+ ok: boolean
17
+ output: string
18
+ }
19
+
20
+ function describeError(status: number, body: unknown): string {
21
+ const b = (body ?? {}) as { message?: string; code?: string; details?: unknown; issues?: unknown }
22
+ const lines = [`error [${b.code ?? status}]: ${b.message ?? 'the behavior settings were refused'}`]
23
+ const issues = (b.details as { issues?: unknown } | undefined)?.issues ?? b.issues ?? b.details
24
+ if (Array.isArray(issues)) {
25
+ for (const issue of issues.slice(0, 6)) {
26
+ const i = issue as { path?: unknown; message?: string }
27
+ const path = Array.isArray(i.path) ? i.path.join('.') : typeof i.path === 'string' ? i.path : ''
28
+ lines.push(` ${path ? `${path}: ` : ''}${i.message ?? JSON.stringify(issue)}`)
29
+ }
30
+ }
31
+ return lines.join('\n')
32
+ }
33
+
34
+ export async function applyOperatingConfig(args: {
35
+ serviceUrl: string
36
+ creds: LocalCredentials
37
+ slug: string
38
+ file: string
39
+ }): Promise<Result> {
40
+ let operatingConfig: unknown
41
+ try {
42
+ operatingConfig = JSON.parse(readFileSync(args.file, 'utf8'))
43
+ } catch (err) {
44
+ return { ok: false, output: `error [USAGE]: ${args.file} is not valid JSON: ${(err as Error).message}` }
45
+ }
46
+ if (!args.creds.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime has no owner session; restart it' }
47
+ const headers = {
48
+ authorization: `Bearer ${args.creds.session}`,
49
+ 'x-org-id': args.creds.organizationId,
50
+ 'x-workspace-id': args.creds.workspaceId,
51
+ 'content-type': 'application/json',
52
+ }
53
+ const call = async (path: string, init: { method?: string; body?: unknown } = {}) => {
54
+ const res = await fetch(new URL(path, args.serviceUrl), {
55
+ method: init.method ?? 'GET',
56
+ headers,
57
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
58
+ })
59
+ const text = await res.text()
60
+ let body: unknown = text
61
+ try {
62
+ body = text ? JSON.parse(text) : null
63
+ } catch {
64
+ // Keep the text.
65
+ }
66
+ return { status: res.status, body }
67
+ }
68
+
69
+ try {
70
+ const agent = await call(`/v1/config/agents/${encodeURIComponent(args.slug)}`)
71
+ const id = ((agent.body as { data?: { id?: string } })?.data ?? (agent.body as { id?: string }))?.id
72
+ if (agent.status === 404) {
73
+ return { ok: false, output: `error [NOT_FOUND]: no Agent ${args.slug}; add agents/${args.slug}/agent.json first` }
74
+ }
75
+ if (agent.status !== 200 || !id) return { ok: false, output: describeError(agent.status, agent.body) }
76
+ const put = await call(`/v1/config/agents/${id}/operating`, { method: 'PUT', body: { operatingConfig } })
77
+ if (put.status >= 400) return { ok: false, output: describeError(put.status, put.body) }
78
+ return { ok: true, output: '' }
79
+ } catch (err) {
80
+ return { ok: false, output: `error [UNAVAILABLE]: the local service did not answer: ${(err as Error).message}` }
81
+ }
82
+ }
@@ -0,0 +1,111 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { createServer } from 'node:net'
4
+ import { join } from 'node:path'
5
+
6
+ import { writeFileAtomic } from '../fs-atomic'
7
+ import { dataDir } from '../paths'
8
+
9
+ /**
10
+ * Sticky ports, one block per local runtime.
11
+ *
12
+ * Supabase's fixed defaults allow one project per machine; its newer stack
13
+ * keeps a per-user port registry instead, so a project's URLs stay the same
14
+ * across restarts while two projects can run side by side. Same here: each
15
+ * runtime claims a block of ten consecutive ports starting at 54100 and keeps
16
+ * it, recorded in `~/.local/share/frontera/local-ports.json`.
17
+ */
18
+
19
+ export type PortName = 'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces'
20
+
21
+ export type PortBlock = Record<PortName, number>
22
+
23
+ const BASE = 54100
24
+ const BLOCK = 10
25
+ const MAX_BLOCKS = 40
26
+ const OFFSETS: Record<PortName, number> = { studio: 0, service: 1, jobs: 2, runner: 3, postgres: 4, cache: 5, embeddings: 6, storage: 7, traces: 8 }
27
+
28
+ interface Registry {
29
+ version: 1
30
+ blocks: Record<string, { start: number; root: string }>
31
+ }
32
+
33
+ function registryPath(): string {
34
+ return join(dataDir(), 'local-ports.json')
35
+ }
36
+
37
+ function readRegistry(): Registry {
38
+ const path = registryPath()
39
+ if (!existsSync(path)) return { version: 1, blocks: {} }
40
+ try {
41
+ return JSON.parse(readFileSync(path, 'utf8')) as Registry
42
+ } catch {
43
+ return { version: 1, blocks: {} }
44
+ }
45
+ }
46
+
47
+ function blockFrom(start: number): PortBlock {
48
+ return {
49
+ studio: start + OFFSETS.studio,
50
+ service: start + OFFSETS.service,
51
+ jobs: start + OFFSETS.jobs,
52
+ runner: start + OFFSETS.runner,
53
+ postgres: start + OFFSETS.postgres,
54
+ cache: start + OFFSETS.cache,
55
+ embeddings: start + OFFSETS.embeddings,
56
+ storage: start + OFFSETS.storage,
57
+ traces: start + OFFSETS.traces,
58
+ }
59
+ }
60
+
61
+ /** The block this project already owns, or a fresh one nobody else has claimed. */
62
+ export function claimPorts(projectId: string, root: string): PortBlock {
63
+ const registry = readRegistry()
64
+ const existing = registry.blocks[projectId]
65
+ if (existing) return blockFrom(existing.start)
66
+ const taken = new Set(Object.values(registry.blocks).map((b) => b.start))
67
+ for (let i = 0; i < MAX_BLOCKS; i++) {
68
+ const start = BASE + i * BLOCK
69
+ if (taken.has(start)) continue
70
+ registry.blocks[projectId] = { start, root }
71
+ writeFileAtomic(registryPath(), `${JSON.stringify(registry, null, 2)}\n`, 0o644)
72
+ return blockFrom(start)
73
+ }
74
+ throw new Error('no free local port block left; remove stale entries from ~/.local/share/frontera/local-ports.json')
75
+ }
76
+
77
+ export function releasePorts(projectId: string): void {
78
+ const registry = readRegistry()
79
+ if (!registry.blocks[projectId]) return
80
+ delete registry.blocks[projectId]
81
+ writeFileAtomic(registryPath(), `${JSON.stringify(registry, null, 2)}\n`, 0o644)
82
+ }
83
+
84
+ /** Whether nothing is listening on 127.0.0.1:<port>. */
85
+ export function isPortFree(port: number): Promise<boolean> {
86
+ return new Promise((resolvePromise) => {
87
+ const server = createServer()
88
+ server.once('error', () => resolvePromise(false))
89
+ server.once('listening', () => server.close(() => resolvePromise(true)))
90
+ server.listen(port, '127.0.0.1')
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Who holds a port, as `command (pid N)`, so a conflict names its cause.
96
+ * Best effort: `lsof` is on every macOS and most Linux machines.
97
+ */
98
+ export function portHolder(port: number): string | null {
99
+ try {
100
+ const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'], {
101
+ encoding: 'utf8',
102
+ stdio: ['ignore', 'pipe', 'ignore'],
103
+ })
104
+ const pid = /^p(\d+)/m.exec(out)?.[1]
105
+ const cmd = /^c(.+)$/m.exec(out)?.[1]
106
+ if (!pid) return null
107
+ return `${cmd ?? 'unknown'} (pid ${pid})`
108
+ } catch {
109
+ return null
110
+ }
111
+ }
@@ -0,0 +1,106 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { openSync } from 'node:fs'
3
+
4
+ /**
5
+ * Native child processes owned by the supervisor.
6
+ *
7
+ * Each one writes stdout and stderr to its own log file, starts in its own
8
+ * process group so a stop reaches anything it spawned, and runs at a lower
9
+ * scheduling priority: a local runtime shares the laptop with an editor, a
10
+ * browser and whatever else the FDE is doing, and should never be the reason
11
+ * those stutter.
12
+ */
13
+
14
+ export interface ProcessSpec {
15
+ name: string
16
+ command: string
17
+ args: string[]
18
+ cwd: string
19
+ env: Record<string, string>
20
+ logFile: string
21
+ /** `nice` increment; 10 keeps the runtime behind interactive work. */
22
+ niceness?: number
23
+ }
24
+
25
+ export function startProcess(spec: ProcessSpec): ChildProcess {
26
+ const fd = openSync(spec.logFile, 'a')
27
+ const niceness = spec.niceness ?? 10
28
+ const useNice = process.platform !== 'win32' && niceness > 0
29
+ const child = spawn(
30
+ useNice ? 'nice' : spec.command,
31
+ useNice ? ['-n', String(niceness), spec.command, ...spec.args] : spec.args,
32
+ {
33
+ cwd: spec.cwd,
34
+ env: { ...minimalEnv(), ...spec.env },
35
+ stdio: ['ignore', fd, fd],
36
+ detached: true,
37
+ },
38
+ )
39
+ return child
40
+ }
41
+
42
+ /** Stop a process group: SIGTERM, then SIGKILL after a grace period. */
43
+ export async function stopProcess(pid: number | undefined, graceMs = 8000): Promise<void> {
44
+ if (!pid) return
45
+ const signal = (sig: NodeJS.Signals) => {
46
+ try {
47
+ process.kill(-pid, sig)
48
+ } catch {
49
+ try {
50
+ process.kill(pid, sig)
51
+ } catch {
52
+ // Already gone.
53
+ }
54
+ }
55
+ }
56
+ signal('SIGTERM')
57
+ const deadline = Date.now() + graceMs
58
+ while (Date.now() < deadline) {
59
+ if (!alive(pid)) return
60
+ await Bun.sleep(200)
61
+ }
62
+ signal('SIGKILL')
63
+ }
64
+
65
+ function alive(pid: number): boolean {
66
+ try {
67
+ process.kill(pid, 0)
68
+ return true
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The environment a runtime process inherits.
76
+ *
77
+ * Deliberately not the whole parent environment: an FDE's shell carries
78
+ * DATABASE_URL, NODE_ENV and provider keys for other projects, and a local
79
+ * runtime that silently picked those up would test against the wrong database.
80
+ * Only what a process needs to find its tools crosses over.
81
+ */
82
+ function minimalEnv(): Record<string, string> {
83
+ const keep = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR', 'SHELL', 'TERM', 'BUN_INSTALL']
84
+ const env: Record<string, string> = {}
85
+ for (const key of keep) {
86
+ const value = process.env[key]
87
+ if (value) env[key] = value
88
+ }
89
+ return env
90
+ }
91
+
92
+ /** Poll an HTTP URL until it answers 2xx or the deadline passes. */
93
+ export async function waitForHttp(url: string, timeoutMs: number, isAliveCheck?: () => boolean): Promise<boolean> {
94
+ const deadline = Date.now() + timeoutMs
95
+ while (Date.now() < deadline) {
96
+ if (isAliveCheck && !isAliveCheck()) return false
97
+ try {
98
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) })
99
+ if (res.ok) return true
100
+ } catch {
101
+ // Not up yet.
102
+ }
103
+ await Bun.sleep(500)
104
+ }
105
+ return false
106
+ }
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { basename, dirname, join, resolve } from 'node:path'
4
+
5
+ /**
6
+ * Which local runtime a directory belongs to.
7
+ *
8
+ * A local runtime is owned by a project directory, the way a Supabase stack is
9
+ * owned by the directory holding `supabase/config.toml`. The marker is a
10
+ * `.frontera/` folder (written by `frontera init`, `auth use` and `start`) or a
11
+ * `frontera.yaml` (`frontera.solution.yaml` in projects made before it was
12
+ * renamed); the nearest one walking up wins, so running a
13
+ * command from a sub-folder reaches the same runtime. With neither present the
14
+ * current directory becomes the root — `frontera start` in an empty folder is a
15
+ * valid way to begin.
16
+ */
17
+ export interface LocalProject {
18
+ /** Absolute path of the project directory. */
19
+ root: string
20
+ /** Stable, filesystem- and docker-safe id: `<slug>-<hash8>`. */
21
+ id: string
22
+ /** `<root>/.frontera/local`: state, logs, secrets for this runtime. */
23
+ stateDir: string
24
+ }
25
+
26
+ /** The project file, under its current name first and its earlier one after. */
27
+ export const PROJECT_FILES = ['frontera.yaml', 'frontera.solution.yaml'] as const
28
+ const MARKERS = [...PROJECT_FILES, '.frontera'] as const
29
+
30
+ export function findSolutionRoot(cwd: string): string {
31
+ let dir = resolve(cwd)
32
+ for (;;) {
33
+ if (MARKERS.some((m) => existsSync(join(dir, m)))) return dir
34
+ const parent = dirname(dir)
35
+ if (parent === dir) return resolve(cwd)
36
+ dir = parent
37
+ }
38
+ }
39
+
40
+ export function slugify(name: string): string {
41
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
42
+ // Part of a runtime's id and container names: keep it stable across releases.
43
+ return (slug || 'solution').slice(0, 24)
44
+ }
45
+
46
+ export function localProject(cwd: string): LocalProject {
47
+ const root = findSolutionRoot(cwd)
48
+ const hash = createHash('sha256').update(root).digest('hex').slice(0, 8)
49
+ return {
50
+ root,
51
+ id: `${slugify(basename(root))}-${hash}`,
52
+ stateDir: join(root, '.frontera', 'local'),
53
+ }
54
+ }