@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.52.
|
|
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.
|
|
43
|
-
"@frontera-sdk/core": "1.52.
|
|
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.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.52.25",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
package/src/api/dataset-api.ts
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
package/src/blueprint/tree.ts
CHANGED
|
@@ -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
|
+
}
|