@frontera-sdk/cli 1.52.27 → 1.52.29

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 (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.27",
3
+ "version": "1.52.29",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,15 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.52.27",
43
- "@frontera-sdk/core": "1.52.27",
42
+ "@faker-js/faker": "10.6.0",
43
+ "@frontera-sdk/core": "1.52.29",
44
+ "@frontera-sdk/functions": "1.52.29",
44
45
  "ai": "^6.0.116",
45
46
  "gray-matter": "^4.0.3",
46
47
  "yaml": "^2.9.0"
47
48
  },
48
49
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.52.27",
50
+ "@frontera-sdk/forge-contracts": "1.52.29",
50
51
  "@types/bun": "^1.3.14",
51
52
  "typescript": "^5.9.3"
52
53
  }
@@ -0,0 +1,60 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { UsageError } from '../../errors'
5
+ import { localProject } from '../../local/local-project'
6
+ import { addMissingBackings, generateSyntheticData, loadScenario, SCENARIO_TEMPLATE } from '../../local/local-synthetic-data'
7
+ import { flagBool, flagString, type Command } from '../types'
8
+
9
+ /**
10
+ * `frontera local data --synthetic`: sample rows for every object type in the
11
+ * project's Blueprint, written under `datasets/`, so a Blueprint pulled from
12
+ * a workspace (which brings no records) can be explored and used by Agents.
13
+ * `local dev` loads them like any dataset file. No real record is copied.
14
+ */
15
+ export const localData: Command = {
16
+ meta: {
17
+ noun: 'local',
18
+ verb: 'data',
19
+ args: [],
20
+ flags: { synthetic: 'boolean', rows: 'string', seed: 'string' },
21
+ summary: "Generate sample data for the project's Blueprint (datasets/*.csv), with consistent links",
22
+ examples: ['frontera local data --synthetic', 'frontera local data --synthetic --rows 500 --seed 42'],
23
+ offline: true,
24
+ internal: true,
25
+ },
26
+ async run(ctx) {
27
+ if (!flagBool(ctx, 'synthetic')) throw new UsageError('say what to make', 'run `frontera local data --synthetic`')
28
+ const rows = Number(flagString(ctx, 'rows') ?? 200)
29
+ const seed = Number(flagString(ctx, 'seed') ?? 7)
30
+ if (!Number.isInteger(rows) || rows < 1 || rows > 100_000) throw new UsageError('--rows takes a whole number from 1 to 100000', 'e.g. `frontera local data --synthetic --rows 500`')
31
+ if (!Number.isInteger(seed)) throw new UsageError('--seed takes a whole number', 'e.g. `frontera local data --synthetic --seed 42`')
32
+ const project = localProject(ctx.cwd)
33
+ if (!existsSync(join(project.root, 'blueprint', 'object-types'))) {
34
+ throw new UsageError('this project has no Blueprint object types', 'pull them with `frontera local sync pull --kinds blueprint`, or add blueprint/object-types/<Name>.yaml')
35
+ }
36
+ // A scenario file to edit, the first time, so a coding agent finds it.
37
+ const scenarioFile = join(project.root, 'data', 'generate.ts')
38
+ let wroteScenario = false
39
+ if (!existsSync(scenarioFile)) {
40
+ mkdirSync(join(project.root, 'data'), { recursive: true })
41
+ writeFileSync(scenarioFile, SCENARIO_TEMPLATE)
42
+ wroteScenario = true
43
+ }
44
+ const backed = addMissingBackings(project.root)
45
+ const scenario = await loadScenario(project.root, seed)
46
+ const made = generateSyntheticData(project.root, { rows, seed, scenario: scenario ?? undefined })
47
+ const total = made.reduce((n, m) => n + m.rows, 0)
48
+ return {
49
+ data: { datasets: made, backings: backed, scenario: 'data/generate.ts', wroteScenario, rows: total, seed },
50
+ text: [
51
+ `Wrote ${total} sample rows for ${made.length} object types (seed ${seed}):`,
52
+ ...made.map((m) => ` ${m.objectType.padEnd(24)} ${String(m.rows).padStart(6)} ${m.csvPath}`),
53
+ ...(backed.length ? ['', `Gave ${backed.length} object type${backed.length === 1 ? '' : 's'} a dataset to read: ${backed.join(', ')}`] : []),
54
+ '',
55
+ wroteScenario ? 'Adjust them in data/generate.ts (rows per type, values per property).' : 'Adjusted by data/generate.ts.',
56
+ '`frontera local dev` loads them into the runtime.',
57
+ ].join('\n'),
58
+ }
59
+ },
60
+ }
@@ -3,7 +3,10 @@ import { existsSync, lstatSync, readFileSync, rmSync, watch, writeFileSync } fro
3
3
  import { join, sep } from 'node:path'
4
4
 
5
5
  import { CliError } from '../../errors'
6
- import { contentHash, loadHashes, saveHashes } from '../../local/local-applied'
6
+ import { adaptationOf, contentHash, loadBuild, loadHashes, oneLine, pruneBuild, recordBuild, recordSettingsDifferences, saveHashes, type BuildEntry } from '../../local/local-applied'
7
+ import { deployActions, ensureActionDraft, localActionCalls, prepareActions, retireActions, staleActions, unpublishedActions, type ActionBuild } from '../../local/local-actions'
8
+ import { agentIdentity, syncAgentName } from '../../local/local-agent-name'
9
+ import { classify, itemNeeds, type BuiltInKind } from '../../local/local-portability'
7
10
  import { localProfileName } from '../../local/local-control'
8
11
  import {
9
12
  allTargets,
@@ -17,11 +20,29 @@ import {
17
20
  type SyncProfiles,
18
21
  type SyncTarget,
19
22
  } from '../../local/local-dev-sync'
23
+ import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, localDirectory, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild } from '../../local/local-dev-references'
24
+ import { applyObjectSets, namesObjectSet, objectSetCalls } from '../../local/local-object-sets'
25
+ import { applyTheme, themeCalls } from '../../local/local-themes'
26
+ import { ensureMediaSets } from '../../local/local-media-sets'
27
+ import { applyChatApp } from '../../local/local-chat-apps'
28
+ import { applyLessons } from '../../local/local-lessons'
29
+ import { applyStandIn, platformOnlyNames, readStandIn, standInNames } from '../../local/local-stand-ins'
30
+ import { projectProvides } from '../../local/local-adapt'
20
31
  import { applyOperatingConfig } from '../../local/local-operating'
32
+ import { mirrorOrgSettings, settingsCalls } from '../../local/local-org-settings'
33
+ import { readSnapshot } from '../../local/local-sync-snapshot'
34
+ import { functionSlugOf, mirrorFunctionState } from '../../local/local-function-state'
35
+ import { functionSecretNames, missingSecretLines, readProjectDoc, readProjectText, workflowSecretNames } from '../../local/local-secrets'
36
+ import { decideStepNotes, mirrorWorkflowState } from '../../local/local-workflow-state'
37
+ import type { Directory } from '../../local/local-sync-refs'
21
38
  import { localProject, type LocalProject } from '../../local/local-project'
22
39
  import { isAlive, readState, recordApplied } from '../../local/local-state'
23
40
  import { applyErrorText } from '../../local/local-apply-error'
24
41
  import { flagBool, type Command, type CommandContext } from '../types'
42
+ import { isSharedFunctionFile } from '../../local/local-function-shared'
43
+ import { applyApp } from '../../local/local-apps'
44
+ import { applyBrand, brandUpload } from '../../local/local-brand'
45
+ import { applyLibrary } from '../../local/local-library'
25
46
 
26
47
  /**
27
48
  * `frontera local dev`: the inner loop. Applies the whole project to the
@@ -37,6 +58,15 @@ const DEBOUNCE_MS = 300
37
58
  interface RunResult {
38
59
  ok: boolean
39
60
  output: string
61
+ /** Not applied because it names these (`workflow/x`, `action/y`), which the runtime does not have yet. */
62
+ missing?: string[]
63
+ unnamedActions?: number
64
+ /** The Blueprint: how the runtime holds each object type and governed Action. */
65
+ blueprint?: { objects: Record<string, ObjectBuild>; actions: Record<string, ActionBuild>; objectSets?: string[] }
66
+ /** Saved object sets made again here under a new id: what names them is applied again. */
67
+ objectSetsAgain?: string[]
68
+ /** Governed Actions authored again under new ids: what names them is applied again. */
69
+ authoredAgain?: string[]
40
70
  }
41
71
 
42
72
  /** The commands running now, so stopping `local dev` stops them too. */
@@ -99,13 +129,82 @@ interface ApplyOptions {
99
129
 
100
130
  async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptions): Promise<RunResult> {
101
131
  const { profiles } = opts
132
+ const creds = readState(project)?.credentials
133
+ // Names in the file (`function/refund-eligibility`) become this runtime's ids in a resolved copy.
134
+ let directory: Promise<Directory> | null = null
135
+ const lookup = () => (directory ??= creds ? localDirectory(opts.serviceUrl, creds) : Promise.resolve({}))
136
+ const resolved = await resolveTarget(project, target, lookup, creds?.workspaceId, creds?.organizationId)
137
+ // The project's own file, before a resolved copy takes its place.
138
+ const original = target as Extract<SyncTarget, { file: string }>
139
+ if (!resolved.ok) return { ok: false, output: resolved.output, missing: resolved.missing, unnamedActions: resolved.unnamedActions }
140
+ let notes = resolved.output
141
+ const withNotes = (r: RunResult): RunResult => (notes ? { ...r, output: `${notes}\n${r.output}`.trim() } : r)
102
142
  if (target.kind === 'operating') {
103
143
  // Read fresh: the owner session is renewed on every runtime start.
104
- const creds = readState(project)?.credentials
105
144
  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) })
145
+ return withNotes(await applyOperatingConfig({ serviceUrl: opts.serviceUrl, creds, slug: target.slug, file: join(project.root, target.file), document: resolved.doc }))
107
146
  }
108
- let result = await runCli(project, commandFor(target, profiles), opts.auth)
147
+ if (target.kind === 'bindings') {
148
+ if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
149
+ return applyBindings(project, target, opts.serviceUrl, creds)
150
+ }
151
+ if (target.kind === 'knowledge') return applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
152
+ if (target.kind === 'theme') {
153
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
154
+ return applyTheme(project.root, target.name, themeCalls(opts.serviceUrl, creds))
155
+ }
156
+ if (target.kind === 'chat-app') {
157
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
158
+ // The owner's session, as the platform's Chat App editor writes; the roster's Agents by this runtime's ids.
159
+ return applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId))
160
+ }
161
+ if (target.kind === 'app') {
162
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
163
+ return applyApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), projectProvides(project, creds.organizationId))
164
+ }
165
+ if (target.kind === 'brand') {
166
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
167
+ // The owner's session, as an administrator sets the brand in Settings.
168
+ return applyBrand(project.root, creds.organizationId, themeCalls(opts.serviceUrl, creds), brandUpload(opts.serviceUrl, creds))
169
+ }
170
+ if (target.kind === 'library') {
171
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
172
+ return applyLibrary(project.root, themeCalls(opts.serviceUrl, creds))
173
+ }
174
+ if (target.kind === 'lessons') {
175
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
176
+ return applyLessons(project.root, target.slug, themeCalls(opts.serviceUrl, creds))
177
+ }
178
+ if (target.kind === 'plugin' && (target.file.endsWith('.stand-in.json') || target.file.endsWith('.platform.json'))) return applyStandIn(project, target.name, profiles.workspace, (args) => runCli(project, args, opts.auth))
179
+ if (target.kind === 'plugin') return applyPlugin(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
180
+ if (resolved.file) target = { ...target, file: resolved.file } as SyncTarget
181
+ let command = commandFor(target, profiles)
182
+ // Where the applied Blueprint lives: the runtime's copy when it needed one.
183
+ let blueprintRoot = project.root
184
+ const blueprint = target.kind === 'blueprint' ? resolveBlueprint(project) : null
185
+ // Governed Actions are deployed on this runtime only (D3): calls to it, with the project's own keys.
186
+ const actionCalls = blueprint?.actions.length && creds && opts.publish ? localActionCalls(opts.serviceUrl, creds) : null
187
+ if (blueprint) {
188
+ // The Blueprint as this runtime can hold it; see `resolveBlueprint`.
189
+ if (blueprint.dir) {
190
+ command = [...command, '--dir', join(project.root, blueprint.dir)]
191
+ blueprintRoot = join(project.root, blueprint.dir, '..')
192
+ }
193
+ notes = [notes, ...blueprint.warnings].filter(Boolean).join('\n')
194
+ // A media-backed type over a set's catalog binds to an empty Media Set of that name, made first.
195
+ if (blueprint.mediaSets) {
196
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
197
+ const why = await ensureMediaSets(blueprint.mediaSets, themeCalls(opts.serviceUrl, creds))
198
+ if (why) return { ok: false, output: `error [BLUEPRINT_MEDIA]: ${why}` }
199
+ }
200
+ if (actionCalls) {
201
+ // A draft on the first schema cannot hold Actions.
202
+ const draft = await ensureActionDraft(actionCalls)
203
+ if (!draft.ok) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: ${draft.note}` }
204
+ if (draft.note) notes = [notes, ` warning: ${draft.note}`].filter(Boolean).join('\n')
205
+ }
206
+ }
207
+ let result = withNotes(await runCli(project, command, opts.auth))
109
208
  const fallback = fallbackFor(target, profiles, project.root)
110
209
  if (!result.ok && fallback?.when.test(result.output)) {
111
210
  // An empty fallback means there was nothing more to do.
@@ -116,14 +215,158 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
116
215
  }
117
216
  }
118
217
  if (!result.ok) return result
119
- const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(project.root) : []
218
+ if (target.kind === 'workflow' && creds) {
219
+ // Live in the workspace: live here too, with its triggers as they are there where that is safe.
220
+ const lines = [
221
+ ...(await mirrorWorkflowState(project, target.slug, original.file, opts.serviceUrl, creds)),
222
+ ...decideStepNotes(project, original.file, creds),
223
+ // The secrets its HTTP steps and webhook name: placeholders until set on this computer.
224
+ ...(await missingSecretLines(workflowSecretNames(readProjectDoc(project, original.file)), `workflow ${target.slug}`, opts.serviceUrl, creds)),
225
+ ]
226
+ if (lines.length) result = { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }
227
+ }
228
+ if (target.kind === 'function' && creds) {
229
+ // Switched off in the workspace: off here too; and the secrets it is granted, until set here.
230
+ const source = readProjectText(project, original.file)
231
+ const lines = [
232
+ ...(await mirrorFunctionState(project, target.key, opts.serviceUrl, creds)),
233
+ ...(await missingSecretLines(functionSecretNames(source), `Function ${functionSlugOf(target.key) ?? original.file}`, opts.serviceUrl, creds)),
234
+ ]
235
+ if (lines.length) result = { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }
236
+ }
237
+ if (target.kind === 'agent' && creds) {
238
+ // Agents stay drafts here, so the row keeps the name it was created with unless set from the file.
239
+ const named = await syncAgentName({ slug: target.slug, identity: agentIdentity(project.root, target.file), serviceUrl: opts.serviceUrl, creds })
240
+ if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
241
+ }
242
+ let authoredAgain: string[] = []
243
+ if (actionCalls && blueprint) {
244
+ const names = blueprint.actions.map((a) => a.apiName)
245
+ // A decision pins the Action's contract, its object type's fields included, and a first
246
+ // apply can leave the draft short of the files: apply once more before deciding.
247
+ if ((await unpublishedActions(actionCalls, names)).length) {
248
+ const again = await runCli(project, command, opts.auth)
249
+ if (!again.ok) return again
250
+ }
251
+ // What each Action may write (`backing.editable`): `blueprint apply` leaves backings to `bind`.
252
+ const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
253
+ const subjects = new Set(blueprint.actions.flatMap((a) => (a.fit.subject ? [a.fit.subject] : [])))
254
+ for (const [type, props] of editableSets(appliedDir, subjects)) {
255
+ const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
256
+ if (!set.ok && !/no change|already|unchanged/i.test(set.output)) notes = [notes, ` warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}`].filter(Boolean).join('\n')
257
+ }
258
+ // An Action whose contract moved after its decision here blocks the publish, and a decision
259
+ // cannot follow a new contract. On this runtime it is authored again under a new id: out of
260
+ // the draft (`--prune` over a copy without it), then back from the files.
261
+ const { stale, consumed } = await staleActions(actionCalls, names)
262
+ authoredAgain = stale.filter((n) => names.includes(n))
263
+ // A release may not drop a contract a live deployment runs: that deployment is retired first.
264
+ const live = await retireActions(actionCalls, [...new Set([...authoredAgain, ...consumed])])
265
+ if (live.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...live].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
266
+ // Retired here, so authored again: what names it is applied again too.
267
+ for (const name of consumed) if (!authoredAgain.includes(name)) authoredAgain.push(name)
268
+ if (stale.length) {
269
+ const dir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
270
+ const without = await runCli(project, ['blueprint', 'apply', '--yes', '--prune', '--dir', blueprintWithout(project, dir, stale), '--profile', profiles.organization], opts.auth)
271
+ if (!without.ok) return without
272
+ const back = await runCli(project, command, opts.auth)
273
+ if (!back.ok) return back
274
+ notes = [notes, ` warning: governed Action ${stale.join(', ')} changed since it was deployed here, so it was authored again on this runtime`].filter(Boolean).join('\n')
275
+ result = withNotes(back)
276
+ }
277
+ // Publishing refuses an Action with no deployment decision: each gets "ships switched off" first.
278
+ const refused = await prepareActions(actionCalls, names, new Set(authoredAgain))
279
+ if (refused.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...refused].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
280
+ }
281
+ const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(blueprintRoot) : []
120
282
  for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes })) {
121
283
  const done = await runCli(project, step.args, opts.auth)
122
284
  if (!done.ok && !step.alreadyDone?.test(done.output)) return done
123
285
  }
286
+ if (blueprint) {
287
+ // After the publish: what writes only the Blueprint's records runs here; the rest stays switched off.
288
+ const actions: Record<string, ActionBuild> = actionCalls ? await deployActions(actionCalls, blueprint.actions) : {}
289
+ for (const [name, why] of Object.entries(blueprint.leftOffActions)) actions[name] = { status: 'left-off', reason: why }
290
+ for (const [name, a] of Object.entries(actions)) {
291
+ if (a.status === 'runs') result = { ...result, output: `${result.output}\n governed Action ${name} runs on this runtime` }
292
+ else if (a.status !== 'left-off') result = { ...result, output: `${result.output}\n warning: governed Action ${name} ${a.status === 'failed' ? 'didn’t deploy' : 'is switched off here'}: ${a.reason}` }
293
+ }
294
+ // Saved object sets, once the object types they read are published here.
295
+ let objectSets: string[] = []
296
+ let objectSetsAgain: string[] = []
297
+ if (creds && opts.publish) {
298
+ const leftOff = new Set(Object.entries(blueprint.objects).flatMap(([name, o]) => (o.status === 'left-off' ? [name] : [])))
299
+ const sets = await applyObjectSets(project.root, objectSetCalls(opts.serviceUrl, creds), leftOff)
300
+ if (sets.lines.length || sets.warnings.length) result = { ...result, output: [result.output, ...sets.lines, ...sets.warnings].join('\n').trim() }
301
+ objectSets = sets.warnings.map(adaptationOf)
302
+ objectSetsAgain = sets.recreated
303
+ }
304
+ result = {
305
+ ...result,
306
+ blueprint: { objects: blueprint.objects, actions, ...(objectSets.length ? { objectSets } : {}) },
307
+ ...(authoredAgain.length ? { authoredAgain } : {}),
308
+ ...(objectSetsAgain.length ? { objectSetsAgain } : {}),
309
+ }
310
+ }
124
311
  return result
125
312
  }
126
313
 
314
+ /**
315
+ * What a target was applied with, in words: the warnings the apply printed
316
+ * (a Plugin it went without, a model swapped) and what it needs that the
317
+ * runtime gives it differently (no computer; a governed Action switched off
318
+ * here, as the last Blueprint build left it).
319
+ */
320
+ function adaptationsFor(project: LocalProject, target: SyncTarget, output: string, organizationId: string): { adaptations: string[]; stubbed: boolean } {
321
+ const fromOutput = output.split('\n').filter((l) => /^\s*warning: /.test(l)).map(adaptationOf)
322
+ if (target.kind === 'blueprint' || target.kind === 'dataset') return { adaptations: [...new Set(fromOutput)], stubbed: false }
323
+ let files: Record<string, string> = {}
324
+ try {
325
+ if ('file' in target) files = { [target.file]: readFileSync(join(project.root, target.file), 'utf8') }
326
+ } catch {
327
+ // Read for its needs only; a file that vanished has none.
328
+ }
329
+ const deployed = loadBuild(project, organizationId).blueprint?.actions ?? {}
330
+ const actions = new Map(Object.entries(deployed).map(([name, a]) => [name, { runsLocally: a.status === 'runs', reason: a.reason, subject: null }] as const))
331
+ const kind = target.kind === 'operating' || target.kind === 'bindings' || target.kind === 'lessons' ? 'agent' : target.kind
332
+ // A built-in connection the project has a stand-in for is Stubbed: its tools answer "not connected".
333
+ // The Documentation plugin's stand-in serves the runtime's documentation, and the workspace's
334
+ // analytics stays on the platform: neither is Stubbed.
335
+ const standIns = standInNames(project.root)
336
+ const platformOnly = platformOnlyNames(project.root)
337
+ const titles = new Map([...standIns].map((n) => [n, readStandIn(project.root, n)?.connection ?? n] as const))
338
+ const builtIns = new Map<string, BuiltInKind>([
339
+ ...[...standIns].flatMap((n) => (readStandIn(project.root, n)?.serves === 'documentation' ? [[n, 'documentation'] as const] : [])),
340
+ ...[...platformOnly].map((n) => [n, 'analytics'] as const),
341
+ ])
342
+ const portability = classify(itemNeeds(kind, files, new Set([...standIns, ...platformOnly]), builtIns), { actions, titles })
343
+ const judged = portability.adaptations.map((a) => a.charAt(0).toUpperCase() + a.slice(1))
344
+ const stubbed = portability.class === 'stubbed' || (target.kind === 'plugin' && target.file.endsWith('.stand-in.json') && readStandIn(project.root, target.name)?.serves !== 'documentation')
345
+ return { adaptations: [...new Set([...judged, ...fromOutput])], stubbed }
346
+ }
347
+
348
+ /** The Blueprint's own adaptations, one line each: what is left off, and the Actions that do not run here. */
349
+ function blueprintAdaptations(b: NonNullable<RunResult['blueprint']>): string[] {
350
+ const out: string[] = []
351
+ // "Receipt’s files stay on the platform…", "FieldJob location is left off…".
352
+ for (const [name, o] of Object.entries(b.objects)) for (const a of o.adaptations ?? []) out.push(a.startsWith('its ') ? `${name}’s ${a.slice('its '.length)}` : `${name} ${a}`)
353
+ for (const [name, a] of Object.entries(b.actions)) {
354
+ if (a.status === 'switched-off') out.push(`Governed Action ${name} is switched off here: ${a.reason}`)
355
+ if (a.status === 'failed') out.push(`Governed Action ${name} didn’t deploy: ${a.reason}`)
356
+ if (a.status === 'left-off') out.push(`Governed Action ${name} is left off: ${a.reason}`)
357
+ }
358
+ out.push(...(b.objectSets ?? []))
359
+ return out
360
+ }
361
+
362
+ /**
363
+ * The bindings an Agent could not get because the Function, workflow or
364
+ * object type (its Context and data) is not on the runtime yet.
365
+ */
366
+ export function missingFromBindings(output: string): string[] {
367
+ return [...new Set([...output.matchAll(/^\s+((?:function|workflow|objectType)\/[A-Za-z0-9._-]+): (?:404|.*(?:not found|no live version|does not exist|not on the runtime yet))/gim)].map((m) => m[1]!))]
368
+ }
369
+
127
370
  async function dev(ctx: CommandContext) {
128
371
  const project = localProject(ctx.cwd)
129
372
  const state = readState(project)
@@ -148,21 +391,74 @@ async function dev(ctx: CommandContext) {
148
391
 
149
392
  const organizationId = state.credentials!.organizationId
150
393
  const applied = flagBool(ctx, 'reapply') ? new Map<string, string | null>() : loadHashes(project, organizationId)
394
+
395
+ // The organization's settings as the last fetch read them: the timezone is taken, every
396
+ // other difference is said here and in the build summary.
397
+ // Read again when a fetch records new settings (`.frontera/sync/remote.json`), so the build summary follows the workspace.
398
+ let settingsSeen: string | null = null
399
+ const mirrorSettings = async () => {
400
+ const synced = readSnapshot(project.root)?.settings
401
+ if (!synced || !creds.session) return
402
+ const seen = JSON.stringify(synced)
403
+ if (seen === settingsSeen) return
404
+ settingsSeen = seen
405
+ const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds)).catch(() => null)
406
+ if (mirrored) {
407
+ for (const line of mirrored.lines) ctx.output.note(line)
408
+ for (const d of mirrored.differences) ctx.output.note(` differs from the workspace: ${d}`)
409
+ recordSettingsDifferences(project, organizationId, mirrored.differences)
410
+ }
411
+ }
412
+ await mirrorSettings()
151
413
  let unchanged = 0
152
414
  const pending = new Map<string, SyncTarget>()
153
415
  // Files refused while something they depend on was missing; tried again after the next success.
154
416
  const failed = new Map<string, SyncTarget>()
417
+ // Workflows applied again for an Action authored again: their live version held the old id.
418
+ const promoteAfter = new Set<string>()
419
+ // Agents whose row name was checked against their file in this session.
420
+ const named = new Set<string>()
155
421
  let failures = 0
422
+ // Whether anything applied since the failed targets were last tried.
423
+ let progressed = false
156
424
  let busy = false
157
425
  let stopping = false
158
426
  let timer: ReturnType<typeof setTimeout> | null = null
159
427
 
428
+ /** What one apply came to, for the build record Forge and Studio read (design §9). */
429
+ const buildEntry = (target: SyncTarget, hash: string | null, result: RunResult): BuildEntry => {
430
+ const base = { what: describeTarget(target), hash, at: new Date().toISOString() }
431
+ if (result.ok) {
432
+ // A Blueprint with a media-backed type over an empty Media Set is Stubbed, as an item with a stand-in is.
433
+ const { adaptations, stubbed } = result.blueprint ? { adaptations: [...new Set([...blueprintAdaptations(result.blueprint)])], stubbed: Object.values(result.blueprint.objects).some((o) => o.status === 'stubbed') } : adaptationsFor(project, target, result.output, organizationId)
434
+ return {
435
+ ...base,
436
+ status: stubbed ? 'stubbed' : adaptations.length ? 'adapted' : 'built',
437
+ ...(adaptations.length ? { adaptations } : {}),
438
+ ...(result.blueprint ? { objects: result.blueprint.objects, actions: result.blueprint.actions } : {}),
439
+ }
440
+ }
441
+ const missing = result.missing?.length ? result.missing : target.kind === 'bindings' ? missingFromBindings(result.output) : []
442
+ if (missing.length || result.unnamedActions) return { ...base, status: 'blocked', needs: missing, ...(result.unnamedActions ? { unnamedActions: result.unnamedActions } : {}) }
443
+ return { ...base, status: 'failed', error: oneLine(applyErrorText(result.output)) }
444
+ }
445
+
160
446
  const runOne = async (target: SyncTarget) => {
161
447
  if (stopping) return
162
448
  if (target.kind !== 'blueprint' && !existsSync(join(project.root, target.file))) return
163
449
  const hash = contentHash(project, target)
164
450
  if (hash && applied.get(target.key) === hash) {
165
451
  unchanged += 1
452
+ // An Agent applied before names were set from its file still reads its slug: set it once.
453
+ if (target.kind === 'agent' && !named.has(target.slug)) {
454
+ named.add(target.slug)
455
+ const line = await syncAgentName({ slug: target.slug, identity: agentIdentity(project.root, target.file), serviceUrl, creds })
456
+ if (line) ctx.output.note(line)
457
+ // The runtime changed: say so as an apply does, so Studio's lists and the Agent's page refetch.
458
+ if (line && !/warning:/.test(line)) recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: true })
459
+ }
460
+ // Applied before builds were recorded: it is built as it is.
461
+ if (!loadBuild(project, organizationId)[target.key]) recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'built', hash, at: new Date().toISOString() })
166
462
  return
167
463
  }
168
464
  const started = Date.now()
@@ -170,6 +466,7 @@ async function dev(ctx: CommandContext) {
170
466
  if (stopping) return
171
467
  const ms = Date.now() - started
172
468
  recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: result.ok })
469
+ recordBuild(project, organizationId, target.key, buildEntry(target, hash, result))
173
470
  if (result.ok) {
174
471
  // A draft-only Blueprint is not what a plain `local dev` would leave, so it is not recorded as applied.
175
472
  if (target.kind === 'blueprint' && !options.publish) applied.delete(target.key)
@@ -177,28 +474,68 @@ async function dev(ctx: CommandContext) {
177
474
  saveHashes(project, organizationId, applied)
178
475
  failed.delete(target.key)
179
476
  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()
477
+ if (target.kind === 'workflow' && promoteAfter.delete(target.key)) {
478
+ const promoted = await promoteWorkflow(serviceUrl, creds, target.slug)
479
+ if (promoted) ctx.output.note(` promoted workflow ${target.slug} v${promoted}: its live version ran the Action's old id`)
480
+ }
481
+ // What it was applied without, or with instead (a missing model), said once here.
482
+ for (const line of result.output.split('\n')) if (/^\s+warning: /.test(line)) ctx.output.note(` ${line.trim()}`)
483
+ progressed = true
484
+ // Workflows and Agents hold the runtime's id of each Action they run: a new id means applying them again.
485
+ if (result.authoredAgain?.length || result.objectSetsAgain?.length) {
486
+ const names = result.authoredAgain ?? []
487
+ for (const t of allTargets(project.root)) {
488
+ if (t.kind !== 'workflow' && t.kind !== 'agent' && t.kind !== 'operating' && t.kind !== 'bindings') continue
489
+ let text = ''
490
+ try {
491
+ text = readFileSync(join(project.root, t.file), 'utf8')
492
+ } catch {
493
+ continue
494
+ }
495
+ if (names.some((n) => text.includes(`action/${n}`) || text.includes(`action:${n}`)) || namesObjectSet(text, result.objectSetsAgain ?? [])) {
496
+ applied.delete(t.key)
497
+ pending.set(t.key, t)
498
+ if (t.kind === 'workflow') promoteAfter.add(t.key)
499
+ }
500
+ }
501
+ }
182
502
  } else {
183
- failures += 1
184
503
  failed.set(target.key, target)
185
- ctx.output.note(` ✕ ${describeTarget(target)}: ${applyErrorText(result.output)}`)
504
+ // A retry that fails the same way is not said (or counted) again in the same round of changes.
505
+ const text = applyErrorText(result.output)
506
+ if (said.get(target.key) !== text) {
507
+ failures += 1
508
+ said.set(target.key, text)
509
+ ctx.output.note(` ✕ ${describeTarget(target)}: ${text}`)
510
+ }
186
511
  }
187
512
  }
188
513
 
514
+ /** What each failure last said in this drain, so a retry failing the same way is said once. */
515
+ const said = new Map<string, string>()
516
+
189
517
  /** Apply what is queued, dependencies first, one at a time. Never throws: a bad file must not end the loop. */
190
518
  const drain = async () => {
191
519
  if (busy) return
192
520
  busy = true
521
+ said.clear()
193
522
  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}`)
523
+ for (;;) {
524
+ while (pending.size && !stopping) {
525
+ const next = [...pending.values()].sort(byApplyOrder)[0]!
526
+ pending.delete(next.key)
527
+ try {
528
+ await runOne(next)
529
+ } catch (err) {
530
+ ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
531
+ }
201
532
  }
533
+ // What failed is tried again once the queue is empty, and only while a round applied
534
+ // something it may have been waiting for; otherwise it waits for its own file to change.
535
+ if (stopping || !progressed || !failed.size) break
536
+ progressed = false
537
+ for (const [key, retry] of failed) pending.set(key, retry)
538
+ failed.clear()
202
539
  }
203
540
  } finally {
204
541
  busy = false
@@ -212,6 +549,8 @@ async function dev(ctx: CommandContext) {
212
549
 
213
550
  /** What a changed path means: one file, every Function (a helper or the SDK changed), or a moved folder. */
214
551
  const queuePath = (rel: string) => {
552
+ // Forge's state and the runtime's resolved copies are written under `.frontera/`: never a reason to apply.
553
+ if (rel.split(sep).some((p) => p === '.frontera' || p === 'node_modules' || p === '.git')) return
215
554
  const absolute = join(project.root, rel)
216
555
  const direct = targetFor(project.root, absolute)
217
556
  if (direct) {
@@ -226,7 +565,8 @@ async function dev(ctx: CommandContext) {
226
565
  return false
227
566
  }
228
567
  })()
229
- const helper = (top === 'functions' || top === 'vendor') && /\.ts$/.test(rel) && !rel.endsWith('.test.ts')
568
+ // A helper in functions/, the SDK, or a shared file a Function imports from outside functions/.
569
+ const helper = ((top === 'functions' || top === 'vendor') && /\.ts$/.test(rel) && !rel.endsWith('.test.ts')) || isSharedFunctionFile(rel.split(sep).join('/'))
230
570
  if (!isDir && !helper) return
231
571
  for (const target of allTargets(project.root)) {
232
572
  const path = target.kind === 'blueprint' ? 'blueprint' : target.file
@@ -252,11 +592,14 @@ async function dev(ctx: CommandContext) {
252
592
  ? null
253
593
  : watch(project.root, { recursive: true }, (_event, filename) => {
254
594
  if (!filename || stopping) return
595
+ if (filename.toString() === join('.frontera', 'sync', 'remote.json')) void mirrorSettings()
255
596
  queuePath(filename.toString())
256
597
  if (!busy) schedule()
257
598
  })
258
599
 
259
600
  const targets = allTargets(project.root)
601
+ // Outcomes of files that are gone are forgotten, so the home lists what the project holds.
602
+ pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
260
603
  ctx.output.note(targets.length ? `Applying ${targets.length} item${targets.length === 1 ? '' : 's'} to the local runtime` : 'Nothing to apply yet')
261
604
  for (const target of targets) pending.set(target.key, target)
262
605
  await drain()
@@ -268,7 +611,7 @@ async function dev(ctx: CommandContext) {
268
611
  return { data: { applied: done, unchanged }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
269
612
  }
270
613
 
271
- ctx.output.note('Watching datasets/, blueprint/, functions/, workflows/ and agents/ (Ctrl+C to stop)')
614
+ ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
272
615
  if (pending.size) schedule()
273
616
 
274
617
  await new Promise<void>((resolve) => {
@@ -26,12 +26,14 @@ import { DEFAULT_REGISTRY, runSupervisor } from '../../local/local-supervisor'
26
26
  import { findCaseFiles, runCases } from '../../local/local-test-runner'
27
27
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
28
28
  import { localCheck } from './check'
29
+ import { localData } from './data'
29
30
  import { localDev } from './dev'
30
31
  import { localDocsMcp } from './docs-mcp'
31
32
  import { localInit } from './init'
32
33
  import { localKeyList, localKeyRemove, localKeySet } from './key'
33
34
  import { localModelAdd, localModelDefault, localModelRemove, localModelsAvailable, localProviderAdd, localProviderRemove, localProviders } from './providers'
34
35
  import { localSchema } from './schema'
36
+ import { localSync } from './sync'
35
37
 
36
38
  /**
37
39
  * `frontera local …`: Frontera's runtimes on this machine, for internal FDEs.
@@ -388,8 +390,12 @@ async function test(ctx: CommandContext) {
388
390
  if (!state || state.phase !== 'ready' || !state.credentials?.session) {
389
391
  throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
390
392
  }
391
- const files = findCaseFiles(project.root, flagString(ctx, 'agent'))
393
+ // `--regression`: only the Agents' regression datasets, as the workspace chose them.
394
+ const files = findCaseFiles(project.root, flagString(ctx, 'agent')).filter((f) => !flagBool(ctx, 'regression') || f.regression)
392
395
  const total = files.reduce((n, f) => n + f.cases.length, 0)
396
+ if (total === 0 && flagBool(ctx, 'regression')) {
397
+ return { data: { passed: 0, failed: 0, results: [] }, text: 'No regression datasets. A pull marks the dataset each Agent is tested on before publishing in the workspace (`regression: true`).' }
398
+ }
393
399
  if (total === 0) {
394
400
  return {
395
401
  data: { passed: 0, failed: 0, results: [] },
@@ -419,9 +425,9 @@ export const localTest: Command = {
419
425
  noun: 'local',
420
426
  verb: 'test',
421
427
  args: [],
422
- flags: { agent: 'string', live: 'boolean' },
428
+ flags: { agent: 'string', live: 'boolean', regression: 'boolean' },
423
429
  summary: 'Run the project\'s saved Agent test cases against the local runtime',
424
- examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --live'],
430
+ examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --live', 'frontera local test --regression'],
425
431
  offline: true,
426
432
  internal: true,
427
433
  },
@@ -473,5 +479,7 @@ export const localCommands: readonly Command[] = [
473
479
  localModelDefault,
474
480
  localDocsMcp,
475
481
  localSchema,
482
+ localData,
483
+ localSync,
476
484
  localDaemon,
477
485
  ]