@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.
- 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 +49 -5
- 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/draft-changes.ts +1 -1
- 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 +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- 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 +876 -89
- 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 +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- 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/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- 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 +113 -2
- package/src/kit.ts +1 -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 +212 -22
- 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 +181 -8
- 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 +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- 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-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- 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 +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- 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 +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- 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-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- 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/scopes.ts +1 -1
- 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 +17 -14
|
@@ -1,26 +1,56 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
|
|
4
5
|
import { AppsApi } from '../../api/apps-api'
|
|
5
|
-
import {
|
|
6
|
+
import { isKeyToken } from '../../auth-verify'
|
|
7
|
+
import { CliError, UsageError } from '../../errors'
|
|
6
8
|
import { resolveManifest } from '../../manifest'
|
|
7
9
|
import { collectSourceFiles, formatBytes } from '../../packaging'
|
|
8
10
|
import { packDirectory } from '../../pack'
|
|
9
|
-
import {
|
|
11
|
+
import { collectAppContract } from '../../app-contract'
|
|
12
|
+
import { writeAppId, writeState } from '../../project'
|
|
10
13
|
import { collectBuildProvenance } from '../../provenance'
|
|
11
14
|
import { validateStaticAppProject } from '../../static-app-validation'
|
|
12
|
-
import { describeRenderEvidence, readRenderEvidence, readRenderThumbnail } from '../../render-evidence'
|
|
15
|
+
import { describeRenderEvidence, readRenderEvidence, readRenderThumbnail, type RenderEvidence } from '../../render-evidence'
|
|
13
16
|
import { createTarGz } from '../../tar'
|
|
17
|
+
import { keepBuildWrittenFiles } from '../../local/local-app-pulled'
|
|
18
|
+
import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
|
|
14
19
|
import { requireProjectFrom } from './shared'
|
|
20
|
+
import { canPrompt } from '../../prompt'
|
|
15
21
|
import { flagBool, flagString, type Command } from '../types'
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
|
-
*
|
|
24
|
+
* A release label is a version, as `package.json` gives one: what the
|
|
25
|
+
* platform accepts (`RELEASE_LABEL` in the service's app-deployment-id.ts,
|
|
26
|
+
* which refuses anything else with a 400 after the build and upload).
|
|
27
|
+
*/
|
|
28
|
+
export const RELEASE_LABEL = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
|
29
|
+
|
|
30
|
+
/** A label the platform accepts, made from one it would refuse: `e2e-1` → `0.1.0-e2e-1`, keeping the App's own version when it has one. */
|
|
31
|
+
export function suggestedLabel(label: string, packageVersion?: string | null): string {
|
|
32
|
+
const base = packageVersion && /^\d+\.\d+\.\d+$/.test(packageVersion) ? packageVersion : '0.1.0'
|
|
33
|
+
const tail = label.replace(/^v(?=\d)/i, '').replace(/[^0-9A-Za-z.-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '')
|
|
34
|
+
if (RELEASE_LABEL.test(tail)) return tail
|
|
35
|
+
const partial = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(tail)
|
|
36
|
+
if (partial) return `${partial[1]}.${partial[2] ?? 0}.${partial[3] ?? 0}`
|
|
37
|
+
return tail ? `${base}-${tail}` : base
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Refuse a label the platform would refuse, before anything is built or uploaded. */
|
|
41
|
+
export function assertReleaseLabel(label: string | null, packageVersion?: string | null): void {
|
|
42
|
+
if (label === null || RELEASE_LABEL.test(label)) return
|
|
43
|
+
throw new UsageError(
|
|
44
|
+
`release label "${label}" is not a version: labels look like 1.4.0, or 1.4.0-e2e.1 with a suffix`,
|
|
45
|
+
`pass --label ${suggestedLabel(label, packageVersion)}, or deploy without --label (every deployment has its own id anyway)`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Publish, and say the useful thing when the release label is taken.
|
|
19
51
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* DRAFT conflict and does nothing here. The fix is to bump a number, and this
|
|
23
|
-
* is the only layer that knows that.
|
|
52
|
+
* Every deployment gets its own id, so a deploy never collides with another;
|
|
53
|
+
* only an optional release label can, and the fix is a new label.
|
|
24
54
|
*/
|
|
25
55
|
async function publish(
|
|
26
56
|
client: AppsApi,
|
|
@@ -31,16 +61,109 @@ async function publish(
|
|
|
31
61
|
return await client.publish(appId, args)
|
|
32
62
|
} catch (err) {
|
|
33
63
|
const code = (err as { code?: string }).code
|
|
34
|
-
if (code === 'CONFLICT' || code === 'DRAFT_CONFLICT') {
|
|
35
|
-
throw new CliError(`
|
|
64
|
+
if ((code === 'CONFLICT' || code === 'DRAFT_CONFLICT') && args.label) {
|
|
65
|
+
throw new CliError(`release label ${args.label} is already used by another deployment of this app`, {
|
|
36
66
|
code: 'CONFLICT',
|
|
37
|
-
hint: '
|
|
67
|
+
hint: 'labels are unique per App — pass a new --label, or deploy without one',
|
|
38
68
|
})
|
|
39
69
|
}
|
|
40
70
|
throw err
|
|
41
71
|
}
|
|
42
72
|
}
|
|
43
73
|
|
|
74
|
+
/** The App's `package.json` version, when it has one. */
|
|
75
|
+
function packageVersionAt(root: string): string | null {
|
|
76
|
+
try {
|
|
77
|
+
const v = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: unknown }).version
|
|
78
|
+
return typeof v === 'string' ? v : null
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The App's own check (`bun run check`: its typecheck and tests, as the
|
|
86
|
+
* scaffold defines it), when it has one. Its result travels with the
|
|
87
|
+
* deployment, and a required build check holds production on it.
|
|
88
|
+
*/
|
|
89
|
+
function runBuildCheck(root: string, skip: boolean): { status: 'passed' | 'failed' | 'skipped'; detail: string; output?: string } {
|
|
90
|
+
if (skip) return { status: 'skipped', detail: 'Deployed with --skip-build-check' }
|
|
91
|
+
let scripts: Record<string, string> = {}
|
|
92
|
+
try {
|
|
93
|
+
scripts = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}
|
|
94
|
+
} catch {
|
|
95
|
+
scripts = {}
|
|
96
|
+
}
|
|
97
|
+
if (!scripts.check) return { status: 'passed', detail: 'Validated as a static export with a current build (no check script)' }
|
|
98
|
+
const run = spawnSync('bun', ['run', 'check'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10 * 60_000 })
|
|
99
|
+
if (run.status === 0) return { status: 'passed', detail: 'bun run check passed; validated as a static export' }
|
|
100
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`.trim().split('\n').slice(-25).join('\n')
|
|
101
|
+
return { status: 'failed', detail: 'bun run check failed', output }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run the App's build or its check in its folder as `local check` and `app
|
|
106
|
+
* dev` run them, so the three agree on whether it builds:
|
|
107
|
+
*
|
|
108
|
+
* - A pulled App installed as its bun.lock records, whose tsconfig.json still
|
|
109
|
+
* sends the SDK to the older copy under src/frontera/, runs with those
|
|
110
|
+
* `paths` set aside (`setAsideCopyPaths`) and put back byte for byte with
|
|
111
|
+
* their time, so it builds against the release it was written for.
|
|
112
|
+
* - What the run writes into the folder's top (`tsconfig.tsbuildinfo`,
|
|
113
|
+
* `next-env.d.ts`, `next build`'s `tsconfig.json`) goes back as it was, and
|
|
114
|
+
* one it created is removed (`keepBuildWrittenFiles`): a deploy is not a
|
|
115
|
+
* change to the tree.
|
|
116
|
+
*
|
|
117
|
+
* Both put back with the times the files had, so a render before the deploy
|
|
118
|
+
* is still of this source (`render-evidence.ts`), and the build's are back
|
|
119
|
+
* before the source is packaged.
|
|
120
|
+
*/
|
|
121
|
+
function asLocalCheckRuns<T>(root: string, note: (line: string) => void, run: () => T): T {
|
|
122
|
+
const putBack = keepBuildWrittenFiles(root)
|
|
123
|
+
const aside = setAsideCopyPaths(root)
|
|
124
|
+
if (aside) note(` ${aside.line}`)
|
|
125
|
+
try {
|
|
126
|
+
return run()
|
|
127
|
+
} finally {
|
|
128
|
+
if (aside) note(` ${aside.restore()}`)
|
|
129
|
+
const restored = putBack()
|
|
130
|
+
if (restored.length) note(` it wrote ${restored.join(', ')}; put back as ${restored.length === 1 ? 'it was' : 'they were'} before it`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `bun run build`, before anything is validated, for `--build`: what
|
|
136
|
+
* `app_deploy` runs so a deploy never ships the bundle from before the last
|
|
137
|
+
* edit. Its failure stops the deploy with the build's own last lines.
|
|
138
|
+
*/
|
|
139
|
+
function runBuild(root: string): void {
|
|
140
|
+
const run = spawnSync('bun', ['run', 'build'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10 * 60_000 })
|
|
141
|
+
if (run.status === 0) return
|
|
142
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`.trim().split('\n').slice(-30).join('\n')
|
|
143
|
+
throw new CliError('bun run build failed; nothing was deployed', { code: 'VALIDATION_ERROR', hint: output || 'run `bun run build` to see why' })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether the render this deployment carries was clean, in the terms the
|
|
148
|
+
* platform's checks use (`app-deployment-checks.ts`): a fresh render, no
|
|
149
|
+
* console errors (the Fresh render check), no serious accessibility findings
|
|
150
|
+
* (the Accessibility check), and no failed requests, which that check
|
|
151
|
+
* reports. A preview deploys either way; this says what a person promoting
|
|
152
|
+
* it should know.
|
|
153
|
+
*/
|
|
154
|
+
export function renderVerdict(evidence: RenderEvidence): { ok: boolean; problems: string[] } {
|
|
155
|
+
const problems: string[] = []
|
|
156
|
+
if (evidence.notThisApp) problems.push(`the last render (${evidence.notThisApp.url}) is not attached: ${evidence.notThisApp.why}. Render the App (app_render with no url) and deploy again`)
|
|
157
|
+
else if (evidence.reason === 'none') problems.push('nobody has rendered this build: app_render (or a browser) has not opened it')
|
|
158
|
+
if (evidence.reason === 'stale') problems.push('the source changed after the last render, so nobody has seen this version')
|
|
159
|
+
if (evidence.rendered) {
|
|
160
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
161
|
+
if (evidence.consoleErrors) problems.push(`the last render had ${plural(evidence.consoleErrors, 'console error')}`)
|
|
162
|
+
if (evidence.failedRequests) problems.push(`the last render had ${plural(evidence.failedRequests, 'failed request')}`)
|
|
163
|
+
if (evidence.a11y?.violations) problems.push(`the accessibility scan found ${plural(evidence.a11y.violations, 'serious violation')}`)
|
|
164
|
+
}
|
|
165
|
+
return { ok: problems.length === 0, problems }
|
|
166
|
+
}
|
|
44
167
|
|
|
45
168
|
/**
|
|
46
169
|
* Newest modification time across a set of project-relative files.
|
|
@@ -73,23 +196,50 @@ export const appDeploy: Command = {
|
|
|
73
196
|
noun: 'app',
|
|
74
197
|
verb: 'deploy',
|
|
75
198
|
args: [],
|
|
76
|
-
flags: { version: 'string', 'no-promote': 'boolean', 'skip-build-check': 'boolean' },
|
|
77
|
-
summary: '
|
|
78
|
-
|
|
199
|
+
flags: { label: 'string', version: 'string', prod: 'boolean', preview: 'boolean', 'no-promote': 'boolean', 'skip-build-check': 'boolean', build: 'boolean' },
|
|
200
|
+
summary: 'Upload the build as a preview deployment; --build to build first, --prod to go live',
|
|
201
|
+
details: [
|
|
202
|
+
'A deploy is a preview unless --prod is given. A pipeline (a key, no terminal) must say which it wants: --prod or --preview. Without either it exits with an error, so a pipeline written when a deploy went live stops instead of silently making previews.',
|
|
203
|
+
],
|
|
204
|
+
examples: ['frontera app deploy', 'frontera app deploy --build', 'frontera app deploy --label 1.4.0', 'frontera app deploy --prod', 'frontera app deploy --preview', 'frontera app deploy --prod --no-promote'],
|
|
79
205
|
needsProject: true,
|
|
80
206
|
},
|
|
81
207
|
|
|
82
208
|
async run(ctx) {
|
|
83
209
|
const project = requireProjectFrom(ctx)
|
|
84
|
-
|
|
210
|
+
// `--version` is the label's earlier name; a deployment's id is the platform's.
|
|
211
|
+
const label = flagString(ctx, 'label') ?? flagString(ctx, 'version') ?? null
|
|
212
|
+
if (flagString(ctx, 'version') && !flagString(ctx, 'label')) ctx.output.note(' --version is now --label: a release label, unique per App')
|
|
213
|
+
assertReleaseLabel(label, packageVersionAt(project.root))
|
|
214
|
+
const prod = flagBool(ctx, 'prod')
|
|
215
|
+
const preview = flagBool(ctx, 'preview')
|
|
216
|
+
if (prod && preview) throw new UsageError('--prod and --preview ask for opposite things', 'pass --prod to go live, or --preview for a preview')
|
|
217
|
+
const stage = prod && flagBool(ctx, 'no-promote')
|
|
218
|
+
if (!prod && !preview && !flagBool(ctx, 'no-promote')) {
|
|
219
|
+
// A pipeline written when a deploy went live would now make previews and exit 0, and nobody reads
|
|
220
|
+
// the note in its log. A key with no terminal is a pipeline: it says which it wants, for a release
|
|
221
|
+
// or two after deploys became previews, before anything is built or uploaded.
|
|
222
|
+
if (isKeyToken(ctx.token) && !(ctx.interactive ?? canPrompt())) {
|
|
223
|
+
throw new UsageError(
|
|
224
|
+
'frontera app deploy no longer goes live by default: in a pipeline, say whether this deployment goes to production',
|
|
225
|
+
'add --prod to go live once the required checks pass (what this command did before), or --preview to make a preview',
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
// One release of notice for scripts that relied on deploy going live.
|
|
229
|
+
ctx.output.note(' deploy now creates a preview and never changes what users see; pass --prod to go live')
|
|
230
|
+
}
|
|
85
231
|
const outputDirectory = project.outputDirectory ?? 'dist'
|
|
86
232
|
const distDir = join(project.root, outputDirectory)
|
|
233
|
+
if (flagBool(ctx, 'build')) {
|
|
234
|
+
ctx.output.note(' building: bun run build')
|
|
235
|
+
asLocalCheckRuns(project.root, (line) => ctx.output.note(line), () => runBuild(project.root))
|
|
236
|
+
}
|
|
87
237
|
validateStaticAppProject(project.root, project.routing ?? 'spa')
|
|
88
238
|
|
|
89
239
|
if (!existsSync(join(distDir, 'index.html'))) {
|
|
90
240
|
throw new CliError(`${outputDirectory}/index.html not found`, {
|
|
91
241
|
code: 'VALIDATION_ERROR',
|
|
92
|
-
hint: 'run your build first: bun run build',
|
|
242
|
+
hint: 'run your build first: bun run build, or deploy with --build',
|
|
93
243
|
})
|
|
94
244
|
}
|
|
95
245
|
|
|
@@ -125,7 +275,7 @@ export const appDeploy: Command = {
|
|
|
125
275
|
{
|
|
126
276
|
code: 'VALIDATION_ERROR',
|
|
127
277
|
hint:
|
|
128
|
-
'run your build first: bun run build. '
|
|
278
|
+
'run your build first: bun run build (or deploy with --build). '
|
|
129
279
|
+ 'Deploying now would publish the previous bundle, and nothing would say so. '
|
|
130
280
|
+ 'Pass --skip-build-check if the output really is current.',
|
|
131
281
|
},
|
|
@@ -153,71 +303,104 @@ export const appDeploy: Command = {
|
|
|
153
303
|
// Reported whichever way it comes out: "rendered" is worth saying too, and
|
|
154
304
|
// a line that only ever appears when something is wrong is a line people
|
|
155
305
|
// learn to skim past.
|
|
156
|
-
|
|
306
|
+
// Only a render of THIS App counts: its dev server or one of its previews, not whatever was opened last.
|
|
307
|
+
const renderEvidence = readRenderEvidence(project.root, project.slug)
|
|
157
308
|
ctx.output.note(describeRenderEvidence(renderEvidence))
|
|
309
|
+
const evidence = renderVerdict(renderEvidence)
|
|
310
|
+
if (renderEvidence.rendered && !evidence.ok) ctx.output.note(` render evidence: ${evidence.problems.join('; ')}`)
|
|
158
311
|
|
|
159
312
|
// The picture from that same render. Uploaded whenever one exists — the
|
|
160
313
|
// gallery has shown every app as an identical tinted tile, and a screenshot
|
|
161
314
|
// of a slightly older state of the app says more than its first letter.
|
|
162
|
-
|
|
315
|
+
// Not a picture of another page: a render that is not this App's is not its thumbnail either.
|
|
316
|
+
const thumbnail = renderEvidence.notThisApp ? null : readRenderThumbnail(project.root)
|
|
317
|
+
|
|
318
|
+
const skipCheck = flagBool(ctx, 'skip-build-check')
|
|
319
|
+
const build = skipCheck ? runBuildCheck(project.root, true) : asLocalCheckRuns(project.root, (line) => ctx.output.note(line), () => runBuildCheck(project.root, false))
|
|
320
|
+
if (build.status === 'failed') {
|
|
321
|
+
// Going live on a failing check is refused here; a preview still uploads, marked.
|
|
322
|
+
if (prod && !stage) {
|
|
323
|
+
throw new CliError('bun run check failed: production needs the build check to pass', { code: 'VALIDATION_ERROR', hint: `${build.output ?? ''}\nfix it, or deploy a preview first: frontera app deploy` })
|
|
324
|
+
}
|
|
325
|
+
ctx.output.note(` bun run check failed; deploying a preview marked as failing it\n${build.output ?? ''}`)
|
|
326
|
+
}
|
|
327
|
+
const contract = collectAppContract(project.root)
|
|
328
|
+
const provenance = { ...collectBuildProvenance(project.root), checks: { build: { status: build.status, detail: build.detail } }, contract }
|
|
163
329
|
|
|
164
|
-
const promote = !flagBool(ctx, 'no-promote')
|
|
165
330
|
const res = await publish(client, app.id, {
|
|
166
|
-
|
|
331
|
+
label,
|
|
332
|
+
prod,
|
|
333
|
+
stage,
|
|
167
334
|
parentVersion: project.parentVersion,
|
|
168
335
|
dist,
|
|
169
336
|
source,
|
|
170
337
|
manifest,
|
|
171
|
-
promote,
|
|
172
338
|
renderEvidence,
|
|
173
|
-
provenance
|
|
339
|
+
provenance,
|
|
174
340
|
thumbnail,
|
|
175
341
|
})
|
|
342
|
+
const deployment = res.deployment ?? res.version
|
|
176
343
|
|
|
177
|
-
|
|
344
|
+
// On the runtime again: whatever a database restore had taken away is back.
|
|
345
|
+
writeState(project.root, { appId: app.id, parentVersion: deployment, restoredAway: undefined })
|
|
178
346
|
// Local environment binding: source remains portable and package.json
|
|
179
347
|
// remains ordinary package metadata.
|
|
180
348
|
writeAppId(project.root, app.id)
|
|
181
349
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (res.
|
|
189
|
-
ctx.output.note(
|
|
190
|
-
res.promoted
|
|
191
|
-
? ` live at ${res.liveUrl ?? ''} · this exact build: ${res.previewUrl}`
|
|
192
|
-
: ` open it: ${res.previewUrl}`,
|
|
193
|
-
)
|
|
194
|
-
}
|
|
350
|
+
// Where a person opens what was just made: its own link, and the moving
|
|
351
|
+
// links of its branch and session. A preview was once described as one
|
|
352
|
+
// nobody could see, and the turn ended asking to make it live for everyone.
|
|
353
|
+
if (res.previewUrl) ctx.output.note(` this deployment: ${res.previewUrl}`)
|
|
354
|
+
if (res.branchUrl) ctx.output.note(` branch ${res.branch}: ${res.branchUrl}`)
|
|
355
|
+
if (res.sessionUrl) ctx.output.note(` this session: ${res.sessionUrl}`)
|
|
356
|
+
if (res.promoted && res.liveUrl) ctx.output.note(` live at ${res.liveUrl}`)
|
|
195
357
|
if (res.addressUrl) ctx.output.note(` address: ${res.addressUrl}`)
|
|
196
358
|
|
|
359
|
+
const name = `${app.slug}@${res.label ?? deployment}`
|
|
360
|
+
const data = {
|
|
361
|
+
app: app.slug,
|
|
362
|
+
appId: app.id,
|
|
363
|
+
deployment,
|
|
364
|
+
// The same id, under its earlier name, for scripts that read `version`.
|
|
365
|
+
version: deployment,
|
|
366
|
+
label: res.label ?? null,
|
|
367
|
+
promoted: res.promoted,
|
|
368
|
+
staged: res.staged ?? false,
|
|
369
|
+
...(res.promoteRefused ? { promoteRefused: res.promoteRefused } : {}),
|
|
370
|
+
...(res.previewUrl ? { previewUrl: res.previewUrl } : {}),
|
|
371
|
+
...(res.branchUrl ? { branchUrl: res.branchUrl } : {}),
|
|
372
|
+
...(res.sessionUrl ? { sessionUrl: res.sessionUrl } : {}),
|
|
373
|
+
...(res.liveUrl && res.promoted ? { liveUrl: res.liveUrl } : {}),
|
|
374
|
+
...(res.addressUrl ? { addressUrl: res.addressUrl } : {}),
|
|
375
|
+
// In `data` as well as in the note: an agent reads the JSON, and this
|
|
376
|
+
// is the fact it most needs to carry into what it tells the user.
|
|
377
|
+
rendered: renderEvidence.rendered,
|
|
378
|
+
renderEvidence,
|
|
379
|
+
// Whether that render was clean, and if not, what it found.
|
|
380
|
+
evidence,
|
|
381
|
+
build: build.status,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Asked for production and refused: the deployment exists as a preview; say why it is not live.
|
|
385
|
+
if (prod && !stage && !res.promoted) {
|
|
386
|
+
const refused = res.promoteRefused
|
|
387
|
+
const blocking = (refused?.checks ?? []).filter((c) => c.required && c.status !== 'passed')
|
|
388
|
+
throw new CliError(`deployed ${name} as a preview, but it did not go live: ${refused?.message ?? 'production was not changed'}`, {
|
|
389
|
+
code: refused?.code === 'FORBIDDEN' ? 'FORBIDDEN' : 'CONFLICT',
|
|
390
|
+
hint: blocking.length
|
|
391
|
+
? `required checks: ${blocking.map((c) => `${c.label} — ${c.detail}`).join('; ')}. Fix them and deploy again, or ask someone who may promote to force it.`
|
|
392
|
+
: refused?.code === 'PRODUCTION_PINNED'
|
|
393
|
+
? 'production is pinned since a rollback: frontera app rollback --undo, or unpin it on the App\'s Deployments'
|
|
394
|
+
: `open it: ${res.previewUrl ?? 'frontera app deployments'}`,
|
|
395
|
+
details: data,
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
197
399
|
return {
|
|
198
|
-
data
|
|
199
|
-
app: app.slug,
|
|
200
|
-
appId: app.id,
|
|
201
|
-
version: res.version,
|
|
202
|
-
promoted: res.promoted,
|
|
203
|
-
...(res.previewUrl ? { previewUrl: res.previewUrl } : {}),
|
|
204
|
-
...(res.liveUrl && res.promoted ? { liveUrl: res.liveUrl } : {}),
|
|
205
|
-
...(res.addressUrl ? { addressUrl: res.addressUrl } : {}),
|
|
206
|
-
// In `data` as well as in the note: an agent reads the JSON, and this
|
|
207
|
-
// is the fact it most needs to carry into what it tells the user.
|
|
208
|
-
rendered: renderEvidence.rendered,
|
|
209
|
-
renderEvidence,
|
|
210
|
-
},
|
|
211
|
-
// The slug as the PLATFORM knows it: `project.slug` is a bootstrap name
|
|
212
|
-
// and goes stale the moment the app is renamed, so printing it would
|
|
213
|
-
// report a deploy to an app that no longer exists.
|
|
400
|
+
data,
|
|
214
401
|
text:
|
|
215
|
-
`
|
|
216
|
-
(
|
|
217
|
-
(renderEvidence.rendered ? '' : ' — nobody has looked at this version') +
|
|
218
|
-
// Not "use `frontera app promote`". That read as "invisible until you
|
|
219
|
-
// promote", which is false and is what sent an agent asking to publish
|
|
220
|
-
// an unreviewed build to everyone.
|
|
402
|
+
(res.promoted ? `Deployed ${name} and it is live` : stage ? `Staged ${name} for production: nothing serves it until it is promoted` : `Deployed ${name} as a preview`) +
|
|
403
|
+
(renderEvidence.rendered ? (evidence.ok ? '' : ` — its render was not clean: ${evidence.problems.join('; ')}`) : ' — nobody has looked at this deployment') +
|
|
221
404
|
(res.previewUrl ? `\n\nOpen it: ${res.previewUrl}` : '') +
|
|
222
405
|
(res.addressUrl ? `\nAddress: ${res.addressUrl}` : ''),
|
|
223
406
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { AppsApi, type DeploymentCheck, type DeploymentLedger, type DeploymentRow } from '../../api/apps-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import { projectApp, requireProjectFrom } from './shared'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
|
|
6
|
+
/** Who produced a deployment, in a few words. */
|
|
7
|
+
function producerWords(d: DeploymentRow): string {
|
|
8
|
+
const who = d.producer.name ?? (d.producer.kind === 'person' ? 'someone' : d.producer.kind === 'agent-key' ? 'an Agent key' : 'a workspace key')
|
|
9
|
+
return d.session ? `${who} (${d.session.harness ?? 'Forge'} session)` : who
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What the render evidence says, short: rendered, stale, or not looked at. */
|
|
13
|
+
function renderWords(d: DeploymentRow): string {
|
|
14
|
+
const e = d.renderEvidence
|
|
15
|
+
if (e.rendered === true) return Number(e.consoleErrors ?? 0) > 0 ? `rendered, ${e.consoleErrors} errors` : 'rendered'
|
|
16
|
+
return e.reason === 'stale' ? 'render stale' : 'not rendered'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How many of the newest deployments the list asks checks for: one request each. */
|
|
20
|
+
const CHECKED = 10
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The checks of one deployment, short: "all passed", or what did not pass,
|
|
24
|
+
* the workspace's required checks first. Without checks, the render evidence.
|
|
25
|
+
*/
|
|
26
|
+
function checkWords(d: DeploymentRow, checks: DeploymentCheck[] | undefined): string {
|
|
27
|
+
if (!checks) return renderWords(d)
|
|
28
|
+
const open = checks.filter((c) => c.status !== 'passed').sort((a, b) => Number(b.required) - Number(a.required))
|
|
29
|
+
if (!open.length) return checks.length ? 'all passed' : '-'
|
|
30
|
+
return open.map((c) => `${c.id} ${c.status}${c.required ? ' (required)' : ''}`).join(', ')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The ledger for a person: production and its pin, the previews, then every deployment. */
|
|
34
|
+
export function renderLedger(ledger: DeploymentLedger, checks: Record<string, DeploymentCheck[]> = {}): string {
|
|
35
|
+
const pin = ledger.production.pinned
|
|
36
|
+
const lines = [
|
|
37
|
+
`Production: ${ledger.production.label ?? ledger.production.deployment ?? 'nothing yet'}${pin ? ` — pinned since a rollback${pin.reason ? `: ${pin.reason}` : ''} (frontera app rollback --undo, or unpin)` : ''}`,
|
|
38
|
+
]
|
|
39
|
+
if (ledger.previews.length) lines.push(`Previews: ${ledger.previews.map((p) => `${p.kind} ${p.name} → ${p.label ?? p.deployment ?? '-'}`).join(' · ')}`)
|
|
40
|
+
lines.push('')
|
|
41
|
+
if (!ledger.deployments.length) return `${lines.join('\n')}No deployments yet. frontera app deploy uploads a preview.`
|
|
42
|
+
lines.push(
|
|
43
|
+
table(
|
|
44
|
+
['DEPLOYMENT', 'LABEL', 'SERVES', 'BY', 'BRANCH', 'COMMIT', 'CHECKS', 'CREATED'],
|
|
45
|
+
ledger.deployments.map((d) => [
|
|
46
|
+
d.id,
|
|
47
|
+
d.label ?? '-',
|
|
48
|
+
d.serves.length ? d.serves.join(', ') : d.staged ? 'staged' : d.pruned ? 'pruned' : '-',
|
|
49
|
+
producerWords(d),
|
|
50
|
+
d.branch ?? '-',
|
|
51
|
+
d.commit ? `${d.commit.slice(0, 8)}${d.dirty ? '+dirty' : ''}` : '-',
|
|
52
|
+
checkWords(d, checks[d.id]),
|
|
53
|
+
d.createdAt.slice(0, 16).replace('T', ' '),
|
|
54
|
+
]),
|
|
55
|
+
[undefined, undefined, 32, 28, 24, undefined, 48],
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
return lines.join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const appDeployments: Command = {
|
|
62
|
+
meta: {
|
|
63
|
+
noun: 'app',
|
|
64
|
+
verb: 'deployments',
|
|
65
|
+
args: [],
|
|
66
|
+
flags: {},
|
|
67
|
+
summary: 'List deployments: id, label, who and where from, and which link serves each',
|
|
68
|
+
examples: ['frontera app deployments', 'frontera app deployments --json'],
|
|
69
|
+
needsProject: true,
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async run(ctx) {
|
|
73
|
+
const project = requireProjectFrom(ctx)
|
|
74
|
+
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
75
|
+
const app = await projectApp(client, project)
|
|
76
|
+
const ledger = await client.deployments(app.id)
|
|
77
|
+
// The checks of the newest deployments that still have files; an older or
|
|
78
|
+
// failed read falls back to the render evidence the ledger already carries.
|
|
79
|
+
const checks: Record<string, DeploymentCheck[]> = {}
|
|
80
|
+
await Promise.all(
|
|
81
|
+
ledger.deployments
|
|
82
|
+
.filter((d) => !d.pruned)
|
|
83
|
+
.slice(0, CHECKED)
|
|
84
|
+
.map(async (d) => {
|
|
85
|
+
try {
|
|
86
|
+
checks[d.id] = (await client.checks(app.id, d.id)).checks
|
|
87
|
+
} catch {
|
|
88
|
+
// Best effort: the list still says who made it and what it serves.
|
|
89
|
+
}
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
return {
|
|
93
|
+
data: { ...ledger, deployments: ledger.deployments.map((d) => (checks[d.id] ? { ...d, checks: checks[d.id] } : d)) },
|
|
94
|
+
text: renderLedger(ledger, checks),
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|