@frontera-sdk/cli 1.52.39 → 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.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +24 -21
|
@@ -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 {
|
|
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 {
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
21
|
-
child.on('error', () => resolve(
|
|
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: '
|
|
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
|
}
|