@frontera-sdk/cli 1.52.23 → 1.52.25
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,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
|
+
}
|