@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.
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,165 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import type { LocalProject } from './local-project'
6
+
7
+ /**
8
+ * The one document that says what a local runtime is doing.
9
+ *
10
+ * The supervisor owns it and rewrites it on every transition; every other
11
+ * command only reads it. That single-writer rule is what lets `status`,
12
+ * `stop` and Studio agree without talking to each other: they all look at the
13
+ * same file, and the supervisor's pid in it says whether anyone is home.
14
+ */
15
+
16
+ export type ComponentName = 'postgres' | 'storage' | 'service' | 'jobs' | 'runner' | 'studio'
17
+
18
+ export type ComponentStatus = 'off' | 'starting' | 'running' | 'stopped' | 'failed'
19
+
20
+ export interface ComponentState {
21
+ name: ComponentName
22
+ status: ComponentStatus
23
+ /** How it runs: a container, a native process from source, or the supervisor itself. */
24
+ kind: 'container' | 'process' | 'supervisor'
25
+ port?: number
26
+ pid?: number
27
+ container?: string
28
+ /** Why it is off or failed, in words a person can act on. */
29
+ detail?: string
30
+ startedAt?: string
31
+ }
32
+
33
+ export interface LocalCredentials {
34
+ email: string
35
+ organizationId: string
36
+ workspaceId: string
37
+ workspaceSlug?: string
38
+ /** `sk-org-…`: the CLI's organization profile (Blueprint, datasets) uses it. */
39
+ orgKey: string
40
+ /**
41
+ * `owner` when `orgKey` carries every capability the owner may delegate.
42
+ * Runtimes seeded before that have a narrower key, replaced on next start.
43
+ */
44
+ orgKeyGrant?: 'owner'
45
+ /** `sk-ws-…` scoped to the seeded workspace. */
46
+ workspaceKey?: string
47
+ /** The owner's session, refreshed on every start; Studio's API proxy acts as this person. */
48
+ session?: string
49
+ /** Providers whose keys were stored on the organization at the last start. */
50
+ modelProviders?: string[]
51
+ }
52
+
53
+ export interface LocalState {
54
+ version: 1
55
+ projectId: string
56
+ root: string
57
+ /** `source` runs the platform from a checkout; `image` runs release images. */
58
+ mode: 'source' | 'image'
59
+ platformVersion: string
60
+ supervisorPid?: number
61
+ phase: 'starting' | 'seeding' | 'ready' | 'stopping' | 'stopped' | 'failed'
62
+ error?: string
63
+ startedAt: string
64
+ updatedAt: string
65
+ ports: Record<'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces', number>
66
+ components: ComponentState[]
67
+ credentials?: LocalCredentials
68
+ }
69
+
70
+ export function statePath(project: LocalProject): string {
71
+ return join(project.stateDir, 'state.json')
72
+ }
73
+
74
+ export function logsDir(project: LocalProject): string {
75
+ return join(project.stateDir, 'logs')
76
+ }
77
+
78
+ export function logPath(project: LocalProject, component: ComponentName): string {
79
+ return join(logsDir(project), `${component}.log`)
80
+ }
81
+
82
+ export function ensureStateDir(project: LocalProject): void {
83
+ mkdirSync(logsDir(project), { recursive: true })
84
+ // The state dir holds a password and keys for a disposable local runtime.
85
+ // Not production secrets, but nobody else on the machine needs to read them.
86
+ try {
87
+ chmodSync(project.stateDir, 0o700)
88
+ } catch {
89
+ // Best effort; a filesystem without modes still works.
90
+ }
91
+ const ignore = join(project.root, '.frontera', '.gitignore')
92
+ if (!existsSync(ignore)) writeFileAtomic(ignore, 'local/\n', 0o644)
93
+ }
94
+
95
+ export function readState(project: LocalProject): LocalState | null {
96
+ const path = statePath(project)
97
+ if (!existsSync(path)) return null
98
+ try {
99
+ return JSON.parse(readFileSync(path, 'utf8')) as LocalState
100
+ } catch {
101
+ return null
102
+ }
103
+ }
104
+
105
+ export function writeState(project: LocalProject, state: LocalState): void {
106
+ ensureStateDir(project)
107
+ writeFileAtomic(statePath(project), `${JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2)}\n`, 0o600)
108
+ }
109
+
110
+ export function updateComponent(state: LocalState, next: ComponentState): LocalState {
111
+ const others = state.components.filter((c) => c.name !== next.name)
112
+ return { ...state, components: [...others, next].sort(byOrder) }
113
+ }
114
+
115
+ const ORDER: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
116
+
117
+ function byOrder(a: ComponentState, b: ComponentState): number {
118
+ return ORDER.indexOf(a.name) - ORDER.indexOf(b.name)
119
+ }
120
+
121
+ /** Whether a pid names a live process. Signal 0 checks without delivering anything. */
122
+ export function isAlive(pid: number | undefined): boolean {
123
+ if (!pid) return false
124
+ try {
125
+ process.kill(pid, 0)
126
+ return true
127
+ } catch (err) {
128
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
129
+ }
130
+ }
131
+
132
+ export function clearState(project: LocalProject): void {
133
+ rmSync(statePath(project), { force: true })
134
+ }
135
+
136
+ /**
137
+ * The last thing `frontera local dev` applied. Its own file rather than a
138
+ * field in `state.json`, which only the supervisor writes; Studio reads it with
139
+ * the state and refreshes its pages when it changes.
140
+ */
141
+ export interface LastApplied {
142
+ at: string
143
+ what: string
144
+ ok: boolean
145
+ }
146
+
147
+ function appliedPath(project: LocalProject): string {
148
+ return join(project.stateDir, 'last-applied.json')
149
+ }
150
+
151
+ export function recordApplied(project: LocalProject, entry: LastApplied): void {
152
+ try {
153
+ writeFileAtomic(appliedPath(project), `${JSON.stringify(entry)}\n`, 0o600)
154
+ } catch {
155
+ // A missed refresh hint must never fail an apply.
156
+ }
157
+ }
158
+
159
+ export function readApplied(project: LocalProject): LastApplied | null {
160
+ try {
161
+ return JSON.parse(readFileSync(appliedPath(project), 'utf8')) as LastApplied
162
+ } catch {
163
+ return null
164
+ }
165
+ }
@@ -0,0 +1,68 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, openSync, readdirSync, statSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import type { LocalConfig } from './local-env'
6
+
7
+ /**
8
+ * Where the built Studio app is.
9
+ *
10
+ * In order: an explicit override, the CLI's own vendored copy (a release
11
+ * build places it next to the CLI), then the platform checkout the runtime
12
+ * runs from. `null` makes the server answer with how to build it.
13
+ */
14
+ export function studioAssetsDir(config: LocalConfig | null): string | null {
15
+ const candidates = [
16
+ process.env.FRONTERA_STUDIO_DIST,
17
+ resolve(import.meta.dir, '..', 'vendor', 'studio'),
18
+ config?.platformSource ? join(config.platformSource, 'packages', 'studio', 'dist') : undefined,
19
+ resolve(import.meta.dir, '..', '..', '..', 'studio', 'dist'),
20
+ ]
21
+ for (const dir of candidates) {
22
+ if (dir && existsSync(join(dir, 'index.html'))) return dir
23
+ }
24
+ return null
25
+ }
26
+
27
+ /** The packages a Studio build bundles from a platform checkout. */
28
+ const STUDIO_SOURCES = ['packages/studio/src', 'packages/studio/index.html', 'packages/chat/src', 'packages/blueprint/src', 'packages/platform-kit/src']
29
+
30
+ function newestChange(path: string): number {
31
+ if (!existsSync(path)) return 0
32
+ const stat = statSync(path)
33
+ if (!stat.isDirectory()) return stat.mtimeMs
34
+ let newest = stat.mtimeMs
35
+ for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
36
+ if (!entry.isFile()) continue
37
+ const mtime = statSync(join(entry.parentPath, entry.name)).mtimeMs
38
+ if (mtime > newest) newest = mtime
39
+ }
40
+ return newest
41
+ }
42
+
43
+ /**
44
+ * True when the checkout's Studio build is missing or older than its sources,
45
+ * so a runtime started from the checkout would serve a stale Studio. Only for
46
+ * the checkout's own build: an override or a vendored copy is used as it is.
47
+ */
48
+ export function studioBuildStale(config: LocalConfig): boolean {
49
+ if (config.mode !== 'source' || !config.platformSource) return false
50
+ if (process.env.FRONTERA_STUDIO_DIST || existsSync(resolve(import.meta.dir, '..', 'vendor', 'studio', 'index.html'))) return false
51
+ const built = join(config.platformSource, 'packages', 'studio', 'dist', 'index.html')
52
+ if (!existsSync(built)) return true
53
+ const builtAt = statSync(built).mtimeMs
54
+ return STUDIO_SOURCES.some((source) => newestChange(join(config.platformSource!, source)) > builtAt)
55
+ }
56
+
57
+ /** Build Studio in the checkout at low priority, logging to `logFile`; resolves whether it built. */
58
+ export function buildStudio(platformSource: string, logFile: string): Promise<boolean> {
59
+ return new Promise((done) => {
60
+ const fd = openSync(logFile, 'a')
61
+ const child = spawn('nice', ['-n', '10', 'bun', 'run', 'build'], {
62
+ cwd: join(platformSource, 'packages', 'studio'),
63
+ stdio: ['ignore', fd, fd],
64
+ })
65
+ child.on('error', () => done(false))
66
+ child.on('exit', (code) => done(code === 0))
67
+ })
68
+ }
@@ -0,0 +1,304 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { existsSync, readFileSync, statSync } from 'node:fs'
3
+ import { extname, join, normalize } from 'node:path'
4
+
5
+ import type { LocalProject } from './local-project'
6
+ import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
7
+ import { solutionStatus } from './local-applied'
8
+ import { logPath, readApplied, readState, type ComponentName } from './local-state'
9
+ import { appendTestCase, renderTestCase } from './local-test-cases'
10
+ import { findCaseFiles } from './local-test-runner'
11
+ import { createTestRuns } from './local-test-runs'
12
+
13
+ /**
14
+ * Frontera Studio's server: the static app, the API, and the runtime's own
15
+ * status — one origin, served by the supervisor, as `mastra dev` serves Studio
16
+ * beside its API.
17
+ *
18
+ * Three rules shape it:
19
+ * - It binds to 127.0.0.1 only, and every request needs the session cookie
20
+ * that the printed `?token=` link sets. "No auth configured means public" is
21
+ * the pattern we are deliberately not copying.
22
+ * - The browser never sees a credential. `/api/*` is proxied to the local
23
+ * service with the seeded owner's session added here, so the API explorer
24
+ * and every Studio view call the real API exactly as the platform does.
25
+ * - The runtime's own data (`/__local/*`) is read from the supervisor's state
26
+ * file and log files, never from the service, so Studio still explains what
27
+ * is wrong when the service is the thing that is down.
28
+ */
29
+
30
+ const COOKIE = 'frontera_studio'
31
+
32
+ const MIME: Record<string, string> = {
33
+ '.html': 'text/html; charset=utf-8',
34
+ '.js': 'text/javascript; charset=utf-8',
35
+ '.css': 'text/css; charset=utf-8',
36
+ '.json': 'application/json; charset=utf-8',
37
+ '.svg': 'image/svg+xml',
38
+ '.png': 'image/png',
39
+ '.woff2': 'font/woff2',
40
+ '.woff': 'font/woff',
41
+ '.ico': 'image/x-icon',
42
+ '.map': 'application/json; charset=utf-8',
43
+ }
44
+
45
+ export interface StudioServerOptions {
46
+ project: LocalProject
47
+ port: number
48
+ serviceUrl: string
49
+ /** Where the built Studio app is (`index.html` + assets); asked per request, so a rebuild needs no restart. */
50
+ assetsDir: () => string | null
51
+ /** The session secret printed in the Studio link. */
52
+ sessionToken: string
53
+ }
54
+
55
+ export function newSessionToken(): string {
56
+ return randomBytes(24).toString('base64url')
57
+ }
58
+
59
+ function sameToken(a: string, b: string): boolean {
60
+ const ab = Buffer.from(a)
61
+ const bb = Buffer.from(b)
62
+ return ab.length === bb.length && timingSafeEqual(ab, bb)
63
+ }
64
+
65
+ function cookieToken(req: Request): string | null {
66
+ const header = req.headers.get('cookie') ?? ''
67
+ for (const part of header.split(';')) {
68
+ const [k, ...v] = part.trim().split('=')
69
+ if (k === COOKIE) return v.join('=')
70
+ }
71
+ return null
72
+ }
73
+
74
+ const LOG_COMPONENTS: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
75
+
76
+ function tail(path: string, lines: number): string[] {
77
+ if (!existsSync(path)) return []
78
+ const size = statSync(path).size
79
+ // Read at most the last 512 KiB: a long-running log must not be loaded whole.
80
+ const start = Math.max(0, size - 512 * 1024)
81
+ const buf = readFileSync(path).subarray(start)
82
+ return buf.toString('utf8').split('\n').filter(Boolean).slice(-lines)
83
+ }
84
+
85
+ export function startStudioServer(opts: StudioServerOptions) {
86
+ const { project } = opts
87
+ const testRuns = createTestRuns(project, opts.serviceUrl)
88
+
89
+ const json = (data: unknown, status = 200) =>
90
+ new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
91
+
92
+ const localRoute = (url: URL): Response => {
93
+ if (url.pathname === '/__local/state') {
94
+ const state = readState(project)
95
+ if (!state) return json({ error: 'no local runtime state' }, 404)
96
+ const { credentials, ...rest } = state
97
+ return json({
98
+ ...rest,
99
+ serviceUrl: opts.serviceUrl,
100
+ lastApplied: readApplied(project),
101
+ credentials: credentials
102
+ ? {
103
+ email: credentials.email,
104
+ organizationId: credentials.organizationId,
105
+ workspaceId: credentials.workspaceId,
106
+ workspaceSlug: credentials.workspaceSlug,
107
+ modelProviders: credentials.modelProviders ?? [],
108
+ }
109
+ : null,
110
+ })
111
+ }
112
+ // Each file `local dev` applies, and whether the runtime has its current content.
113
+ if (url.pathname === '/__local/solution') {
114
+ return json({ items: solutionStatus(project, readState(project)?.credentials?.organizationId ?? null) })
115
+ }
116
+ // The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
117
+ if (url.pathname === '/__local/test-cases') {
118
+ try {
119
+ return json({ files: findCaseFiles(project.root) })
120
+ } catch (err) {
121
+ return json({ error: `a case file could not be read: ${(err as Error).message}` }, 422)
122
+ }
123
+ }
124
+ if (url.pathname === '/__local/test-runs') return json({ runs: testRuns.list() })
125
+ const runMatch = /^\/__local\/test-runs\/([0-9a-f-]+)$/.exec(url.pathname)
126
+ if (runMatch) {
127
+ const run = testRuns.get(runMatch[1]!)
128
+ return run ? json(run) : json({ error: 'no such test run' }, 404)
129
+ }
130
+ const logMatch = /^\/__local\/logs\/([a-z]+)$/.exec(url.pathname)
131
+ if (logMatch) {
132
+ const component = logMatch[1] as ComponentName
133
+ if (!LOG_COMPONENTS.includes(component)) return json({ error: 'unknown component' }, 404)
134
+ const lines = Math.min(Number(url.searchParams.get('lines') ?? 300) || 300, 2000)
135
+ return json({ component, lines: tail(logPath(project, component), lines) })
136
+ }
137
+ return json({ error: 'not found' }, 404)
138
+ }
139
+
140
+ /** Save a conversation into the solution as a regression case (`agents/<slug>/evals/regression.yaml`). */
141
+ const saveTestCase = async (req: Request): Promise<Response> => {
142
+ const state = readState(project)
143
+ const creds = state?.credentials
144
+ if (!creds?.session) return json({ error: 'the runtime is not seeded yet' }, 409)
145
+ const body = (await req.json().catch(() => null)) as { agentId?: string; conversationId?: string } | null
146
+ if (!body?.agentId || !body.conversationId) return json({ error: 'agentId and conversationId are required' }, 400)
147
+ const headers = { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId }
148
+ const get = async (path: string) => {
149
+ const res = await fetch(new URL(path, opts.serviceUrl), { headers })
150
+ if (!res.ok) throw new Error(`${path} answered ${res.status}`)
151
+ const parsed = (await res.json()) as { data?: unknown }
152
+ return parsed.data
153
+ }
154
+ try {
155
+ const agent = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}`)) as { agentId?: string }
156
+ const messages = (await get(`/v1/sessions/${encodeURIComponent(body.conversationId)}/messages`)) as unknown
157
+ const list = Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])
158
+ const yaml = renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString() })
159
+ const path = appendTestCase(project.root, agent.agentId ?? body.agentId, yaml)
160
+ return json({ path })
161
+ } catch (err) {
162
+ return json({ error: (err as Error).message }, 502)
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The service's API reference on Studio's origin. The OpenAPI document's
168
+ * server is rewritten to `/api`, so the reference's "Send request" runs
169
+ * through the proxy as the local owner instead of asking for a key.
170
+ */
171
+ const apiReference = async (url: URL): Promise<Response> => {
172
+ const target = new URL(url.pathname + url.search, opts.serviceUrl)
173
+ try {
174
+ const res = await fetch(target)
175
+ if (url.pathname === '/docs/json' && res.ok) {
176
+ const spec = (await res.json()) as Record<string, unknown>
177
+ spec.servers = [{ url: `${url.origin}/api`, description: 'Local runtime, signed in as the local owner' }]
178
+ return json(spec)
179
+ }
180
+ const out = new Headers(res.headers)
181
+ out.delete('content-encoding')
182
+ out.delete('content-length')
183
+ return new Response(res.body, { status: res.status, headers: out })
184
+ } catch (err) {
185
+ return json({ error: 'the local service did not answer', detail: (err as Error).message }, 502)
186
+ }
187
+ }
188
+
189
+ const proxy = async (req: Request, url: URL): Promise<Response> => {
190
+ const state = readState(project)
191
+ // The shared packages give Better Auth's client `<origin>/api` as its base,
192
+ // which it reads as the auth base path; on a deployment those endpoints
193
+ // live under `/api/auth/`. Every other service route is `/v1/…`, `/api/…`,
194
+ // `/health` or `/docs`, so anything else is an auth endpoint.
195
+ let path = url.pathname.replace(/^\/api/, '') || '/'
196
+ if (!/^\/(v1|api|health|docs)(\/|$)/.test(path)) path = `/api/auth${path}`
197
+ const target = new URL(path + url.search, opts.serviceUrl)
198
+ const headers = new Headers(req.headers)
199
+ headers.delete('cookie')
200
+ headers.delete('host')
201
+ headers.delete('origin')
202
+ headers.delete('referer')
203
+ // Studio acts as the seeded owner: a session reaches every route a person
204
+ // can use, including the session-only ones an API key is refused on.
205
+ const bearer = state?.credentials?.session ?? state?.credentials?.workspaceKey
206
+ if (bearer && !headers.has('authorization')) headers.set('authorization', `Bearer ${bearer}`)
207
+ if (state?.credentials?.organizationId && !headers.has('x-org-id')) headers.set('x-org-id', state.credentials.organizationId)
208
+ if (state?.credentials?.workspaceId && !headers.has('x-workspace-id')) {
209
+ headers.set('x-workspace-id', state.credentials.workspaceId)
210
+ }
211
+ try {
212
+ const res = await fetch(target, {
213
+ method: req.method,
214
+ headers,
215
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
216
+ redirect: 'manual',
217
+ duplex: 'half',
218
+ })
219
+ const out = new Headers(res.headers)
220
+ out.delete('content-encoding')
221
+ out.delete('content-length')
222
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: out })
223
+ } catch (err) {
224
+ return json({ error: 'the local service did not answer', detail: (err as Error).message }, 502)
225
+ }
226
+ }
227
+
228
+ const asset = (url: URL): Response => {
229
+ const assetsDir = opts.assetsDir()
230
+ if (!assetsDir) {
231
+ return new Response(
232
+ '<!doctype html><title>Frontera Studio</title><body style="font-family:system-ui;padding:40px">' +
233
+ '<h1>Studio is not built</h1><p>Run <code>bun run --cwd packages/studio build</code> in the platform repository, then reload.</p>',
234
+ { status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } },
235
+ )
236
+ }
237
+ const rel = normalize(decodeURIComponent(url.pathname)).replace(/^(\.\.[/\\])+/, '')
238
+ let file = join(assetsDir, rel)
239
+ if (!file.startsWith(assetsDir) || !existsSync(file) || statSync(file).isDirectory()) {
240
+ file = join(assetsDir, 'index.html')
241
+ }
242
+ const type = MIME[extname(file)] ?? 'application/octet-stream'
243
+ const cache = file.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable'
244
+ return new Response(Bun.file(file), { headers: { 'content-type': type, 'cache-control': cache } })
245
+ }
246
+
247
+ return Bun.serve({
248
+ hostname: '127.0.0.1',
249
+ port: opts.port,
250
+ idleTimeout: 120,
251
+ async fetch(req) {
252
+ const url = new URL(req.url)
253
+
254
+ const offered = url.searchParams.get('token')
255
+ if (offered) {
256
+ if (!sameToken(offered, opts.sessionToken)) return new Response('Invalid Studio link', { status: 401 })
257
+ url.searchParams.delete('token')
258
+ return new Response(null, {
259
+ status: 302,
260
+ headers: {
261
+ location: url.pathname + (url.search || ''),
262
+ 'set-cookie': `${COOKIE}=${opts.sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
263
+ },
264
+ })
265
+ }
266
+
267
+ if (url.pathname === '/__local/health') return json({ ok: true })
268
+
269
+ const cookie = cookieToken(req)
270
+ if (!cookie || !sameToken(cookie, opts.sessionToken)) {
271
+ return new Response(
272
+ '<!doctype html><title>Frontera Studio</title><body style="font-family:system-ui;padding:40px">' +
273
+ '<h1>Open Studio from the CLI</h1><p>Run <code>frontera local ui</code>. It prints a link that signs this browser in.</p>',
274
+ { status: 401, headers: { 'content-type': 'text/html; charset=utf-8' } },
275
+ )
276
+ }
277
+
278
+ if (url.pathname === '/docs' || url.pathname.startsWith('/docs/')) return apiReference(url)
279
+ if (url.pathname === '/__local/test-cases' && req.method === 'POST') return saveTestCase(req)
280
+ const editMatch = /^\/__local\/agents\/([^/]+)\/file$/.exec(url.pathname)
281
+ if (editMatch && req.method === 'PUT') {
282
+ const organizationId = readState(project)?.credentials?.organizationId
283
+ if (!organizationId) return json({ error: 'the runtime is not seeded yet' }, 409)
284
+ const edit = (await req.json().catch(() => null)) as AgentFileEdit | null
285
+ if (!edit || typeof edit !== 'object') return json({ error: 'the edit must be a JSON object' }, 400)
286
+ try {
287
+ return json(await saveAgentEdit(project, organizationId, decodeURIComponent(editMatch[1]!), edit))
288
+ } catch (err) {
289
+ if (err instanceof AgentFileError) return json({ error: err.message }, err.status)
290
+ return json({ error: (err as Error).message }, 500)
291
+ }
292
+ }
293
+ if (url.pathname === '/__local/test-runs' && req.method === 'POST') {
294
+ const body = (await req.json().catch(() => ({}))) as { agent?: string; live?: boolean }
295
+ const started = testRuns.start({ agent: typeof body.agent === 'string' ? body.agent : undefined, live: body.live === true })
296
+ return 'run' in started ? json(started.run, 202) : json({ error: started.error }, started.status)
297
+ }
298
+ if (url.pathname.startsWith('/__local/')) return localRoute(url)
299
+ // `/api/…` is the service; `/api` alone is Studio's API page.
300
+ if (url.pathname.startsWith('/api/')) return proxy(req, url)
301
+ return asset(url)
302
+ },
303
+ })
304
+ }