@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) 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 +49 -5
  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/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -1,64 +1,677 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
3
+ import { join, normalize, relative } from 'node:path'
2
4
 
3
- import { CliError } from '../../errors'
4
- import { localProject } from '../../local/local-project'
5
+ import { CliError, UsageError } from '../../errors'
6
+ import { EXIT } from '../../exit'
7
+ import { appSdkDrift, isVendoredSpec, sdkDriftLines, vendoredShadows } from '../../local/local-app-sdk'
8
+ import { lockedSdkVersions } from '../../app-lock'
9
+ import { projectBuildRows } from '../../local/local-build-summary'
10
+ import { allInherited, inheritedActionWarnings, lockRefOf, typecheckErrorFiles, unchangedSincePull } from '../../local/local-check-inherited'
11
+ import { appPulledAsIs, keepBuildWrittenFiles } from '../../local/local-app-pulled'
12
+ import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
13
+ import { repullRefFor } from '../../local/local-app-code'
14
+ import { customAppFolders } from '../../local/local-apps'
15
+ import { readBaseline, restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
16
+ import { localIngestApi, settleKnowledge, type KnowledgeSettle } from '../../local/local-knowledge-ingestion'
17
+ import { localProject, type LocalProject } from '../../local/local-project'
18
+ import { projectSecretLines } from '../../local/local-project-secrets'
19
+ import { localSecretPresence } from '../../local/local-secrets'
20
+ import { waitForDatabaseReady } from '../../local/local-runtime-health'
5
21
  import { isAlive, readState } from '../../local/local-state'
6
- import { flagBool, type Command } from '../types'
22
+ import { recordRun } from '../../local/local-test-baseline'
23
+ import { notRunGroups, notRunWords } from '../../local/local-case-not-run'
24
+ import { evidenceLine, failingCaseEvidence, type CaseEvidence } from '../../local/local-case-evidence'
25
+ import { agentFilesProblem, caseConcurrency, findCaseFiles, runCases, selectCaseFiles, waitedWords } from '../../local/local-test-runner'
26
+ import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflowFileState } from '../../local/local-workflow-tests'
27
+ import { vendoredSdkLine, vendoredSdkState } from '../../local/local-vendored-sdk'
28
+ import { withFix } from '../../local/fix-words'
29
+ import { ensureAppPackages } from '../../project-bootstrap'
30
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
7
31
 
8
32
  /**
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.
33
+ * `frontera local check`: the one gate, with one exit code, for a pre-push
34
+ * hook, CI, or a coding agent saying it is done (like `supabase start` then
35
+ * `supabase test`). Every step runs and is reported, in order; only the
36
+ * runtime is a prerequisite: when start or apply fails, the steps that test
37
+ * on the runtime (agents, workflows) are skipped and say why. `--fail-fast`
38
+ * stops at the first failure instead:
39
+ *
40
+ * start the runtime, if it is not running
41
+ * apply `local dev --once`: every item applied; a governed Action that didn't deploy fails it
42
+ * typecheck the project's `typecheck` script, else `tsc --noEmit` when it has a tsconfig.json
43
+ * unit `bun test` over the Functions' test files (functions/**\/*.test.ts)
44
+ * apps each Custom App's own `bun run check` (apps/<name>/, or any folder with an App's frontera.config.json)
45
+ * agents the Agents' test cases (agents/<slug>/evals/*.yaml)
46
+ * workflows the workflow tests (workflows/tests/<slug>.yaml)
47
+ *
48
+ * A step with nothing to check is skipped, with a note, and is not a
49
+ * failure. `--json` returns each step's outcome on stdout, failed or not
50
+ * (exit 1 when it failed). `--stop` stops the runtime afterwards.
51
+ *
52
+ * The tests write for real (a workflow test always does, as does an Agent
53
+ * case with `realActions` or a `decision`), so after the apply the database
54
+ * is kept as a baseline (`local-db-snapshot.ts`, taken again only when what
55
+ * is applied changed), and after the tests it is restored: each check starts
56
+ * from the data the project's files make. `--keep-state` keeps what the tests
57
+ * wrote, to look at it.
58
+ *
59
+ * Knowledge documents are read by a background job whose work lands in the
60
+ * database: before the baseline and the tests, the check waits (bounded)
61
+ * until no document is still being read, and queues again any left stuck
62
+ * with no job behind it; after the restore it queues again every document
63
+ * the baseline holds unread (`local-knowledge-ingestion.ts`), so each
64
+ * check's tests search the same documents.
65
+ *
66
+ * In a pulled project, a failure only in items or files unchanged since the
67
+ * pull (`frontera.lock`) is inherited from the workspace: the step passes,
68
+ * and lists each one, unless `--strict`, which fails on them too. A failure
69
+ * in anything the change touched or added fails the step as always.
13
70
  */
14
71
 
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]
72
+ /**
73
+ * The gate's rule for inherited failures, in the sentence the passed result
74
+ * says it in: `12 inherited failures in items unchanged since the pull, which
75
+ * pass unless --strict (anything changed or added fails the gate)`.
76
+ */
77
+ export function inheritedWords(count: number): string {
78
+ return `${count} inherited failure${count === 1 ? '' : 's'} in items unchanged since the pull, which pass unless --strict (anything changed or added fails the gate)`
79
+ }
80
+
81
+ export const CHECK_STEPS = ['start', 'apply', 'typecheck', 'unit', 'apps', 'agents', 'workflows'] as const
82
+ export type CheckStepName = (typeof CHECK_STEPS)[number]
83
+ /**
84
+ * The steps each one needs to have passed: the runtime's tests need it started.
85
+ * They do not need every item applied: an Agent's or workflow's cases run on
86
+ * what applied, and the cases of one whose own files did not apply fail with
87
+ * why (`agentFilesProblem`, `workflowFileState`). So one item that no test
88
+ * touches (a pulled Function missing a file) never hides every test result;
89
+ * the apply failure still fails the gate.
90
+ */
91
+ const NEEDS: Partial<Record<CheckStepName, CheckStepName[]>> = { apply: ['start'], agents: ['start'], workflows: ['start'] }
92
+
93
+ /** Why a step can't run, given the steps that failed so far; null when it can. */
94
+ export function blockedBy(step: CheckStepName, failed: ReadonlySet<CheckStepName>, failFast = false): string | null {
95
+ if (failFast && failed.size) return 'not run: an earlier step failed (--fail-fast)'
96
+ const missing = (NEEDS[step] ?? []).filter((n) => failed.has(n))
97
+ return missing.length ? `not run: ${missing.join(' and ')} failed, and it runs on the applied runtime` : null
98
+ }
99
+
100
+ /** What `--skip` may leave out: the runtime and the apply are the gate itself. */
101
+ const SKIPPABLE: readonly CheckStepName[] = ['typecheck', 'unit', 'apps', 'agents', 'workflows']
102
+
103
+ /** What became of the tests' writes: rolled back to the applied baseline, kept, or not rolled back and why. */
104
+ export interface CheckTestData {
105
+ /** taken: a new baseline after this apply; reused: the stored one is of this build. */
106
+ baseline?: 'taken' | 'reused'
107
+ rolledBack: boolean
108
+ /** --keep-state */
109
+ kept?: boolean
110
+ note: string
111
+ ms?: number
112
+ }
113
+
114
+ export interface CheckStep {
115
+ step: CheckStepName
116
+ status: 'passed' | 'failed' | 'skipped'
117
+ /**
118
+ * What failed in items or files unchanged since the pull (`frontera.lock`):
119
+ * inherited from the workspace, not made by the change. A step whose every
120
+ * failure is inherited passes unless `--strict`, and lists them here.
121
+ */
122
+ inherited?: string[]
123
+ /**
124
+ * apply: warnings inherited from the workspace on items that applied (a
125
+ * governed Action offering a value the semantic type it writes refuses,
126
+ * deployed as the workspace runs it). Listed; never a failure, even with `--strict`.
127
+ */
128
+ inheritedWarnings?: string[]
129
+ /** What ran, as a person would type it. */
130
+ command?: string
131
+ /** Why it was skipped, or what failed, in a line. */
132
+ note?: string
133
+ ms: number
134
+ /**
135
+ * agents, workflows: each case that failed, with why; an Agent case also with its conversation, the trace of
136
+ * its last turn (kept after the rollback: traces live outside the platform database) and the start of its reply.
137
+ */
138
+ failures?: Array<{ name: string; reason?: string } & CaseEvidence>
139
+ /**
140
+ * agents: the cases that couldn't run, by cause (the model provider refused
141
+ * them, the turn timed out, the runtime did not answer), with the fix. The
142
+ * step does not pass, but these are not failures of the Agent.
143
+ */
144
+ couldNotRun?: Array<{ what: string; fix: string; cases: string[]; notSent?: number }>
145
+ }
146
+
147
+ /** Run a command in the project, its output on stderr so stdout stays the check's own result. */
148
+ function runIn(cwd: string, command: string[]): Promise<number> {
19
149
  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))
150
+ const child = spawn(command[0]!, command.slice(1), { cwd, stdio: ['ignore', 2, 2], env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? '1' } })
151
+ child.on('error', () => resolve(127))
22
152
  child.on('close', (code) => resolve(code ?? 1))
23
153
  })
24
154
  }
25
155
 
156
+ /** As `runIn`, and what it printed, for reading which files a failure is in. */
157
+ function runCapturing(cwd: string, command: string[]): Promise<{ code: number; output: string }> {
158
+ return new Promise((resolve) => {
159
+ let output = ''
160
+ const child = spawn(command[0]!, command.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? '1' } })
161
+ const pass = (chunk: Buffer) => {
162
+ output += chunk.toString()
163
+ process.stderr.write(chunk)
164
+ }
165
+ child.stdout.on('data', pass)
166
+ child.stderr.on('data', pass)
167
+ child.on('error', () => resolve({ code: 127, output }))
168
+ child.on('close', (code) => resolve({ code: code ?? 1, output }))
169
+ })
170
+ }
171
+
172
+ /**
173
+ * Why an App's own `bun run check` failed, in one line: the first error it
174
+ * printed (a TypeScript error with its file and line, a missing command, an
175
+ * `error:` line) and what fixes it. A copy of the SDK vendored under
176
+ * src/frontera/ while the App's bun.lock installed a published one is named
177
+ * as the cause, with the command that switches back.
178
+ */
179
+ export function appCheckFailure(appDir: string, app: string, code: number, output: string): string {
180
+ const lines = output.replace(/\u001b\[[0-9;]*m/g, '').split('\n').map((l) => l.trim()).filter(Boolean)
181
+ const tsError = lines.find((l) => /error TS\d+:/.test(l))
182
+ const missing = lines.find((l) => /command not found|not found: [\w.-]+$|Cannot find module|Could not resolve/i.test(l))
183
+ const other = lines.find((l) => /^(error|✕|FAIL)\b|\berror:/i.test(l))
184
+ const first = (tsError ?? missing ?? other ?? '').slice(0, 300)
185
+ const exited = `exited ${code}`
186
+ if ((code === 127 || /command not found/i.test(missing ?? '')) && !existsSync(join(appDir, 'node_modules'))) {
187
+ // A copy shadowing the release its bun.lock installed: a plain install would rewrite the lockfile to the copy.
188
+ const shadowed = vendoredShadows(appDir)
189
+ if (shadowed.length) return `${exited}: ${first || 'a command it runs is not installed'}. Its packages aren't installed, and package.json takes the SDK copy under src/frontera/ where its bun.lock installed the published ${shadowed.map((v) => `${v.pkg} ${v.version}`).join(' and ')}: run \`frontera app sdk use-local --npm\` in ${app} (it installs too), then check again`
190
+ const locked = existsSync(join(appDir, 'bun.lock')) || existsSync(join(appDir, 'bun.lockb'))
191
+ return `${exited}: ${first || 'a command it runs is not installed'}. Its packages aren't installed: run \`bun install${locked ? ' --frozen-lockfile' : ''}\` in ${app}, then check again`
192
+ }
193
+ if (tsError) {
194
+ const manifest = (() => {
195
+ try {
196
+ return JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> }
197
+ } catch {
198
+ return null
199
+ }
200
+ })()
201
+ const vendoredSdk = Object.entries(manifest?.dependencies ?? {}).filter(([pkg, spec]) => isVendoredSpec(pkg, spec)).map(([pkg]) => pkg)
202
+ const locked = lockedSdkVersions(appDir)
203
+ const published = vendoredSdk.filter((pkg) => locked.has(pkg))
204
+ if (published.length) {
205
+ // An earlier pull's folder, still as it left it: the pull that brings the deployed source back comes first.
206
+ const repull = repullRefFor(appDir)
207
+ const fix = repull ? `\`frontera local sync pull ${repull}\` brings the deployed source back, or \`frontera app sdk use-local --npm\` in ${app} goes back to that release where it is` : `run \`frontera app sdk use-local --npm\` in ${app}`
208
+ return `${exited}: ${first}. Its SDK is a copy under src/frontera/, not the published ${published.map((p) => `${p} ${locked.get(p)!.version}`).join(' and ')} its bun.lock installed and its code was written against: ${fix}, then check again`
209
+ }
210
+ return `${exited}: ${first}. Fix it in ${app} (\`bun run typecheck\` there shows every error)`
211
+ }
212
+ if (first) return `${exited}: ${first}. Run \`bun run check\` in ${app} to see the whole output`
213
+ return `${exited}. Run \`bun run check\` in ${app} to see why`
214
+ }
215
+
216
+ /**
217
+ * An App's packages before its own check, when it has none (a pulled App
218
+ * comes without node_modules), installed without writing a tracked file: from
219
+ * its bun.lock as it is (`--frozen-lockfile`), or, when package.json names
220
+ * the App's own SDK copy under src/frontera/ where bun.lock installed a
221
+ * published release, as package.json says with `bun install --no-save`
222
+ * (`ensureAppPackages`). With no lockfile nothing is installed (an install
223
+ * would write one) and the line says so. The line to say, or null when it
224
+ * already had them.
225
+ */
226
+ export async function installForCheck(appDir: string, app: string, install?: Parameters<typeof ensureAppPackages>[1]): Promise<string | null> {
227
+ if (existsSync(join(appDir, 'node_modules')) || !existsSync(join(appDir, 'package.json'))) return null
228
+ if (!['bun.lock', 'bun.lockb'].some((f) => existsSync(join(appDir, f)))) return `${app} has no node_modules and no bun.lock: \`bun install\` there first if its check needs its packages (it writes a bun.lock)`
229
+ const result = await ensureAppPackages(appDir, install)
230
+ if (!result) return null
231
+ if ((result.fromCopy || result.fromLock) && result.installed) return `${app} had no node_modules: ${result.summary}`
232
+ if (result.installed) return `${app} had no node_modules: installed them from its bun.lock as it is (\`bun install --frozen-lockfile\`; the lockfile is unchanged) (${result.summary})`
233
+ if (result.lockMismatch) return `${app} has no node_modules, and they weren't installed: ${result.summary}`
234
+ return `${app} has no node_modules, and installing them failed: ${result.summary}`
235
+ }
236
+
237
+ /** This CLI, again. */
238
+ function self(args: string[]): string[] {
239
+ const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
240
+ return compiled ? [process.execPath, ...args] : [process.execPath, Bun.main, ...args]
241
+ }
242
+
243
+ function readPackage(dir: string): { scripts?: Record<string, string> } | null {
244
+ try {
245
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }
246
+ } catch {
247
+ return null
248
+ }
249
+ }
250
+
251
+ /** How the project is typechecked: its own script first, else the compiler over its tsconfig; null when it has neither. */
252
+ export function typecheckCommand(root: string): string[] | null {
253
+ if (readPackage(root)?.scripts?.typecheck) return ['bun', 'run', 'typecheck']
254
+ if (existsSync(join(root, 'tsconfig.json'))) return ['bunx', 'tsc', '--noEmit', '-p', 'tsconfig.json']
255
+ return null
256
+ }
257
+
258
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.frontera', 'dist', '.next', 'build'])
259
+
260
+ /** The Functions' unit test files, relative to the project root, `./`-prefixed so `bun test` takes them as paths. */
261
+ export function functionTestFiles(root: string): string[] {
262
+ const out: string[] = []
263
+ const walk = (dir: string, depth: number) => {
264
+ if (depth > 6 || !existsSync(dir)) return
265
+ for (const name of readdirSync(dir).sort()) {
266
+ if (SKIP_DIRS.has(name) || name.startsWith('.')) continue
267
+ const path = join(dir, name)
268
+ let stat
269
+ try {
270
+ stat = lstatSync(path)
271
+ } catch {
272
+ continue
273
+ }
274
+ if (stat.isDirectory()) walk(path, depth + 1)
275
+ else if (/\.(test|spec)\.(ts|tsx|js|mjs)$/.test(name)) out.push(`./${relative(root, path)}`)
276
+ }
277
+ }
278
+ walk(join(root, 'functions'), 0)
279
+ return out
280
+ }
281
+
282
+ /**
283
+ * Each Custom App's code folder that has its own `check` script: apps/<name>/,
284
+ * and any other folder in the project an App was made in, found by its
285
+ * manifest (`customAppFolders`). The project's own root is not one.
286
+ */
287
+ export function appsWithCheck(root: string): string[] {
288
+ return customAppFolders(root).filter((dir) => !!readPackage(join(root, dir))?.scripts?.check)
289
+ }
290
+
291
+ function parseSkip(raw: string | undefined): Set<CheckStepName> {
292
+ const names = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean)
293
+ for (const n of names) {
294
+ if (!SKIPPABLE.includes(n as CheckStepName)) throw new UsageError(`\`${n}\` is not a step --skip can leave out`, `use one or more of: ${SKIPPABLE.join(', ')}`)
295
+ }
296
+ return new Set(names as CheckStepName[])
297
+ }
298
+
299
+ function running(project: LocalProject): boolean {
300
+ const state = readState(project)
301
+ return state?.phase === 'ready' && !!state.supervisorPid && isAlive(state.supervisorPid)
302
+ }
303
+
304
+ async function check(ctx: CommandContext) {
305
+ const project = localProject(ctx.cwd)
306
+ const root = project.root
307
+ const skip = parseSkip(flagString(ctx, 'skip'))
308
+ const steps: CheckStep[] = []
309
+ const failedSteps = new Set<CheckStepName>()
310
+ const failFast = flagBool(ctx, 'fail-fast')
311
+ /** What secrets.json says is missing here, for `--json`. */
312
+ const secretNotes: string[] = []
313
+ /** Apps whose installed `@frontera-sdk/*` is older than (or lacks what is in) this CLI's SDK, for `--json`. */
314
+ const sdkNotes: string[] = []
315
+ // A pulled project: what is still as the workspace gave it, so a failure only there is inherited (`--strict` fails on it).
316
+ const strict = flagBool(ctx, 'strict')
317
+ // How many Agent cases run at once (`--concurrency`), checked before anything starts.
318
+ let concurrency: number | undefined
319
+ try {
320
+ concurrency = caseConcurrency(flagString(ctx, 'concurrency'))
321
+ } catch (err) {
322
+ throw new UsageError((err as Error).message, 'frontera local check --concurrency 1')
323
+ }
324
+ const unchanged = unchangedSincePull(root)
325
+ // The tests' writes: a baseline after the apply, restored after the tests unless `--keep-state`.
326
+ const keepState = flagBool(ctx, 'keep-state')
327
+ let testData: CheckTestData | null = null
328
+ let baselineReady = false
329
+ /** What became of the knowledge bases' documents around the tests: queued again, still being read, for `--json`. */
330
+ const knowledgeNotes: string[] = []
331
+ const sayKnowledge = (settled: KnowledgeSettle) => {
332
+ for (const line of settled.lines) ctx.output.note(` ${line}`)
333
+ knowledgeNotes.push(...settled.lines.map((l) => l.replace(/^warning:\s*/, '')))
334
+ }
335
+ /** Whether the Agent or workflow tests will run anything, and so may write. */
336
+ const testsWillRun = () =>
337
+ (!skip.has('agents') && !blockedBy('agents', failedSteps, failFast) && findCaseFiles(root).some((f) => f.cases.length > 0))
338
+ || (!skip.has('workflows') && !blockedBy('workflows', failedSteps, failFast) && findWorkflowTestFiles(root).some((f) => f.cases.length > 0))
339
+
340
+ const say = (step: CheckStepName, line: string) => ctx.output.note(`── ${step}: ${line}`)
341
+ const record = (s: CheckStep) => {
342
+ steps.push(s)
343
+ if (s.status === 'failed') failedSteps.add(s.step)
344
+ if (s.status === 'skipped') say(s.step, `skipped — ${s.note}`)
345
+ else if (s.status === 'failed') say(s.step, `failed${s.note ? ` — ${s.note}` : ''}`)
346
+ else say(s.step, `passed (${s.ms} ms)${s.inherited?.length ? `, with ${s.inherited.length} inherited failure${s.inherited.length === 1 ? '' : 's'}` : ''}`)
347
+ // Inherited failures are listed, never hidden: the gate passes over them only without --strict.
348
+ if (s.inherited?.length) {
349
+ ctx.output.note(` inherited from the workspace (unchanged since the pull${strict ? '' : '; `--strict` fails on them'}):`)
350
+ for (const i of s.inherited) ctx.output.note(` ${i}`)
351
+ }
352
+ if (s.inheritedWarnings?.length) {
353
+ ctx.output.note(' warnings inherited from the workspace (they run as the workspace runs them, and fail nothing):')
354
+ for (const w of s.inheritedWarnings) ctx.output.note(` ${w}`)
355
+ }
356
+ }
357
+ /** One command; the step fails on a non-zero exit. */
358
+ const command = async (step: CheckStepName, cwd: string, args: string[], shown: string) => {
359
+ say(step, shown)
360
+ const started = Date.now()
361
+ const code = await runIn(cwd, args)
362
+ record({ step, status: code === 0 ? 'passed' : 'failed', command: shown, ms: Date.now() - started, ...(code === 0 ? {} : { note: `\`${shown}\` exited ${code}` }) })
363
+ }
364
+
365
+ for (const step of CHECK_STEPS) {
366
+ const blocked = blockedBy(step, failedSteps, failFast)
367
+ if (blocked) {
368
+ record({ step, status: 'skipped', note: blocked, ms: 0 })
369
+ continue
370
+ }
371
+ if (skip.has(step)) {
372
+ record({ step, status: 'skipped', note: 'left out with --skip', ms: 0 })
373
+ continue
374
+ }
375
+ if (step === 'start') {
376
+ if (running(project)) record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
377
+ else await command(step, root, self(['local', 'start']), 'frontera local start')
378
+ continue
379
+ }
380
+ if (step === 'apply') {
381
+ say(step, 'frontera local dev --once')
382
+ const started = Date.now()
383
+ const code = await runIn(root, self(['local', 'dev', '--once']))
384
+ // Which items did not apply, and whether each is still as the pull wrote it. `local dev --once` makes the
385
+ // same split and exits 0 when every failure is inherited: then the rows it failed on are those, and
386
+ // `--strict` fails on them here. A row failing from before, which this apply fixed, is not one.
387
+ const built = projectBuildRows(root) ?? []
388
+ const failedRows = built.filter((r) => r.state === 'failed' || r.state === 'blocked')
389
+ const failing = code === 0 ? failedRows.filter((r) => unchanged.refs.has(lockRefOf(r.ref))) : failedRows
390
+ const warned = inheritedActionWarnings(built)
391
+ const warnings = warned.length ? { inheritedWarnings: warned } : {}
392
+ const lines = failing.map((r) => `${r.ref}${r.reason ? `: ${r.reason}` : ''}`)
393
+ // `local dev` decides (it also sees a file added to a pulled item, and a runtime that stopped answering): any other exit fails the step.
394
+ const inheritedOnly = !strict && code === 0
395
+ if (code === 0 && !failing.length) record({ step, status: 'passed', command: 'frontera local dev --once', ms: Date.now() - started, ...warnings })
396
+ else if (inheritedOnly) record({ step, status: 'passed', command: 'frontera local dev --once', ms: Date.now() - started, inherited: lines, note: `${failing.length} item${failing.length === 1 ? '' : 's'} unchanged since the pull didn’t apply`, ...warnings })
397
+ else {
398
+ const made = failing.filter((r) => !unchanged.refs.has(lockRefOf(r.ref)))
399
+ const note = failing.length
400
+ ? `${failing.length} item${failing.length === 1 ? '' : 's'} didn’t apply (${(made.length ? made : failing).slice(0, 4).map((r) => r.ref).join(', ')}${(made.length ? made : failing).length > 4 ? ', …' : ''}); the Agent and workflow tests still run on what applied`
401
+ : `\`frontera local dev --once\` exited ${code}`
402
+ record({ step, status: 'failed', command: 'frontera local dev --once', ms: Date.now() - started, note, ...warnings, ...(failing.length ? { failures: failing.map((r) => ({ name: r.ref, ...(r.reason ? { reason: r.reason } : {}) })) } : {}), ...(failing.length - made.length ? { inherited: failing.filter((r) => unchanged.refs.has(lockRefOf(r.ref))).map((r) => `${r.ref}${r.reason ? `: ${r.reason}` : ''}`) } : {}) })
403
+ }
404
+ // What secrets.json declares and the workflows and Functions name that the runtime has no value for,
405
+ // unchanged items' included: said, never a failure (a value is each computer's own).
406
+ const state = readState(project)
407
+ if (state?.credentials) {
408
+ const lines = projectSecretLines(root, await localSecretPresence(`http://127.0.0.1:${state.ports.service}`, state.credentials))
409
+ for (const line of lines) ctx.output.note(` ${line.trim()}`)
410
+ if (lines.length) secretNotes.push(...lines.map((l) => l.trim().replace(/^warning:\s*/, '')))
411
+ }
412
+ // The knowledge as the tests and the baseline should see it: a document stuck with no job behind it
413
+ // (a rollback by an earlier release) queued again, then every document read (bounded), so the
414
+ // baseline never holds a document mid-read and each check's tests search the same documents.
415
+ const ingest = state?.credentials && running(project) && testsWillRun() ? localIngestApi(`http://127.0.0.1:${state.ports.service}`, state.credentials) : null
416
+ if (ingest && state?.credentials) sayKnowledge(await settleKnowledge(ingest, state.credentials.workspaceId, { restoredAt: readBaseline(project, state.credentials.organizationId)?.restoredAt }))
417
+ // The database as applied, before the tests write to it.
418
+ if (state?.credentials && running(project) && testsWillRun()) {
419
+ if (keepState) testData = { rolledBack: false, kept: true, note: 'kept what the tests write (--keep-state)' }
420
+ else {
421
+ const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
422
+ baselineReady = saved.ok
423
+ if (saved.ok) testData = { baseline: saved.took ? 'taken' : 'reused', rolledBack: false, note: saved.note }
424
+ else testData = { rolledBack: false, note: `not rolled back: ${saved.note}` }
425
+ ctx.output.note(` test data: ${saved.ok ? saved.note : `couldn’t keep a baseline, so what the tests write stays: ${saved.note}`}`)
426
+ }
427
+ }
428
+ continue
429
+ }
430
+ if (step === 'typecheck') {
431
+ const args = typecheckCommand(root)
432
+ if (!args) {
433
+ record({ step, status: 'skipped', note: 'no `typecheck` script and no tsconfig.json', ms: 0 })
434
+ continue
435
+ }
436
+ const shown = args.join(' ')
437
+ say(step, shown)
438
+ const started = Date.now()
439
+ const run = await runCapturing(root, args)
440
+ // Errors only in files unchanged since the pull are the workspace's, not the change's.
441
+ const inFiles = typecheckErrorFiles(run.output)
442
+ const sdkLine = run.code !== 0 ? vendoredSdkLine(vendoredSdkState(root)) : null
443
+ if (sdkLine && !sdkNotes.includes(sdkLine)) {
444
+ ctx.output.note(` ${sdkLine}`)
445
+ sdkNotes.push(sdkLine)
446
+ }
447
+ const inherited = run.code !== 0 && !strict && allInherited(inFiles, unchanged.files)
448
+ if (run.code === 0) record({ step, status: 'passed', command: shown, ms: Date.now() - started })
449
+ else if (inherited) record({ step, status: 'passed', command: shown, ms: Date.now() - started, inherited: inFiles.map((f) => `${f}: type errors`) })
450
+ else record({ step, status: 'failed', command: shown, ms: Date.now() - started, note: `\`${shown}\` exited ${run.code}${inFiles.length ? ` (errors in ${inFiles.filter((f) => !unchanged.files.has(f)).slice(0, 4).join(', ') || inFiles.slice(0, 4).join(', ')})` : ''}`, ...(inFiles.some((f) => unchanged.files.has(f)) ? { inherited: inFiles.filter((f) => unchanged.files.has(f)).map((f) => `${f}: type errors`) } : {}) })
451
+ continue
452
+ }
453
+ if (step === 'unit') {
454
+ const files = functionTestFiles(root)
455
+ if (!files.length) {
456
+ record({ step, status: 'skipped', note: 'no Function unit tests (functions/**/*.test.ts)', ms: 0 })
457
+ continue
458
+ }
459
+ // The change's tests, then the pulled ones still as the workspace gave them: those failing is inherited.
460
+ const pulled = files.filter((f) => unchanged.files.has(normalize(f.replace(/^\.\//, ''))))
461
+ const own = files.filter((f) => !pulled.includes(f))
462
+ const started = Date.now()
463
+ const described = (list: string[]) => `bun test ${list.length === 1 ? list[0] : `(${list.length} files under functions/)`}`
464
+ let ownCode = 0
465
+ if (own.length) {
466
+ say(step, described(own))
467
+ ownCode = await runIn(root, ['bun', 'test', ...own])
468
+ }
469
+ let pulledCode = 0
470
+ if (pulled.length) {
471
+ say(step, `${described(pulled)}, unchanged since the pull`)
472
+ pulledCode = await runIn(root, ['bun', 'test', ...pulled])
473
+ }
474
+ const shown = described(files)
475
+ // A failing test may be failing on the project's own older copy of the SDK: said, with the command that refreshes it.
476
+ const sdkLine = ownCode !== 0 || pulledCode !== 0 ? vendoredSdkLine(vendoredSdkState(root)) : null
477
+ if (sdkLine) {
478
+ ctx.output.note(` ${sdkLine}`)
479
+ sdkNotes.push(sdkLine)
480
+ }
481
+ if (ownCode === 0 && pulledCode === 0) record({ step, status: 'passed', command: shown, ms: Date.now() - started })
482
+ else if (ownCode === 0 && !strict) record({ step, status: 'passed', command: shown, ms: Date.now() - started, inherited: [`${described(pulled)} exited ${pulledCode}: a test file still as the workspace gave it fails`] })
483
+ else record({ step, status: 'failed', command: shown, ms: Date.now() - started, note: ownCode !== 0 ? `\`${described(own)}\` exited ${ownCode}` : `\`${described(pulled)}\` exited ${pulledCode} (test files unchanged since the pull; --strict)`, ...(ownCode !== 0 && pulledCode !== 0 ? { inherited: [`${described(pulled)} exited ${pulledCode}`] } : {}) })
484
+ continue
485
+ }
486
+ if (step === 'apps') {
487
+ const apps = appsWithCheck(root)
488
+ if (!apps.length) {
489
+ record({ step, status: 'skipped', note: 'no Custom App with a `check` script (apps/<name>/, or a folder with frontera.config.json)', ms: 0 })
490
+ continue
491
+ }
492
+ const started = Date.now()
493
+ const broken: Array<{ name: string; reason: string }> = []
494
+ for (const app of apps) {
495
+ say(step, `bun run check in ${app}`)
496
+ // A pulled App comes without its packages: installed first, writing no tracked file, so its check checks it.
497
+ const installNote = await installForCheck(join(root, app), app)
498
+ if (installNote) ctx.output.note(` ${installNote}`)
499
+ // Said, never a failure: the App's own check decides whether the code still builds.
500
+ for (const line of sdkDriftLines(app, appSdkDrift(join(root, app)))) {
501
+ ctx.output.note(` ${line}`)
502
+ // A note (the App builds against a published release on purpose) is said, not listed as something to fix.
503
+ if (/^warning:/.test(line)) sdkNotes.push(line.replace(/^warning:\s*/, ''))
504
+ }
505
+ // Its build info, next-env.d.ts and tsconfig.json go back as they were: a check doesn't change the tree (a pulled App tracks them).
506
+ const putBack = keepBuildWrittenFiles(join(root, app))
507
+ // Installed as its bun.lock records while tsconfig.json still sends the SDK to the older copy under
508
+ // src/frontera/: the check runs with those paths set aside, so it checks the code against the release it was written for.
509
+ const aside = setAsideCopyPaths(join(root, app))
510
+ if (aside) ctx.output.note(` ${app}: ${aside.line}`)
511
+ let restored: string[] = []
512
+ const { code, output } = await runCapturing(join(root, app), ['bun', 'run', 'check']).finally(() => {
513
+ if (aside) ctx.output.note(` ${app}: ${aside.restore()}`)
514
+ restored = putBack()
515
+ })
516
+ if (restored.length) ctx.output.note(` ${app}: its check wrote ${restored.join(', ')}; put back as they were before it`)
517
+ if (code !== 0) {
518
+ const why = appCheckFailure(join(root, app), app, code, output)
519
+ broken.push({ name: app, reason: why })
520
+ ctx.output.note(` ${app}: ${why}`)
521
+ }
522
+ }
523
+ // A pulled App whose source is still as the pull left it fails through no change here: inherited, as
524
+ // a pulled Function's test is (`--strict` fails on it). An App pulled before pulls recorded its files is
525
+ // told by its frontera.lock entry and the one unpack that wrote it (`appPulledAsIs`).
526
+ const pulledAsIs = broken.filter((b) => appPulledAsIs(root, b.name) === true)
527
+ const inheritedApps = pulledAsIs.map((b) => `${b.name}: ${b.reason}`)
528
+ if (broken.length && !strict && pulledAsIs.length === broken.length) record({ step, status: 'passed', command: 'bun run check', ms: Date.now() - started, inherited: inheritedApps })
529
+ else record({ step, status: broken.length ? 'failed' : 'passed', command: 'bun run check', ms: Date.now() - started, ...(broken.length ? { note: broken.map((b) => `${b.name}: ${b.reason}`).join('; '), failures: broken } : {}), ...(inheritedApps.length ? { inherited: inheritedApps } : {}) })
530
+ continue
531
+ }
532
+ // The test steps run here, against the runtime the steps above started and applied.
533
+ const state = readState(project)
534
+ if (!state?.credentials?.session || !running(project)) {
535
+ record({ step, status: 'failed', note: 'the local runtime is not running', ms: 0 })
536
+ continue
537
+ }
538
+ const serviceUrl = `http://127.0.0.1:${state.ports.service}`
539
+ if (step === 'agents') {
540
+ const files = selectCaseFiles(root)
541
+ if (!files.some((f) => f.cases.length || f.problem)) {
542
+ record({ step, status: 'skipped', note: 'no Agent test cases (agents/<slug>/evals/*.yaml)', ms: 0 })
543
+ continue
544
+ }
545
+ say(step, 'frontera local test (Agents)')
546
+ const started = Date.now()
547
+ const orgId = state.credentials.organizationId
548
+ const results = await runCases({
549
+ serviceUrl,
550
+ creds: state.credentials,
551
+ files,
552
+ live: false,
553
+ concurrency,
554
+ staleDraft: (slug) => agentFilesProblem(project, orgId, slug),
555
+ onResult: (r) => {
556
+ // The cases not sent after the provider refused every one are said once, below.
557
+ if (r.notRun?.skipped) return
558
+ ctx.output.note(` ${r.passed ? '✓' : r.notRun ? '–' : '✕'} ${r.agent} · ${r.name}${r.passed ? '' : r.notRun ? ` — couldn’t run: ${r.notRun.what}` : ` — ${r.reason}`}${r.passed && r.retried ? ' (run twice: the provider refused the first try for its limits)' : ''}${r.waited?.length ? ` (${waitedWords(r.waited)})` : ''}`)
559
+ },
560
+ })
561
+ recordRun(project, results)
562
+ const bad = results.filter((r) => !r.passed && !r.notRun)
563
+ // Each failing case's conversation, last turn's trace and reply, read before the rollback takes the conversation.
564
+ const evidence = await failingCaseEvidence(project, results)
565
+ const rollsBack = baselineReady && !!testData && !testData.kept
566
+ for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e, { rolledBack: rollsBack })}`)
567
+ const notRun = notRunGroups(results)
568
+ for (const g of notRun) {
569
+ if (g.skipped) ctx.output.note(` – ${g.skipped} more case${g.skipped === 1 ? '' : 's'} not sent: ${g.what}`)
570
+ ctx.output.note(` couldn’t run ${g.cases.length} of ${results.length} cases: ${withFix(g.what, g.fix)}`)
571
+ }
572
+ const passedCount = results.filter((r) => r.passed).length
573
+ const note = [bad.length ? `${bad.length} of ${results.length} cases failed` : '', notRun.length ? `${notRunWords(notRun)} (${passedCount} passed)` : ''].filter(Boolean).join('; ')
574
+ record({
575
+ step,
576
+ status: bad.length || notRun.length ? 'failed' : 'passed',
577
+ command: 'frontera local test',
578
+ ms: Date.now() - started,
579
+ ...(note ? { note } : {}),
580
+ ...(bad.length ? { failures: bad.map((r) => ({ name: `${r.agent} · ${r.name}`, reason: r.reason, ...(evidence.get(results.indexOf(r)) ?? {}) })) } : {}),
581
+ ...(notRun.length ? { couldNotRun: notRun.map((g) => ({ what: `couldn’t run: ${g.what}`, fix: g.fix, cases: g.cases, ...(g.skipped ? { notSent: g.skipped } : {}) })) } : {}),
582
+ })
583
+ continue
584
+ }
585
+ if (step === 'workflows') {
586
+ const files = findWorkflowTestFiles(root)
587
+ if (!files.some((f) => f.cases.length || f.problem)) {
588
+ record({ step, status: 'skipped', note: 'no workflow tests (workflows/tests/<slug>.yaml)', ms: 0 })
589
+ continue
590
+ }
591
+ say(step, 'frontera local test (workflows)')
592
+ const started = Date.now()
593
+ const orgId = state.credentials.organizationId
594
+ const results = await runWorkflowTests({ serviceUrl, creds: state.credentials, files, fileState: (slug) => workflowFileState(project, orgId, slug), onResult: (r) => ctx.output.note(` ${r.passed ? '✓' : '✕'} workflow ${r.workflow} · ${r.name}${r.passed ? '' : ` — ${r.reason}`}`) })
595
+ recordWorkflowTestRun(project, results)
596
+ const bad = results.filter((r) => !r.passed)
597
+ record({ step, status: bad.length ? 'failed' : 'passed', command: 'frontera local test', ms: Date.now() - started, ...(bad.length ? { note: `${bad.length} of ${results.length} cases failed`, failures: bad.map((r) => ({ name: `${r.workflow} · ${r.name}`, reason: r.reason })) } : {}) })
598
+ }
599
+ }
600
+
601
+ // What the tests wrote, rolled back: the database as the apply left it.
602
+ const testsRan = steps.some((st) => (st.step === 'agents' || st.step === 'workflows') && st.status !== 'skipped')
603
+ if (baselineReady && testData && testsRan) {
604
+ const state = readState(project)
605
+ if (state?.credentials && running(project)) {
606
+ const restored = await restoreBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
607
+ // Traces live outside the platform database: a failing case's trace outlives the rollback, its conversation does not.
608
+ const traced = steps.some((st) => st.failures?.some((f) => f.traceId))
609
+ testData = { ...testData, rolledBack: restored.ok, ms: restored.ms, note: restored.ok ? `the tests’ writes were rolled back: ${restored.note}${traced ? '; the failing cases’ conversations went with them, their traces stay (trace_get, Studio’s Traces)' : ''}` : `the tests’ writes were not rolled back: ${restored.note}` }
610
+ ctx.output.note(`── test data: ${testData.note}${restored.ok ? ' (`--keep-state` keeps them)' : ''}`)
611
+ // The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
612
+ const ingest = restored.ok ? localIngestApi(`http://127.0.0.1:${state.ports.service}`, state.credentials) : null
613
+ if (ingest) sayKnowledge(await settleKnowledge(ingest, state.credentials.workspaceId, { afterRestore: true }))
614
+ // The restore can leave a secret listed without its value: what the runtime needs now, said after it.
615
+ if (restored.ok) {
616
+ const lost = projectSecretLines(root, await localSecretPresence(`http://127.0.0.1:${state.ports.service}`, state.credentials))
617
+ .map((l) => l.trim().replace(/^warning:\s*/, ''))
618
+ .filter((l) => /^Needs a value for /.test(l) && !secretNotes.includes(l))
619
+ for (const line of lost) ctx.output.note(` warning: after the rollback: ${line}`)
620
+ secretNotes.push(...lost)
621
+ }
622
+ }
623
+ } else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); the next `frontera local check` rolls them back')
624
+ const testDataField = testData && testsRan ? { testData } : {}
625
+
626
+ if (flagBool(ctx, 'stop')) {
627
+ ctx.output.note('── frontera local stop')
628
+ await runIn(root, self(['local', 'stop']))
629
+ }
630
+ // Every step that failed, the first (`failedAt`) first.
631
+ const failures = steps.filter((st) => st.status === 'failed')
632
+ const failure = failures[0]
633
+ // Every inherited failure, by step: said in the result whether the gate passed or not.
634
+ const inherited = steps.flatMap((st) => (st.inherited ?? []).map((what) => ({ step: st.step, what })))
635
+ // Warnings inherited from the workspace: said in the result, never a failure.
636
+ const inheritedWarnings = steps.flatMap((st) => (st.inheritedWarnings ?? []).map((what) => ({ step: st.step, what })))
637
+ const warningsField = inheritedWarnings.length ? { inheritedWarnings } : {}
638
+ if (failure) {
639
+ const others = failures.slice(1).map((f) => f.step)
640
+ const message = `the check failed at ${failure.step}${failure.note ? `: ${failure.note}` : ''}${others.length ? `; also failed: ${others.join(', ')}` : ''}`
641
+ // Cases that couldn't run name their own fix (the provider account, the runtime), said with the hint.
642
+ const notRunFixes = [...new Set(failures.flatMap((f) => (f.couldNotRun ?? []).map((g) => g.fix)))]
643
+ const hint = [failure.step === 'apply' ? 'the output above names each item that did not apply; `frontera local status` lists them' : failure.couldNotRun && !failure.failures ? null : 'the output above names the cause of each', ...notRunFixes].filter(Boolean).join('; ')
644
+ const details = { passed: false, failedAt: failure.step, failures: failures.map((f) => ({ step: f.step, ...(f.note ? { note: f.note } : {}), ...(f.failures ? { failures: f.failures } : {}), ...(f.couldNotRun ? { couldNotRun: f.couldNotRun } : {}) })), steps, ...(inherited.length ? { inherited } : {}), ...warningsField, ...(secretNotes.length ? { secrets: secretNotes } : {}), ...(sdkNotes.length ? { sdk: sdkNotes } : {}), ...(knowledgeNotes.length ? { knowledge: knowledgeNotes } : {}), ...testDataField }
645
+ // `--json`: the check's result IS the data, failed or not, so it goes to stdout (exit 1 says it failed).
646
+ if (flagBool(ctx, 'json')) {
647
+ ctx.output.note(`error [LOCAL_CHECK_FAILED]: ${message}`)
648
+ return { data: { ...details, error: { code: 'LOCAL_CHECK_FAILED', message, hint } }, text: '', exitCode: EXIT.FAILURE }
649
+ }
650
+ throw new CliError(message, { code: 'LOCAL_CHECK_FAILED', hint, details })
651
+ }
652
+ const ran = steps.filter((s) => s.status === 'passed').map((s) => s.step)
653
+ const skipped = steps.filter((s) => s.status === 'skipped' && s.step !== 'start')
654
+ return {
655
+ data: { passed: true, steps, ...(inherited.length ? { inherited } : {}), ...warningsField, ...(secretNotes.length ? { secrets: secretNotes } : {}), ...(sdkNotes.length ? { sdk: sdkNotes } : {}), ...(knowledgeNotes.length ? { knowledge: knowledgeNotes } : {}), ...testDataField },
656
+ text: [
657
+ `Check passed: ${ran.join(', ')}${inherited.length ? `, with ${inheritedWords(inherited.length)}` : ''}.`,
658
+ ...(testData && testsRan ? [` test data: ${testData.note}`] : []),
659
+ ...(inherited.length ? [` inherited (report each with its fix):`, ...inherited.map((i) => ` ${i.step}: ${i.what}`)] : []),
660
+ ...(inheritedWarnings.length ? [` warnings inherited from the workspace (report each; its owner fixes it there):`, ...inheritedWarnings.map((i) => ` ${i.step}: ${i.what}`)] : []),
661
+ ...skipped.map((s) => ` skipped ${s.step}: ${s.note}`),
662
+ ].join('\n'),
663
+ }
664
+ }
665
+
26
666
  export const localCheck: Command = {
27
667
  meta: {
28
668
  noun: 'local',
29
669
  verb: 'check',
30
670
  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'],
671
+ flags: { stop: 'boolean', skip: 'string', 'fail-fast': 'boolean', strict: 'boolean', 'keep-state': 'boolean', concurrency: 'string' },
672
+ summary: 'The whole gate with one exit code (start, apply, typecheck, unit tests, App checks, Agent and workflow tests): anything changed or added fails it, and failures only in items unchanged since the pull are listed as inherited and pass unless --strict',
673
+ examples: ['frontera local check', 'frontera local check --json', 'frontera local check --strict', 'frontera local check --skip typecheck,apps', 'frontera local check --fail-fast', 'frontera local check --keep-state', 'frontera local check --concurrency 1', 'frontera local check --stop'],
34
674
  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
675
  },
676
+ run: check,
64
677
  }