@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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.23",
3
+ "version": "1.52.25",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.52.23",
43
- "@frontera-sdk/core": "1.52.23",
42
+ "@frontera-sdk/functions": "1.52.25",
43
+ "@frontera-sdk/core": "1.52.25",
44
44
  "ai": "^6.0.116",
45
45
  "gray-matter": "^4.0.3",
46
46
  "yaml": "^2.9.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.52.23",
49
+ "@frontera-sdk/forge-contracts": "1.52.25",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -117,10 +117,14 @@ export class DatasetApi {
117
117
  // to re-read their file sends them to the wrong place entirely: the
118
118
  // service dials the database itself, from wherever it runs.
119
119
  hint: detail
120
- ?? (response.status === 409
121
- ? 'the service dials the database itself — check host and port are reachable '
122
- + 'from where the service runs, and that the password on stdin is current'
123
- : 'check the document against `frontera source create --help`'),
120
+ ?? (response.status === 409 && /already in use|already exists/i.test(payload?.message ?? '')
121
+ // The same status for a name that is taken, which no connection check fixes.
122
+ ? 'pick another apiName, or load new rows into the existing dataset with '
123
+ + '`frontera dataset upload <apiName> <file.csv>`'
124
+ : response.status === 409
125
+ ? 'the service dials the database itself — check host and port are reachable '
126
+ + 'from where the service runs, and that the password on stdin is current'
127
+ : 'check the document against `frontera source create --help`'),
124
128
  })
125
129
  }
126
130
  return (payload?.data ?? payload) as T
@@ -35,6 +35,9 @@ export const AUTOMATION_SDK_FILES = [
35
35
  // in, shared with the runner.
36
36
  'frontera/automation/messages.ts',
37
37
  'frontera/automation/testing.ts',
38
+ // `testing.ts` imports the file-handle helpers from it; without it every
39
+ // scaffolded project failed to build with `Could not resolve: "./runtime-context"`.
40
+ 'frontera/automation/runtime-context.ts',
38
41
  // `inputs.ts` is authoring surface too: `validateInputValue` is what a
39
42
  // handler's own tests and the Console call to check a value against the
40
43
  // manifest's `inputs` schema, and `index.ts` re-exports it — so omitting it
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, extname, join } from 'node:path'
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+ import { BLUEPRINT_DATA_TYPES } from '../blueprint-types'
4
5
  import { CliError } from '../errors'
5
6
  import {
6
7
  ARTIFACT_KINDS,
@@ -47,6 +48,9 @@ export function readTree(root: string): AuthoredFile[] {
47
48
  for (const entry of readdirSync(directory).sort()) {
48
49
  const extension = extname(entry)
49
50
  if (!READABLE.has(extension)) continue
51
+ // Hidden files are not the author's: an editor's lock (Emacs `.#Name.yaml`
52
+ // is a dangling symlink) or swap file would otherwise fail the whole tree.
53
+ if (entry.startsWith('.') || entry.startsWith('#')) continue
50
54
  const relative = `${KIND_DIRECTORY[kind]}/${entry}`
51
55
  const document = parseDocument(relative, readFileSync(join(directory, entry), 'utf8'))
52
56
  const stem = entry.slice(0, -extension.length)
@@ -66,12 +70,35 @@ export function readTree(root: string): AuthoredFile[] {
66
70
  },
67
71
  )
68
72
  }
73
+ checkValueTypes(kind, relative, document)
69
74
  files.push({ kind, apiName, path: relative, document })
70
75
  }
71
76
  }
72
77
  return files
73
78
  }
74
79
 
80
+ /**
81
+ * A value type the service does not know is refused here, naming the file,
82
+ * the field and the types there are. Left to the service it surfaced as
83
+ * "command — Expected union value", with neither the file nor the field.
84
+ */
85
+ function checkValueTypes(kind: ArtifactKind, path: string, document: Record<string, unknown>): void {
86
+ const known = BLUEPRINT_DATA_TYPES as readonly string[]
87
+ const check = (field: string, valueType: unknown) => {
88
+ if (valueType === undefined || (typeof valueType === 'string' && known.includes(valueType))) return
89
+ throw new CliError(`${path}: ${field} has valueType "${String(valueType)}", which is not a Blueprint value type.`, {
90
+ code: 'USAGE',
91
+ hint: `Use one of: ${known.join(', ')}.`,
92
+ })
93
+ }
94
+ if (kind === 'object-type' && Array.isArray(document.properties)) {
95
+ for (const property of document.properties as Array<Record<string, unknown>>) {
96
+ check(`property "${String(property?.apiName ?? '?')}"`, property?.valueType)
97
+ }
98
+ }
99
+ if (kind === 'shared-field') check('the shared field', document.valueType)
100
+ }
101
+
75
102
  /**
76
103
  * Delete a file the tree should no longer contain.
77
104
  *
@@ -0,0 +1,64 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import { CliError } from '../../errors'
4
+ import { localProject } from '../../local/local-project'
5
+ import { isAlive, readState } from '../../local/local-state'
6
+ import { flagBool, type Command } from '../types'
7
+
8
+ /**
9
+ * `frontera local check`: the whole loop as one command with one exit code,
10
+ * for a pre-push hook or CI (like `supabase start` then `supabase test`):
11
+ * start the runtime if it is not running, apply the solution, run the
12
+ * Agents' test cases. `--stop` stops the runtime afterwards.
13
+ */
14
+
15
+ /** Run this CLI once more, its output passed straight through. */
16
+ function runSelf(cwd: string, args: string[]): Promise<number> {
17
+ const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
18
+ const self = compiled ? [process.execPath] : [process.execPath, Bun.main]
19
+ return new Promise((resolve) => {
20
+ const child = spawn(self[0]!, [...self.slice(1), ...args], { cwd, stdio: ['ignore', 'inherit', 'inherit'] })
21
+ child.on('error', () => resolve(1))
22
+ child.on('close', (code) => resolve(code ?? 1))
23
+ })
24
+ }
25
+
26
+ export const localCheck: Command = {
27
+ meta: {
28
+ noun: 'local',
29
+ verb: 'check',
30
+ args: [],
31
+ flags: { stop: 'boolean' },
32
+ summary: 'Start the runtime if needed, apply the project and run its test cases, with one exit code',
33
+ examples: ['frontera local check', 'frontera local check --stop'],
34
+ offline: true,
35
+ internal: true,
36
+ },
37
+
38
+ async run(ctx) {
39
+ const project = localProject(ctx.cwd)
40
+ const state = readState(project)
41
+ const running = state?.phase === 'ready' && !!state.supervisorPid && isAlive(state.supervisorPid)
42
+ const steps: Array<{ label: string; args: string[] }> = [
43
+ ...(running ? [] : [{ label: 'start', args: ['local', 'start'] }]),
44
+ { label: 'apply', args: ['local', 'dev', '--once'] },
45
+ { label: 'test', args: ['local', 'test'] },
46
+ ]
47
+ let failed: string | null = null
48
+ for (const step of steps) {
49
+ ctx.output.note(`── frontera ${step.args.join(' ')}`)
50
+ if ((await runSelf(project.root, step.args)) !== 0) {
51
+ failed = step.label
52
+ break
53
+ }
54
+ }
55
+ if (flagBool(ctx, 'stop')) {
56
+ ctx.output.note('── frontera local stop')
57
+ await runSelf(project.root, ['local', 'stop'])
58
+ }
59
+ if (failed) {
60
+ throw new CliError(`the check failed at ${failed}`, { code: 'LOCAL_CHECK_FAILED', hint: 'the output above names the cause' })
61
+ }
62
+ return { data: { passed: true }, text: 'Check passed: the project applies and its test cases pass.' }
63
+ },
64
+ }
@@ -0,0 +1,301 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+ import { existsSync, lstatSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'
3
+ import { join, sep } from 'node:path'
4
+
5
+ import { CliError } from '../../errors'
6
+ import { contentHash, loadHashes, saveHashes } from '../../local/local-applied'
7
+ import { localProfileName } from '../../local/local-control'
8
+ import {
9
+ allTargets,
10
+ blueprintObjectTypes,
11
+ byApplyOrder,
12
+ commandFor,
13
+ fallbackFor,
14
+ describeTarget,
15
+ followUpsFor,
16
+ targetFor,
17
+ type SyncProfiles,
18
+ type SyncTarget,
19
+ } from '../../local/local-dev-sync'
20
+ import { applyOperatingConfig } from '../../local/local-operating'
21
+ import { localProject, type LocalProject } from '../../local/local-project'
22
+ import { isAlive, readState, recordApplied } from '../../local/local-state'
23
+ import { applyErrorText } from '../../local/local-apply-error'
24
+ import { flagBool, type Command, type CommandContext } from '../types'
25
+
26
+ /**
27
+ * `frontera local dev`: the inner loop. Applies the whole project to the
28
+ * local runtime once, then watches the folder and applies each saved file with
29
+ * the command an FDE would run by hand (`agent apply`, `workflow push`,
30
+ * `function deploy`, `blueprint apply`), one at a time, through the local
31
+ * profiles only. A Blueprint change is published and granted to the local
32
+ * workspace as well, unless `--no-publish` keeps it as a draft.
33
+ */
34
+
35
+ const DEBOUNCE_MS = 300
36
+
37
+ interface RunResult {
38
+ ok: boolean
39
+ output: string
40
+ }
41
+
42
+ /** The commands running now, so stopping `local dev` stops them too. */
43
+ const children = new Set<ChildProcess>()
44
+
45
+ function stopChildren(): void {
46
+ for (const child of children) child.kill('SIGTERM')
47
+ }
48
+
49
+ /** The local runtime's address and its keys by profile name, from the project's own state. */
50
+ interface LocalAuth {
51
+ serviceUrl: string
52
+ keys: ReadonlyMap<string, string>
53
+ }
54
+
55
+ /**
56
+ * A command's credentials go in its environment (`FRONTERA_TOKEN` with
57
+ * `FRONTERA_API_URL`), taken from the project's state, in place of its
58
+ * `--profile`. So applying never reads or writes the person's CLI config or
59
+ * Keychain, and works inside a coding agent's sandbox (Codex's, Claude
60
+ * Code's), which allows the project folder and nothing else.
61
+ */
62
+ function withLocalAuth(args: string[], auth: LocalAuth | undefined): { args: string[]; env: Record<string, string> } {
63
+ const at = args.indexOf('--profile')
64
+ const key = at >= 0 ? auth?.keys.get(args[at + 1] ?? '') : undefined
65
+ if (!auth || at < 0 || !key) return { args, env: {} }
66
+ return { args: [...args.slice(0, at), ...args.slice(at + 2)], env: { FRONTERA_TOKEN: key, FRONTERA_API_URL: auth.serviceUrl } }
67
+ }
68
+
69
+ /** Run this CLI once more, at low priority, in the solution folder. */
70
+ function runCli(project: LocalProject, command: string[], auth?: LocalAuth): Promise<RunResult> {
71
+ const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
72
+ const self = compiled ? [process.execPath] : [process.execPath, Bun.main]
73
+ const { args, env } = withLocalAuth(command, auth)
74
+ return new Promise((resolve) => {
75
+ const child = spawn('nice', ['-n', '10', ...self, ...args], {
76
+ cwd: project.root,
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ env: { ...process.env, ...env, NO_COLOR: '1' },
79
+ })
80
+ children.add(child)
81
+ let output = ''
82
+ child.stdout.on('data', (chunk) => (output += chunk))
83
+ child.stderr.on('data', (chunk) => (output += chunk))
84
+ child.on('error', (err) => resolve({ ok: false, output: err.message }))
85
+ child.on('close', (code) => {
86
+ children.delete(child)
87
+ resolve({ ok: code === 0, output })
88
+ })
89
+ })
90
+ }
91
+
92
+ interface ApplyOptions {
93
+ profiles: SyncProfiles
94
+ auth: LocalAuth
95
+ publish: boolean
96
+ workspaceId: string
97
+ serviceUrl: string
98
+ }
99
+
100
+ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptions): Promise<RunResult> {
101
+ const { profiles } = opts
102
+ if (target.kind === 'operating') {
103
+ // Read fresh: the owner session is renewed on every runtime start.
104
+ const creds = readState(project)?.credentials
105
+ if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
106
+ return applyOperatingConfig({ serviceUrl: opts.serviceUrl, creds, slug: target.slug, file: join(project.root, target.file) })
107
+ }
108
+ let result = await runCli(project, commandFor(target, profiles), opts.auth)
109
+ const fallback = fallbackFor(target, profiles, project.root)
110
+ if (!result.ok && fallback?.when.test(result.output)) {
111
+ // An empty fallback means there was nothing more to do.
112
+ result = { ok: true, output: '' }
113
+ for (const args of fallback.then) {
114
+ result = await runCli(project, args, opts.auth)
115
+ if (!result.ok) return result
116
+ }
117
+ }
118
+ if (!result.ok) return result
119
+ const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(project.root) : []
120
+ for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes })) {
121
+ const done = await runCli(project, step.args, opts.auth)
122
+ if (!done.ok && !step.alreadyDone?.test(done.output)) return done
123
+ }
124
+ return result
125
+ }
126
+
127
+ async function dev(ctx: CommandContext) {
128
+ const project = localProject(ctx.cwd)
129
+ const state = readState(project)
130
+ if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) {
131
+ throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
132
+ }
133
+ // The profile was registered when the runtime started; applying needs only the keys in the project's state.
134
+ const workspace = localProfileName(project)
135
+ const profiles: SyncProfiles = { workspace, organization: `${workspace}-org`.slice(0, 64) }
136
+ const creds = state.credentials!
137
+ const serviceUrl = `http://127.0.0.1:${state.ports.service}`
138
+ const options: ApplyOptions = {
139
+ profiles,
140
+ auth: {
141
+ serviceUrl,
142
+ keys: new Map([...(creds.workspaceKey ? [[profiles.workspace, creds.workspaceKey] as const] : []), [profiles.organization, creds.orgKey] as const]),
143
+ },
144
+ publish: !flagBool(ctx, 'no-publish'),
145
+ workspaceId: state.credentials!.workspaceId,
146
+ serviceUrl,
147
+ }
148
+
149
+ const organizationId = state.credentials!.organizationId
150
+ const applied = flagBool(ctx, 'reapply') ? new Map<string, string | null>() : loadHashes(project, organizationId)
151
+ let unchanged = 0
152
+ const pending = new Map<string, SyncTarget>()
153
+ // Files refused while something they depend on was missing; tried again after the next success.
154
+ const failed = new Map<string, SyncTarget>()
155
+ let failures = 0
156
+ let busy = false
157
+ let stopping = false
158
+ let timer: ReturnType<typeof setTimeout> | null = null
159
+
160
+ const runOne = async (target: SyncTarget) => {
161
+ if (stopping) return
162
+ if (target.kind !== 'blueprint' && !existsSync(join(project.root, target.file))) return
163
+ const hash = contentHash(project, target)
164
+ if (hash && applied.get(target.key) === hash) {
165
+ unchanged += 1
166
+ return
167
+ }
168
+ const started = Date.now()
169
+ const result = await apply(project, target, options)
170
+ if (stopping) return
171
+ const ms = Date.now() - started
172
+ recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: result.ok })
173
+ if (result.ok) {
174
+ // A draft-only Blueprint is not what a plain `local dev` would leave, so it is not recorded as applied.
175
+ if (target.kind === 'blueprint' && !options.publish) applied.delete(target.key)
176
+ else applied.set(target.key, hash)
177
+ saveHashes(project, organizationId, applied)
178
+ failed.delete(target.key)
179
+ ctx.output.note(` ✓ ${describeTarget(target)} applied (${ms} ms)`)
180
+ for (const [key, retry] of failed) if (!pending.has(key)) pending.set(key, retry)
181
+ failed.clear()
182
+ } else {
183
+ failures += 1
184
+ failed.set(target.key, target)
185
+ ctx.output.note(` ✕ ${describeTarget(target)}: ${applyErrorText(result.output)}`)
186
+ }
187
+ }
188
+
189
+ /** Apply what is queued, dependencies first, one at a time. Never throws: a bad file must not end the loop. */
190
+ const drain = async () => {
191
+ if (busy) return
192
+ busy = true
193
+ try {
194
+ while (pending.size && !stopping) {
195
+ const next = [...pending.values()].sort(byApplyOrder)[0]!
196
+ pending.delete(next.key)
197
+ try {
198
+ await runOne(next)
199
+ } catch (err) {
200
+ ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
201
+ }
202
+ }
203
+ } finally {
204
+ busy = false
205
+ }
206
+ }
207
+
208
+ const schedule = () => {
209
+ if (timer) clearTimeout(timer)
210
+ timer = setTimeout(() => void drain(), DEBOUNCE_MS)
211
+ }
212
+
213
+ /** What a changed path means: one file, every Function (a helper or the SDK changed), or a moved folder. */
214
+ const queuePath = (rel: string) => {
215
+ const absolute = join(project.root, rel)
216
+ const direct = targetFor(project.root, absolute)
217
+ if (direct) {
218
+ pending.set(direct.key, direct)
219
+ return
220
+ }
221
+ const top = rel.split(sep)[0]
222
+ const isDir = (() => {
223
+ try {
224
+ return lstatSync(absolute).isDirectory()
225
+ } catch {
226
+ return false
227
+ }
228
+ })()
229
+ const helper = (top === 'functions' || top === 'vendor') && /\.ts$/.test(rel) && !rel.endsWith('.test.ts')
230
+ if (!isDir && !helper) return
231
+ for (const target of allTargets(project.root)) {
232
+ const path = target.kind === 'blueprint' ? 'blueprint' : target.file
233
+ if (helper ? target.kind === 'function' : path === rel || path.startsWith(rel + sep)) pending.set(target.key, target)
234
+ }
235
+ }
236
+
237
+ // Watch from the start, so a save during the first sync is not missed.
238
+ const once = flagBool(ctx, 'once')
239
+ // One watcher per project (Forge keeps one running; a second would apply every change twice). `--once` is always allowed.
240
+ const pidFile = join(project.root, '.frontera', 'local', 'dev.pid')
241
+ if (!once) {
242
+ const other = existsSync(pidFile) ? Number(readFileSync(pidFile, 'utf8').trim()) : 0
243
+ if (other && other !== process.pid && isAlive(other)) {
244
+ throw new CliError(`\`frontera local dev\` is already watching this project (pid ${other})`, {
245
+ code: 'LOCAL_DEV_RUNNING',
246
+ hint: 'saved files are already being applied; run `frontera local dev --once` to apply now',
247
+ })
248
+ }
249
+ writeFileSync(pidFile, String(process.pid))
250
+ }
251
+ const watcher = once
252
+ ? null
253
+ : watch(project.root, { recursive: true }, (_event, filename) => {
254
+ if (!filename || stopping) return
255
+ queuePath(filename.toString())
256
+ if (!busy) schedule()
257
+ })
258
+
259
+ const targets = allTargets(project.root)
260
+ ctx.output.note(targets.length ? `Applying ${targets.length} item${targets.length === 1 ? '' : 's'} to the local runtime` : 'Nothing to apply yet')
261
+ for (const target of targets) pending.set(target.key, target)
262
+ await drain()
263
+ if (unchanged) ctx.output.note(` ${unchanged} unchanged since the last apply (--reapply applies them again)`)
264
+
265
+ if (once) {
266
+ if (failures) throw new CliError(`${failures} of ${targets.length} failed to apply`, { code: 'LOCAL_APPLY_FAILED', hint: 'fix the files above and run again' })
267
+ const done = targets.length - unchanged
268
+ return { data: { applied: done, unchanged }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
269
+ }
270
+
271
+ ctx.output.note('Watching datasets/, blueprint/, functions/, workflows/ and agents/ (Ctrl+C to stop)')
272
+ if (pending.size) schedule()
273
+
274
+ await new Promise<void>((resolve) => {
275
+ const stop = () => {
276
+ stopping = true
277
+ rmSync(pidFile, { force: true })
278
+ if (timer) clearTimeout(timer)
279
+ watcher?.close()
280
+ stopChildren()
281
+ resolve()
282
+ }
283
+ process.once('SIGINT', stop)
284
+ process.once('SIGTERM', stop)
285
+ })
286
+ return { data: { applied: applied.size }, text: 'Stopped watching' }
287
+ }
288
+
289
+ export const localDev: Command = {
290
+ meta: {
291
+ noun: 'local',
292
+ verb: 'dev',
293
+ args: [],
294
+ flags: { once: 'boolean', 'no-publish': 'boolean', reapply: 'boolean' },
295
+ summary: 'Apply the project to the local runtime, then apply each saved file',
296
+ examples: ['frontera local dev', 'frontera local dev --once', 'frontera local dev --no-publish', 'frontera local dev --once --reapply'],
297
+ offline: true,
298
+ internal: true,
299
+ },
300
+ run: dev,
301
+ }
@@ -0,0 +1,36 @@
1
+ import { docsCopyPath, serveDocsMcp } from '../../local/local-docs'
2
+ import { localProject } from '../../local/local-project'
3
+ import { cliSchema, readKeptSchema } from '../../local/local-schemas'
4
+ import type { Command } from '../types'
5
+
6
+ /**
7
+ * Frontera's documentation as an MCP server on stdio, for the coding agents
8
+ * working in this project. `local init` registers it in `.mcp.json` (Claude
9
+ * Code) and `.cursor/mcp.json`; Codex takes the same command. It reads the
10
+ * documentation `local start` copied for the release the runtime runs, and
11
+ * the formats of the project's files that start kept, so it needs no key and
12
+ * keeps working while the runtime is stopped. Nothing but protocol goes to
13
+ * stdout.
14
+ */
15
+ export const localDocsMcp: Command = {
16
+ meta: {
17
+ noun: 'local',
18
+ verb: 'docs-mcp',
19
+ args: [],
20
+ flags: {},
21
+ summary: "Serve Frontera's documentation to coding agents over MCP (stdio), for the local runtime's release",
22
+ examples: ['frontera local docs-mcp'],
23
+ offline: true,
24
+ internal: true,
25
+ },
26
+ async run(ctx) {
27
+ const project = localProject(ctx.cwd)
28
+ // Not started yet: it still speaks MCP, and each tool call says what to do.
29
+ await serveDocsMcp({
30
+ docsPath: docsCopyPath(project),
31
+ schemaFor: (kind) => cliSchema(kind) ?? readKeptSchema(project, kind),
32
+ })
33
+ // The stream is the protocol: exit here, before the CLI renders a result onto it.
34
+ process.exit(0)
35
+ },
36
+ }