@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { basename } from 'node:path'
2
+ import { basename, join } from 'node:path'
3
3
 
4
4
  import { CliError, UsageError } from '../../errors'
5
5
  import { canPrompt, promptSecret } from '../../prompt'
6
6
  import { allTargets } from '../../local/local-dev-sync'
7
7
  import { pullDocuments, type DocumentsPulled } from '../../local/local-knowledge-documents'
8
+ import { appCodeLine, appCodeVersion, earlierPullShadows, pullAppCode, type AppCodePulled } from '../../local/local-app-code'
9
+ import { remakeLines, remakeReason, remakeSampleData, type SampleRemake } from '../../local/local-sample-remake'
8
10
  import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/local-lessons'
9
11
  import { analyticsNeeded, connectionsNeeded, documentationNeeded, standInPath, writePlatformOnly, writeStandIns } from '../../local/local-stand-ins'
10
12
  import { localProject, type LocalProject } from '../../local/local-project'
@@ -36,6 +38,8 @@ import { readWorkspace, type SyncSource } from '../../local/local-sync-remote'
36
38
  import { buildStatusText, projectBuildSummary } from '../../local/local-build-summary'
37
39
  import { kindTitle } from '../../local/local-sync-names'
38
40
  import { describeUnresolved } from '../../local/local-sync-refs'
41
+ import { blockers, type Blocker, type Need } from '../../local/local-portability'
42
+ import { hardCodedIdLine, hardCodedObjectTypeIds, platformObjectTypeIds } from '../../local/local-function-ids'
39
43
  import {
40
44
  directoryOf,
41
45
  dropBase,
@@ -555,8 +559,11 @@ const pull = async (ctx: CommandContext) => {
555
559
  }
556
560
  const named = ctx.positional.slice(1)
557
561
  const byRef = new Map(remote.items.map((i) => [i.ref, i]))
558
- // Knowledge bases whose documents come too: opt-in, as documents can be large and sensitive.
562
+ // Knowledge bases whose documents come too: every base this pull writes (a pulled Agent answers from them),
563
+ // unless --no-documents; --documents names more, already in the project, to bring theirs again.
564
+ const noDocuments = flagBool(ctx, 'no-documents')
559
565
  const documents = refsFlag(ctx, 'documents').map((r) => r.replace(/^knowledge\//, ''))
566
+ if (noDocuments && documents.length) throw new UsageError('--documents and --no-documents ask opposite things', 'leave one out: --no-documents pulls no documents; --documents <name> pulls those bases\' documents')
560
567
  const noBase = documents.filter((n) => !byRef.has(`knowledge/${n}`))
561
568
  if (noBase.length) throw new UsageError(`no knowledge base ${noBase.join(', ')} in the workspace`, '--documents takes knowledge base names, as `--preview` lists them (knowledge/<name>)')
562
569
  const byPlan = new Map(plan.map((p) => [p.ref, p]))
@@ -608,6 +615,9 @@ const pull = async (ctx: CommandContext) => {
608
615
  const replaced = [...take].filter((r) => status.get(r) === 'edited' || status.get(r) === 'deleted-local')
609
616
  const merge = new Set([...take].filter((r) => status.get(r) === 'conflict'))
610
617
  let base = lock?.workspace === link.workspace ? lock : null
618
+ // An App folder an earlier pull left unchanged with package.json and bun.lock disagreeing (an older CLI's repair):
619
+ // told now, before this pull rewrites its frontera.lock entry, so its code is brought again in its place below.
620
+ const earlierPull = new Set([...take].filter((r) => r.startsWith('app/') && earlierPullShadows(project.root, r.slice('app/'.length)).length))
611
621
  const readFromBase = (path: string) => readBase(project.root, path)
612
622
  const removedDeleted: string[] = []
613
623
  for (const [refs, deleteFiles] of [[deleteHere, true], [keepLocal, false]] as const) {
@@ -622,7 +632,8 @@ const pull = async (ctx: CommandContext) => {
622
632
  const conflictedRefs = new Set(result.conflicted.map((c) => c.ref))
623
633
  writeBase(project.root, items.filter((i) => !conflictedRefs.has(i.ref)), base)
624
634
  const pulled = [...take].filter((r) => !conflictedRefs.has(r))
625
- // Documents asked for land beside each base's knowledge.json; `local dev` uploads them.
635
+ // Documents land beside each base's knowledge.json; `local dev` uploads them. The bases pulled now bring theirs.
636
+ if (!noDocuments) for (const r of pulled) if (r.startsWith('knowledge/') && !documents.includes(r.slice('knowledge/'.length))) documents.push(r.slice('knowledge/'.length))
626
637
  const docs: Record<string, DocumentsPulled> = {}
627
638
  if (documents.length) {
628
639
  const client = sourceFor(project, link).workspace
@@ -631,6 +642,34 @@ const pull = async (ctx: CommandContext) => {
631
642
  docs[n] = id ? await pullDocuments(project.root, client, { name: n, id }) : { written: [], present: [], skipped: [], failed: ['its id is not in the fetched snapshot: fetch again'] }
632
643
  }
633
644
  }
645
+ // Each pulled Custom App's code comes too, into apps/<slug>/, unless --no-app-code; a folder already there is kept,
646
+ // except one an earlier pull left unchanged whose package.json and bun.lock disagree: the deployed source replaces it.
647
+ const appCode: AppCodePulled[] = []
648
+ if (!flagBool(ctx, 'no-app-code')) {
649
+ const wantCode = pulled.flatMap((r) => {
650
+ if (!r.startsWith('app/')) return []
651
+ const slug = r.slice('app/'.length)
652
+ const it = byRef.get(r)
653
+ const version = appCodeVersion(project.root, slug, it?.version)
654
+ const replaceEarlierPull = earlierPull.has(r)
655
+ return it?.id && version && (replaceEarlierPull || !existsSync(join(project.root, 'apps', slug))) ? [{ slug, id: it.id, version, ...(replaceEarlierPull ? { replaceEarlierPull } : {}) }] : []
656
+ })
657
+ if (wantCode.length) {
658
+ const client = sourceFor(project, link).workspace
659
+ for (const app of wantCode) appCode.push(await pullAppCode(project.root, client, app))
660
+ }
661
+ }
662
+ // Sample data made again when the pull brought Blueprint items, or an older generator made it, unless --keep-sample-data.
663
+ let sampleData: SampleRemake | null = null
664
+ let sampleDataError: string | null = null
665
+ const remakeWhy = flagBool(ctx, 'keep-sample-data') ? null : remakeReason(project.root, pulled.some((r) => r.startsWith('blueprint/')))
666
+ if (remakeWhy) {
667
+ try {
668
+ sampleData = await remakeSampleData(project.root, remakeWhy, new Date(), 'pull')
669
+ } catch (err) {
670
+ sampleDataError = (err as Error).message.slice(0, 200)
671
+ }
672
+ }
634
673
  // Built-in connections the project's items use get a declared stand-in (`local-stand-ins.ts`).
635
674
  const held = new Set(Object.keys(readLock(project.root)?.items ?? {}))
636
675
  const heldItems = remote.items.filter((i) => held.has(i.ref))
@@ -667,6 +706,11 @@ const pull = async (ctx: CommandContext) => {
667
706
  ` ${n}: ${plural(d.written.length, 'document')} written${d.present.length ? `, ${d.present.length} already here` : ''}${d.skipped.length ? `, ${d.skipped.length} not a kind local dev uploads (${d.skipped.join(', ')})` : ''}`,
668
707
  ...d.failed.map((f) => ` could not read ${f}`),
669
708
  ])
709
+ // What won't run locally as pulled, with the reason and whose fix it is: the checks `local dev` applies.
710
+ const wontRun = wontRunLocally(project.root, pulled.map((r) => byRef.get(r)!).filter(Boolean))
711
+ // Functions whose code names an object type by the workspace's id (`local dev` adapts their bundle here).
712
+ const platformIds = platformObjectTypeIds(directoryOf(snapshot))
713
+ const hardCoded = pulled.flatMap((r) => (r.startsWith('function/') && byRef.get(r) ? hardCodedObjectTypeIds(byRef.get(r)!.files, platformIds) : []))
670
714
  // The workspace's copies beside conflicted files are not project files; `local dev` ignores them.
671
715
  const projectWritten = result.written.filter((p) => !p.endsWith(WORKSPACE_SUFFIX))
672
716
  const conflictLines = result.conflicted.flatMap((c) => [
@@ -689,8 +733,12 @@ const pull = async (ctx: CommandContext) => {
689
733
  warnings: remote.warnings,
690
734
  bases,
691
735
  documents: docs,
736
+ appCode,
737
+ ...(sampleData ? { sampleData: { why: sampleData.why, options: sampleData.options, datasets: sampleData.written.map((d) => d.dataset), kept: sampleData.kept.map((d) => d.dataset) } } : {}),
692
738
  ...(lessons ? { lessons } : {}),
693
739
  ...(standIns.written.length ? { standIns: standIns.written } : {}),
740
+ ...(wontRun.length ? { wontRunLocally: wontRun } : {}),
741
+ ...(hardCoded.length ? { hardCodedIds: hardCoded } : {}),
694
742
  },
695
743
  text: [
696
744
  pulled.length ? `Pulled ${plural(pulled.length, 'item')} from ${link.workspaceName ?? link.workspace}:` : result.conflicted.length || waiting.length || deleteHere.length || keepLocal.length || lessons ? '' : 'Nothing to pull.',
@@ -703,11 +751,28 @@ const pull = async (ctx: CommandContext) => {
703
751
  'See them with `frontera local sync diff <ref>`; settle with `resolve <ref> --keep workspace|mine`, or edit the file and `resolve <ref> --edited`.',
704
752
  ]
705
753
  : []),
754
+ ...(wontRun.length
755
+ ? [
756
+ '',
757
+ `Won’t run locally as pulled (${plural(wontRun.length, 'item')}; \`local dev\` leaves ${wontRun.length === 1 ? 'it' : 'them'} out and says so):`,
758
+ ...wontRun.flatMap((w) => [` ${w.ref}: ${w.reason}`, ` fix: ${w.fix} — ${w.owner}`]),
759
+ ]
760
+ : []),
761
+ ...(hardCoded.length
762
+ ? [
763
+ '',
764
+ 'Object type ids hard-coded in Function code (the workspace’s ids; `local dev` deploys these Functions here with this runtime’s ids in their place and leaves the files as they are):',
765
+ ...hardCoded.map((h) => ` warning: ${hardCodedIdLine(h)}`),
766
+ ]
767
+ : []),
706
768
  ...(waiting.length ? ['', `Not pulled, conflicts to resolve first: ${waiting.join(', ')}`] : []),
707
769
  ...(deleteHere.length ? ['', `Deleted here, as in the workspace: ${deleteHere.join(', ')}`] : []),
708
770
  ...(keepLocal.length ? ['', `Kept as this project’s own: ${keepLocal.join(', ')}`] : []),
709
771
  ...(projectWritten.length ? ['', `Wrote ${plural(projectWritten.length, 'file')}; \`frontera local dev\` applies them.`] : []),
710
772
  ...(docLines.length ? ['', 'Documents (uploaded to the local knowledge base by `frontera local dev`):', ...docLines] : []),
773
+ ...(sampleData ? ['', ...remakeLines(sampleData)] : []),
774
+ ...(sampleDataError ? ['', `Sample data couldn’t be made again (${sampleDataError}); \`frontera local data --synthetic\` tries again`] : []),
775
+ ...(appCode.length ? ['', 'App code (built by `frontera local dev`; `app_dev` runs it against the local runtime):', ...appCode.map(appCodeLine)] : []),
711
776
  ...lessonLines,
712
777
  ...standInLines,
713
778
  ...remote.warnings.map((w) => `Could not read ${w}`),
@@ -718,6 +783,23 @@ const pull = async (ctx: CommandContext) => {
718
783
  }
719
784
  }
720
785
 
786
+ /**
787
+ * The pulled items that won't run on the local runtime as they are, each with
788
+ * why, the fix and whose fix it is. A file a Function imports that the project
789
+ * now has (written by hand, or by another pull) no longer counts.
790
+ */
791
+ export function wontRunLocally(root: string, items: ReadonlyArray<Pick<RemoteItem, 'ref' | 'portability'>>): Array<Blocker & { ref: string }> {
792
+ const present = (stem: string) => ['', '.ts', '.tsx', '.mts', '.js', '.mjs', '/index.ts', '/index.js'].some((ext) => existsSync(join(root, `${stem}${ext}`)))
793
+ return items.flatMap((it) => {
794
+ const needs = (it.portability?.needs ?? []).flatMap((n): Need[] => {
795
+ if (n.kind !== 'imports-missing') return [n]
796
+ const paths = n.paths.filter((p) => !present(p))
797
+ return paths.length ? [{ kind: 'imports-missing', paths }] : []
798
+ })
799
+ return blockers(needs).map((b) => ({ ref: it.ref, ...b }))
800
+ })
801
+ }
802
+
721
803
  /** Everything about one item for `diff` and `resolve`: the plan row, the workspace's version, and the three-way merge. */
722
804
  async function itemState(project: LocalProject, link: SyncLink, ref: string) {
723
805
  // Offline, as diff and resolve are: a lost base comes back only from an unchanged copy here (pull reads the platform).
@@ -901,7 +983,17 @@ export const localSync: Command = {
901
983
  description: 'string',
902
984
  migration: 'string',
903
985
  documents: 'string',
986
+ 'no-documents': 'boolean',
987
+ 'no-app-code': 'boolean',
988
+ 'keep-sample-data': 'boolean',
904
989
  lessons: 'boolean',
990
+ release: 'string',
991
+ force: 'boolean',
992
+ reason: 'string',
993
+ evals: 'boolean',
994
+ renders: 'boolean',
995
+ screenshots: 'string',
996
+ trace: 'string',
905
997
  },
906
998
  aliases: { t: 'title', d: 'description' },
907
999
  summary: 'Link the project to a platform workspace, pull and merge what you choose, and propose changes back',
@@ -913,7 +1005,10 @@ export const localSync: Command = {
913
1005
  'frontera local sync pull --preview',
914
1006
  'frontera local sync pull agent/support-concierge workflow/return-label',
915
1007
  'frontera local sync pull --kinds blueprint,workflows',
916
- 'frontera local sync pull knowledge/faq --documents faq',
1008
+ 'frontera local sync pull knowledge/faq',
1009
+ 'frontera local sync pull --kinds knowledge --no-documents',
1010
+ 'frontera local sync pull --documents faq',
1011
+ 'frontera local sync pull app/ops-console --no-app-code',
917
1012
  'frontera local sync pull agent/support-concierge --lessons',
918
1013
  'frontera local sync diff agent/support-concierge',
919
1014
  'frontera local sync resolve agent/support-concierge --keep workspace',
@@ -924,12 +1019,14 @@ export const localSync: Command = {
924
1019
  'frontera local sync push agent/support-concierge -t "New greeting"',
925
1020
  'frontera local sync push --documents faq -t "Updated returns policy"',
926
1021
  'frontera local sync request',
1022
+ 'frontera local sync push --release desk=1.4.0 -t "Release the desk"',
1023
+ 'frontera local sync request evidence --evals --renders',
927
1024
  'frontera local sync request apply',
1025
+ 'frontera local sync request apply --force --reason "hotfix, checked by hand"',
928
1026
  'frontera local sync request close',
929
1027
  'frontera local sync disconnect --forget-link',
930
1028
  ],
931
1029
  offline: true,
932
- internal: true,
933
1030
  },
934
1031
  async run(ctx) {
935
1032
  const action = ctx.positional[0]
@@ -0,0 +1,34 @@
1
+ import { localProject } from '../../local/local-project'
2
+ import { stopStartedDevServers } from '../../mcp/mcp-apps'
3
+ import { serveMcp } from '../../mcp/mcp-protocol'
4
+ import { MCP_INFO, mcpTools } from '../../mcp/mcp-server'
5
+ import type { Command } from '../types'
6
+
7
+ /**
8
+ * Frontera as one MCP server on stdio for the coding agents working in a
9
+ * project: the local runtime (status, apply, chat with an Agent, traces,
10
+ * tests, workflows, records), the documentation, App rendering and, with
11
+ * Forge open, a browser the person watches. Registered once per project as
12
+ * `frontera` in `.mcp.json`, `.cursor/mcp.json`, `opencode.json` and
13
+ * `.codex/config.toml`. Every tool takes an optional `project` folder, so
14
+ * one connection also serves the other projects the agent works in. Nothing
15
+ * but protocol goes to stdout.
16
+ */
17
+ export const mcpServe: Command = {
18
+ meta: {
19
+ noun: 'mcp',
20
+ verb: '',
21
+ args: [],
22
+ flags: {},
23
+ summary: 'Serve Frontera to coding agents over MCP (stdio): runtime, docs, Apps and the Forge browser',
24
+ examples: ['frontera mcp'],
25
+ offline: true,
26
+ },
27
+ async run(ctx) {
28
+ await serveMcp({ info: MCP_INFO, tools: mcpTools(localProject(ctx.cwd), ctx.cwd) })
29
+ // The connection closed: the App dev servers this session started stop with it (each also watches this process).
30
+ stopStartedDevServers()
31
+ // The stream is the protocol: exit here, before the CLI renders a result onto it.
32
+ process.exit(0)
33
+ },
34
+ }
@@ -12,11 +12,17 @@ import { appSave } from './app/save'
12
12
  import { appDeploy } from './app/deploy'
13
13
  import { appPromote } from './app/promote'
14
14
  import { appVersions } from './app/versions'
15
+ import { appChecks } from './app/checks'
16
+ import { appDeployments } from './app/deployments'
17
+ import { appRollback } from './app/rollback'
18
+ import { appUnpin } from './app/unpin'
15
19
  import { appSdk } from './app/sdk'
16
20
  import { appDev } from './app/dev'
17
21
  import { blueprintList } from './blueprint/list'
18
22
  import { blueprintGet } from './blueprint/get'
23
+ import { blueprintObjectSetsList } from './blueprint/object-sets'
19
24
  import { blueprintInstance, blueprintQuery } from './blueprint/query'
25
+ import { blueprintMetric } from './blueprint/metric'
20
26
  import { blueprintGenerateTypes } from './blueprint/generate-types'
21
27
  import { blueprintAuthoringCommands } from './blueprint/authoring'
22
28
  import { blueprintDeclarativeCommands } from './blueprint/declarative'
@@ -47,6 +53,8 @@ import { kitCommands } from './kit/index-commands'
47
53
  import { workspaceCommands } from './workspace/index-commands'
48
54
  import { forgeCommands } from './forge/index-commands'
49
55
  import { localCommands } from './local/index-commands'
56
+ import { localSnapshot } from './local/snapshot'
57
+ import { mcpServe } from './mcp/serve'
50
58
 
51
59
  /**
52
60
  * Flags every command accepts. Declared once so a caller never has to wonder
@@ -88,6 +96,8 @@ export const COMMANDS: readonly Command[] = [
88
96
  ...workspaceCommands,
89
97
  ...forgeCommands,
90
98
  ...localCommands,
99
+ localSnapshot,
100
+ mcpServe,
91
101
 
92
102
  appInit,
93
103
  appList,
@@ -96,6 +106,10 @@ export const COMMANDS: readonly Command[] = [
96
106
  appSave,
97
107
  appDeploy,
98
108
  appPromote,
109
+ appRollback,
110
+ appUnpin,
111
+ appDeployments,
112
+ appChecks,
99
113
  appVersions,
100
114
  appSdk,
101
115
  appDev,
@@ -119,7 +133,9 @@ export const COMMANDS: readonly Command[] = [
119
133
  // Reads, next to the other two reads: the authoring verbs below all report
120
134
  // success without proving anything came back, and these are what prove it.
121
135
  blueprintQuery,
136
+ blueprintMetric,
122
137
  blueprintInstance,
138
+ blueprintObjectSetsList,
123
139
  blueprintGenerateTypes,
124
140
  // Authoring is no longer reserved: the organization API key (`sk-org-`) reaches the
125
141
  // organization-level shared draft, which is what these verbs were waiting on.
@@ -151,8 +167,21 @@ export function findCommand(noun: string, verb: string | undefined): Command | n
151
167
  )
152
168
  }
153
169
 
170
+ /**
171
+ * The local loop: `frontera local …` (a runtime on this machine, applying the
172
+ * project, its tests and its gate) and `frontera mcp` (the same for a coding
173
+ * agent). Listed together in help, under one heading, after the platform's
174
+ * nouns.
175
+ */
176
+ export const LOCAL_DEVELOPMENT_NOUNS = ['local', 'mcp'] as const
177
+
178
+ /** Whether a command is in the top-level help, `help --json` and the generated reference. */
179
+ export function isListed(meta: CommandMeta): boolean {
180
+ return !meta.internal
181
+ }
182
+
154
183
  export function nouns(includeInternal = false): string[] {
155
- return [...new Set(COMMANDS.filter((c) => includeInternal || !c.meta.internal).map((c) => c.meta.noun))]
184
+ return [...new Set(COMMANDS.filter((c) => includeInternal || isListed(c.meta)).map((c) => c.meta.noun))]
156
185
  }
157
186
 
158
187
  /**
@@ -183,6 +212,8 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
183
212
  kit: 'Optional project-local Frontera skills for Codex and Claude Code',
184
213
  workspace: 'Workspaces here — the ids --workspace takes',
185
214
  forge: 'Forge — the local daemon the desktop app talks to',
215
+ mcp: 'Frontera for coding agents — runtime, docs, Apps, browser',
216
+ local: 'A runtime on this machine — apply, test and check the project',
186
217
  }
187
218
 
188
219
  export function nounSummary(noun: string): string {
@@ -308,8 +339,9 @@ export function renderHelp(): string {
308
339
  ]
309
340
 
310
341
  // Setup first and ungrouped: these are the two commands someone runs before
311
- // any noun makes sense.
312
- const setup = COMMANDS.filter((c) => c.meta.verb === '' && !c.meta.internal)
342
+ // any noun makes sense. `frontera mcp` is listed with the local loop instead.
343
+ const local = new Set<string>(LOCAL_DEVELOPMENT_NOUNS)
344
+ const setup = COMMANDS.filter((c) => c.meta.verb === '' && isListed(c.meta) && !local.has(c.meta.noun))
313
345
  if (setup.length > 0) {
314
346
  lines.push('', heading('Getting started'))
315
347
  lines.push(
@@ -317,8 +349,8 @@ export function renderHelp(): string {
317
349
  )
318
350
  }
319
351
 
320
- for (const noun of nouns()) {
321
- const group = COMMANDS.filter((c) => c.meta.noun === noun && c.meta.verb !== '')
352
+ for (const noun of nouns().filter((n) => !local.has(n))) {
353
+ const group = COMMANDS.filter((c) => c.meta.noun === noun && c.meta.verb !== '' && isListed(c.meta))
322
354
  if (group.length === 0) continue
323
355
 
324
356
  lines.push('', `${heading(accent(`frontera ${noun}`))} ${dim(nounSummary(noun))}`.trimEnd())
@@ -340,6 +372,13 @@ export function renderHelp(): string {
340
372
  }
341
373
  }
342
374
 
375
+ // The local loop, as one group: every `frontera local` verb, then `frontera mcp`.
376
+ const localGroup = COMMANDS.filter((c) => local.has(c.meta.noun) && isListed(c.meta) && !c.meta.reserved)
377
+ if (localGroup.length > 0) {
378
+ lines.push('', `${heading('Local development')} ${dim('a runtime on this machine, for you and your coding agent')}`)
379
+ lines.push(...definitions(localGroup.map((c) => [usage(c.meta).replace(/^frontera /, ''), c.meta.summary] as const), { indent: 4 }))
380
+ }
381
+
343
382
  lines.push(
344
383
  ...GLOBAL_SECTION,
345
384
  '',
@@ -398,6 +437,8 @@ export const SESSION_LANE_HELP =
398
437
  export function renderCommandHelp(meta: CommandMeta): string {
399
438
  const width = terminalWidth()
400
439
  const lines = [accent(usage(meta)), '', ...wrap(meta.summary, width)]
440
+ // A paragraph that starts indented is code, printed as written.
441
+ for (const paragraph of meta.details ?? []) lines.push('', ...(paragraph.startsWith(' ') ? paragraph.split('\n') : wrap(paragraph, width)))
401
442
 
402
443
  if (meta.reserved) {
403
444
  lines.push('', ...wrap(`Not available yet: ${meta.reserved}`, width).map(dim))
@@ -445,7 +486,7 @@ export function renderCommandHelp(meta: CommandMeta): string {
445
486
  lines.push('', heading('Flags'))
446
487
  lines.push(
447
488
  ...definitions(
448
- own.map((f) => [flagUsage(f, spec[f]!, short[f]), describeFlag(f)] as const),
489
+ own.map((f) => [flagUsage(f, spec[f]!, short[f]), describeFlag(f, meta.flagHelp)] as const),
449
490
  ),
450
491
  )
451
492
  }
@@ -471,6 +512,8 @@ export interface CommandDescriptor {
471
512
  verb: string
472
513
  usage: string
473
514
  summary: string
515
+ /** The paragraphs `--help` prints under the summary. */
516
+ details?: string[]
474
517
  available: boolean
475
518
  unavailableReason?: string
476
519
  args: Array<{ name: string; required: boolean; description: string }>
@@ -497,6 +540,7 @@ export function describeCommand(meta: CommandMeta): CommandDescriptor {
497
540
  verb: meta.verb,
498
541
  usage: usage(meta),
499
542
  summary: meta.summary,
543
+ ...(meta.details?.length ? { details: [...meta.details] } : {}),
500
544
  available: !meta.reserved,
501
545
  ...(meta.reserved ? { unavailableReason: meta.reserved } : {}),
502
546
  args: meta.args.map((a) => ({ ...a })),
@@ -506,7 +550,7 @@ export function describeCommand(meta: CommandMeta): CommandDescriptor {
506
550
  name,
507
551
  type: spec[name]!,
508
552
  ...(short[name] ? { short: short[name]! } : {}),
509
- description: describeFlag(name),
553
+ description: describeFlag(name, meta.flagHelp),
510
554
  })),
511
555
  examples: [...meta.examples],
512
556
  needsProject: Boolean(meta.needsProject),
@@ -516,7 +560,7 @@ export function describeCommand(meta: CommandMeta): CommandDescriptor {
516
560
  }
517
561
 
518
562
  export function describeCommands(noun?: string): CommandDescriptor[] {
519
- return COMMANDS.filter((c) => (noun ? c.meta.noun === noun : !c.meta.internal)).map((c) => describeCommand(c.meta))
563
+ return COMMANDS.filter((c) => (noun ? c.meta.noun === noun : isListed(c.meta))).map((c) => describeCommand(c.meta))
520
564
  }
521
565
 
522
566
  /** Levenshtein distance, iterative with one row — enough for a typo check. */
@@ -21,9 +21,21 @@ export interface CommandMeta {
21
21
  verb: string
22
22
  args: readonly ArgSpec[]
23
23
  flags: FlagSpec
24
+ /**
25
+ * This command's wording for a flag whose shared description (`FLAG_HELP`)
26
+ * would mislead here: `local test --file` names a case file to run, not a
27
+ * document to send. Rare by design; a flag still needs its `FLAG_HELP` entry.
28
+ */
29
+ flagHelp?: Readonly<Record<string, string>>
24
30
  /** Short forms, e.g. `{ f: 'file' }` for `-f`. */
25
31
  aliases?: Readonly<Record<string, string>>
26
32
  summary: string
33
+ /**
34
+ * Paragraphs `--help` prints under the summary: a contract the command reads
35
+ * that no flag describes (the file it runs, the order it fills things in).
36
+ * Agents read --help before documentation, so what they must know is here.
37
+ */
38
+ details?: readonly string[]
27
39
  /** At least one, copy-pasteable. Agents read --help before documentation. */
28
40
  examples: readonly string[]
29
41
  /**
@@ -96,6 +108,8 @@ export interface CommandContext {
96
108
  token: string
97
109
  positional: string[]
98
110
  flags: Record<string, string | boolean>
111
+ /** Every value of a string flag given more than once (`flags` holds the last). */
112
+ repeated?: Record<string, string[]>
99
113
  output: Output
100
114
  /** Present when `meta.needsProject`. */
101
115
  project: AppProject | null
@@ -104,6 +118,8 @@ export interface CommandContext {
104
118
  * acting in. Undefined for a workspace key, which carries its own.
105
119
  */
106
120
  workspaceId?: string
121
+ /** Whether a person is at a terminal; `canPrompt()` when absent. Set by tests. */
122
+ interactive?: boolean
107
123
  }
108
124
 
109
125
  /**
@@ -114,6 +130,11 @@ export interface CommandContext {
114
130
  export interface CommandResult {
115
131
  data: unknown
116
132
  text: string
133
+ /**
134
+ * The exit code when it is not 0: a command whose result is a verdict (`local check --json`) renders it on
135
+ * stdout and still exits non-zero when the verdict is a failure. Errors are thrown, never returned.
136
+ */
137
+ exitCode?: number
117
138
  }
118
139
 
119
140
  export interface Command {
@@ -126,6 +147,14 @@ export function flagString(ctx: CommandContext, name: string): string | undefine
126
147
  return typeof v === 'string' ? v : undefined
127
148
  }
128
149
 
150
+ /** Every value of a string flag, in the order given: `[]` when absent. */
151
+ export function flagStrings(ctx: CommandContext, name: string): string[] {
152
+ const all = ctx.repeated?.[name]
153
+ if (all?.length) return [...all]
154
+ const one = flagString(ctx, name)
155
+ return one === undefined ? [] : [one]
156
+ }
157
+
129
158
  export function flagBool(ctx: CommandContext, name: string): boolean {
130
159
  return ctx.flags[name] === true
131
160
  }
@@ -0,0 +1,46 @@
1
+ import { WorkflowApi } from '../../api/workflow-api'
2
+ import type { Command } from '../types'
3
+ import { requireSlug } from './push'
4
+
5
+ /**
6
+ * Archive and restore: the reversible way a workflow leaves service. Every
7
+ * version and run stays; an archived workflow is left out of `workflow list`,
8
+ * nothing starts it (Agents, webhooks, schedules) and it takes no new version
9
+ * until it is restored. The slug stays taken while it is archived.
10
+ */
11
+ export const workflowArchive: Command = {
12
+ meta: {
13
+ noun: 'workflow',
14
+ verb: 'archive',
15
+ args: [{ name: 'slug', required: true, description: 'workflow slug' }],
16
+ flags: {},
17
+ summary: 'Archive a workflow: nothing starts it, its versions and runs are kept (`workflow restore` puts it back)',
18
+ examples: ['frontera workflow archive hello'],
19
+ },
20
+
21
+ async run(ctx) {
22
+ const slug = requireSlug(ctx)
23
+ const result = await new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId).archive(slug)
24
+ return {
25
+ data: result,
26
+ text: [`Archived workflow ${slug}: nothing starts it now; its versions and runs are kept.`, ` \`frontera workflow restore ${slug}\` puts it back`].join('\n'),
27
+ }
28
+ },
29
+ }
30
+
31
+ export const workflowRestore: Command = {
32
+ meta: {
33
+ noun: 'workflow',
34
+ verb: 'restore',
35
+ args: [{ name: 'slug', required: true, description: 'workflow slug' }],
36
+ flags: {},
37
+ summary: 'Put an archived workflow back into service, as it was',
38
+ examples: ['frontera workflow restore hello'],
39
+ },
40
+
41
+ async run(ctx) {
42
+ const slug = requireSlug(ctx)
43
+ const result = await new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId).restore(slug)
44
+ return { data: result, text: `Restored workflow ${slug}.` }
45
+ },
46
+ }
@@ -39,7 +39,8 @@ export const workflowGet: Command = {
39
39
  // Only when bound. An unbound workflow has no subject line to print, and
40
40
  // an empty one reads as a missing value rather than an absent concept.
41
41
  workflow.subjectApiName ? ` subject ${workflow.subjectLabel ?? workflow.subjectApiName}` : '',
42
- ` runs ${workflow.runCount ?? 0}${workflow.lastRunStatus ? `, last ${workflow.lastRunStatus}` : ''}`,
42
+ // Live runs and test runs apart: a workflow tested twenty times and never live has 0 runs that count.
43
+ ` runs ${workflow.runCount ?? 0} live${workflow.lastRunStatus ? `, last ${workflow.lastRunStatus}` : ''}${typeof workflow.testRunCount === 'number' ? ` · ${workflow.testRunCount} test` : ''}`,
43
44
  ].filter(Boolean)
44
45
 
45
46
  const versionTable = versions.length === 0
@@ -1,3 +1,4 @@
1
+ import { workflowArchive, workflowRestore } from './archive'
1
2
  import { workflowCancel } from './cancel'
2
3
  import { workflowGet } from './get'
3
4
  import { workflowLineage } from './lineage'
@@ -6,6 +7,7 @@ import { workflowPromote } from './promote'
6
7
  import { workflowPush, workflowValidate } from './push'
7
8
  import { workflowRun } from './run'
8
9
  import { workflowRuns } from './runs'
10
+ import { workflowWebhookTest } from './webhook-test'
9
11
  import type { Command } from '../types'
10
12
 
11
13
  /**
@@ -37,5 +39,8 @@ export const workflowCommands: readonly Command[] = [
37
39
  workflowRun,
38
40
  workflowRuns,
39
41
  workflowCancel,
42
+ workflowArchive,
43
+ workflowRestore,
40
44
  workflowLineage,
45
+ workflowWebhookTest,
41
46
  ]
@@ -1,8 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises'
2
+ import { isAbsolute, join, relative, resolve } from 'node:path'
2
3
  import { parse as parseYaml } from 'yaml'
3
4
 
4
5
  import { WorkflowApi, type LineageEdge } from '../../api/workflow-api'
5
6
  import { CliError, UsageError } from '../../errors'
7
+ import { localDirectory, resolveTarget } from '../../local/local-dev-references'
8
+ import { localRuntimeAt } from '../../local/local-runtime-target'
6
9
  import type { Command, CommandContext } from '../types'
7
10
 
8
11
  /**
@@ -107,6 +110,9 @@ export const workflowPush: Command = {
107
110
  const definition = await readDefinitionFile(requireFile(ctx))
108
111
  const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
109
112
  const result = await client.push(slug, definition)
113
+ // What the service accepted but wants said (a confirm step with no one to ask, say): one line each,
114
+ // on stderr as ` warning: …`, which is also how `local dev` reads them from a push it runs.
115
+ for (const w of result.warnings ?? []) ctx.output.note(` warning: ${w}`)
110
116
 
111
117
  const reach = describeReach(result.grants, result.lineage)
112
118
  return {
@@ -118,6 +124,39 @@ export const workflowPush: Command = {
118
124
  },
119
125
  }
120
126
 
127
+ /**
128
+ * A project file's definition as the local runtime takes it: the names in it
129
+ * (`objectType/WorkOrder`, `objectSet/Open work orders`, `action/closeOrder`,
130
+ * `function/…`) become this runtime's ids, as `local dev` resolves them
131
+ * before a push. Null when the command does not reach the project's own
132
+ * local runtime, or the file is outside the project: the file is sent as
133
+ * written. The lines are what the resolving adapted, said as warnings.
134
+ */
135
+ export async function localDefinition(ctx: CommandContext, slug: string, path: string): Promise<{ definition: unknown; lines: string[] } | null> {
136
+ const local = localRuntimeAt(ctx.cwd, ctx.apiUrl)
137
+ if (!local) return null
138
+ const absolute = isAbsolute(path) ? path : resolve(ctx.cwd, path)
139
+ const file = relative(local.project.root, absolute)
140
+ if (!file || file.startsWith('..') || isAbsolute(file)) return null
141
+ const resolved = await resolveTarget(
142
+ local.project,
143
+ { kind: 'workflow', key: `workflow:${slug}`, slug, file },
144
+ () => localDirectory(local.serviceUrl, local.creds),
145
+ local.creds.workspaceId,
146
+ local.creds.organizationId,
147
+ )
148
+ if (!resolved.ok) {
149
+ const [first, ...rest] = resolved.output.split('\n')
150
+ throw new CliError(`${slug}: ${(first ?? '').replace(/^error \[[A-Z_]+\]:\s*/, '')}`, {
151
+ code: 'UNRESOLVED_REFERENCE',
152
+ hint: rest.map((l) => l.trim()).filter(Boolean).join('\n ') || 'add what it names to the project, then run `frontera local dev`',
153
+ })
154
+ }
155
+ const lines = resolved.output.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => (/^warning:/i.test(l) ? ` ${l}` : ` warning: ${l}`))
156
+ const definition = resolved.file ? await readDefinitionFile(join(local.project.root, resolved.file)) : (resolved.doc ?? (await readDefinitionFile(absolute)))
157
+ return { definition, lines }
158
+ }
159
+
121
160
  /**
122
161
  * Check a definition without writing anything.
123
162
  *
@@ -137,12 +176,19 @@ export const workflowValidate: Command = {
137
176
  ],
138
177
  flags: {},
139
178
  summary: 'Validate a definition and report its reach — writes nothing',
140
- examples: ['frontera workflow validate invoice-intake workflow.yaml'],
179
+ details: [
180
+ 'Inside a project whose local runtime is running (the directory is bound to it), the file is read as `frontera local dev` reads it: the names it uses (`objectType/WorkOrder`, `objectSet/Open work orders`, `action/closeOrder`, `function/nearest-technician`) become the runtime\'s ids before the check, and what that adapts is said as a warning. Elsewhere the file is sent as written.',
181
+ 'Warnings are what the service accepts but wants said, such as a confirm step with no `onUnavailable` in a workflow no person can answer. They print on stderr as ` warning: …` and come back as `warnings` with --json.',
182
+ ],
183
+ examples: ['frontera workflow validate invoice-intake workflow.yaml', 'frontera workflow validate overdue-sweep workflows/overdue-sweep.yaml --json'],
141
184
  },
142
185
 
143
186
  async run(ctx) {
144
187
  const slug = requireSlug(ctx)
145
- const definition = await readDefinitionFile(requireFile(ctx))
188
+ const path = requireFile(ctx)
189
+ const local = await localDefinition(ctx, slug, path)
190
+ const definition = local ? local.definition : await readDefinitionFile(path)
191
+ for (const line of local?.lines ?? []) ctx.output.note(line)
146
192
  const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
147
193
  // A dry run reports problems as DATA, not as a 400 — the service does this
148
194
  // so the Console can show them beside the draft. So the refusal arrives
@@ -161,6 +207,7 @@ export const workflowValidate: Command = {
161
207
  })
162
208
  }
163
209
 
210
+ for (const w of result.warnings ?? []) ctx.output.note(` warning: ${w}`)
164
211
  const reach = describeReach(result.grants, result.lineage)
165
212
  return {
166
213
  data: result,