@frontera-sdk/cli 1.52.39 → 1.52.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -1,26 +1,56 @@
1
- import { existsSync, statSync } from 'node:fs'
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 { CliError } from '../../errors'
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 { readPackageVersion, writeAppId, writeState } from '../../project'
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
- * Publish, and say the useful thing when the version is taken.
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
- * Versions are immutable, so republishing one is a CONFLICT — but the generic
21
- * conflict hint says "re-fetch, reapply your edit", which is the recovery for a
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(`version ${args.version} already exists for this app`, {
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: 'versions are immutable — bump "version" in package.json, or pass --version <next>',
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: 'Build output up to an immutable version, and promote it',
78
- examples: ['frontera app deploy', 'frontera app deploy --no-promote'],
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
- const version = flagString(ctx, 'version') ?? readPackageVersion(project.root)
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
- const renderEvidence = readRenderEvidence(project.root)
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
- const thumbnail = readRenderThumbnail(project.root)
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
- version,
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: collectBuildProvenance(project.root),
339
+ provenance,
174
340
  thumbnail,
175
341
  })
342
+ const deployment = res.deployment ?? res.version
176
343
 
177
- writeState(project.root, { appId: app.id, parentVersion: res.version })
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
- // The one line that turns "published" into something a person can act on.
183
- // A version that was published and not promoted used to be described to
184
- // users as one nobody could see — so the turn ended by asking to make an
185
- // unreviewed build live for the whole workspace, rather than showing it to
186
- // the person who asked for it. It was always viewable; only the URL was
187
- // missing.
188
- if (res.previewUrl) {
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
- `Published ${app.slug}@${res.version}` +
216
- (res.promoted ? ' and promoted it' : '') +
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
+ }