@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
package/src/pack.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { collectSourceFiles, formatBytes } from './packaging'
1
+ import { collectSourceFiles, formatBytes, type SourceFile } from './packaging'
2
2
  import { createTarGz } from './tar'
3
3
  import type { Output } from './output'
4
+ import { tsconfigAsKept } from './local/local-app-sdk-paths'
4
5
 
5
6
  /**
6
7
  * Package a directory for upload, reporting its size.
@@ -10,8 +11,12 @@ import type { Output } from './output'
10
11
  * bill — but it moves to stderr via `note`. It is commentary about the work,
11
12
  * not the work's result, and mixing it into stdout broke piping.
12
13
  */
13
- export function packDirectory(dir: string, output: Output, label: string) {
14
- const files = collectSourceFiles(dir)
14
+ export function packDirectory(dir: string, output: Output, label: string, extra: SourceFile[] = []) {
15
+ // `extra`: files kept beside the directory's own (a Function's imports from outside its folder).
16
+ // A tsconfig.json whose SDK-copy paths a run still going (the App's dev server) set aside is packaged as it is the App's.
17
+ const kept = tsconfigAsKept(dir)
18
+ const own = collectSourceFiles(dir).map((f) => (kept && f.rel === 'tsconfig.json' ? { rel: f.rel, bytes: new Uint8Array(kept) } : f))
19
+ const files = [...own, ...extra]
15
20
  const totalBytes = files.reduce((n, f) => n + f.bytes.byteLength, 0)
16
21
  output.note(` ${label} ${files.length} files, ${formatBytes(totalBytes)}`)
17
22
  return { tgz: createTarGz(files), fileCount: files.length, totalBytes }
package/src/packaging.ts CHANGED
@@ -61,7 +61,7 @@ export function isExcluded(rel: string): boolean {
61
61
  * IGNORED for anything in the hard set, so a permissive `.gitignore` can never
62
62
  * widen packaging past §6.2.
63
63
  */
64
- function gitignoreMatcher(root: string): (rel: string) => boolean {
64
+ export function gitignoreMatcher(root: string): (rel: string) => boolean {
65
65
  const path = join(root, '.gitignore')
66
66
  if (!existsSync(path)) return () => false
67
67
 
@@ -1,7 +1,10 @@
1
- import { existsSync, readdirSync, rmSync } from 'node:fs'
1
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
4
  import { UsageError } from './errors'
5
+ import { copyInstallLine, repullRefFor, type SdkShadow } from './local/local-app-code'
6
+ import { installFromLock, lockInstallLine } from './local/local-app-lock-install'
7
+ import { copyInstallShadows, lockMismatchAdvice, vendoredShadows } from './local/local-app-sdk'
5
8
 
6
9
  /**
7
10
  * The steps between "files written" and "a project someone can work in".
@@ -74,6 +77,53 @@ export interface InstallResult {
74
77
  installed: boolean
75
78
  /** The last line bun printed — its own package count and timing. */
76
79
  summary: string
80
+ /** Installed from the lockfile as it is (`--frozen-lockfile`), and it disagreed with package.json: nothing was written. */
81
+ lockMismatch?: boolean
82
+ /**
83
+ * The install went as package.json says, the SDK from the App's own copy
84
+ * under src/frontera/ that shadows the release its bun.lock records,
85
+ * without writing either file (`bun install --no-save`; `installed` says
86
+ * whether it worked): those packages, with the release bun.lock records.
87
+ */
88
+ fromCopy?: SdkShadow[]
89
+ /**
90
+ * Installed as bun.lock records it, from npm, where package.json names the
91
+ * App's own SDK copy (`installFromLock`): those packages, with the release
92
+ * installed. Neither file was written.
93
+ */
94
+ fromLock?: SdkShadow[]
95
+ }
96
+
97
+ export interface InstallOptions {
98
+ /** Install exactly what the lockfile says, never rewriting it (`bun install --frozen-lockfile`). */
99
+ frozenLockfile?: boolean
100
+ /** Install as package.json says without writing package.json or the lockfile (`bun install --no-save`). */
101
+ noSave?: boolean
102
+ }
103
+
104
+ /** The files an install must leave as they are, as they are now. */
105
+ const TRACKED_INSTALL_FILES = ['package.json', 'bun.lock', 'bun.lockb']
106
+ function snapshotFiles(dir: string): Map<string, Buffer> {
107
+ const out = new Map<string, Buffer>()
108
+ for (const name of TRACKED_INSTALL_FILES) if (existsSync(join(dir, name))) out.set(name, readFileSync(join(dir, name)))
109
+ return out
110
+ }
111
+ /** Put each back that changed (removing one that appeared); the names put back. */
112
+ function restoreFiles(dir: string, before: Map<string, Buffer>): string[] {
113
+ const restored: string[] = []
114
+ for (const name of TRACKED_INSTALL_FILES) {
115
+ const path = join(dir, name)
116
+ const was = before.get(name)
117
+ if (!was) {
118
+ if (!existsSync(path)) continue
119
+ rmSync(path, { force: true })
120
+ restored.push(name)
121
+ } else if (!existsSync(path) || !readFileSync(path).equals(was)) {
122
+ writeFileSync(path, was)
123
+ restored.push(name)
124
+ }
125
+ }
126
+ return restored
77
127
  }
78
128
 
79
129
  /**
@@ -85,9 +135,61 @@ export interface InstallResult {
85
135
  * rather than inherited because stdout carries command data and nothing else;
86
136
  * everything here is progress, which belongs on stderr.
87
137
  */
88
- export async function installDependencies(target: string): Promise<InstallResult> {
138
+ /**
139
+ * An App's packages, installed when it has none yet (a pulled App comes
140
+ * without node_modules): `bun install` in its folder. Null when it already
141
+ * has them; otherwise what the install did, for the caller to say.
142
+ *
143
+ * With a lockfile, exactly what it records (`--frozen-lockfile`): a pulled
144
+ * App's bun.lock is the record of the SDK its code was written against, and
145
+ * an install that rewrote it to match a changed package.json would lose that
146
+ * record and change a tracked file behind the person's back. When the two
147
+ * disagree nothing is installed or written, and `summary` says why and what
148
+ * fixes it (`lockMismatchAdvice`).
149
+ *
150
+ * Except when they disagree because package.json names the App's own SDK
151
+ * copy under src/frontera/ where bun.lock installed a published release (an
152
+ * older pull's repair). Then what bun.lock records is installed from npm,
153
+ * the deployed App's actual dependencies, in a temporary folder whose copy
154
+ * of package.json names those releases (`installFromLock`); `fromLock` says
155
+ * so. Offline, or refused, and the copy is there (`copyInstallShadows`): the
156
+ * packages are installed as package.json says, the SDK from that copy, with
157
+ * `bun install --no-save`, so the App previews and checks while package.json
158
+ * and bun.lock stay as pulled (either put back if the install changed it).
159
+ * `fromCopy` names those packages and `summary` says so, with what brings the
160
+ * deployed source back.
161
+ */
162
+ export async function ensureAppPackages(
163
+ appRoot: string,
164
+ install: (dir: string, options?: InstallOptions) => Promise<InstallResult> = installDependencies,
165
+ ): Promise<InstallResult | null> {
166
+ if (!existsSync(join(appRoot, 'package.json')) || existsSync(join(appRoot, 'node_modules'))) return null
167
+ if (!['bun.lock', 'bun.lockb'].some((f) => existsSync(join(appRoot, f)))) return install(appRoot)
168
+ // package.json names the SDK copy where bun.lock installed a release: what bun.lock records comes first, from npm.
169
+ const shadowed = vendoredShadows(appRoot)
170
+ const fromLock = shadowed.length ? await installFromLock(appRoot, shadowed, install) : null
171
+ if (fromLock?.installed) return { ...fromLock, summary: lockInstallLine(fromLock.fromLock, fromLock.summary) }
172
+ // Offline, or the registry refused it: the App's own SDK copy, as before.
173
+ const notFromLock = fromLock ? `the install of what bun.lock records from npm failed (${fromLock.summary}), so ` : ''
174
+ const fromCopy = copyInstallShadows(appRoot)
175
+ if (fromCopy.length) {
176
+ // Read before the install: whether the folder is still as an earlier pull left it.
177
+ const repull = repullRefFor(appRoot)
178
+ const before = snapshotFiles(appRoot)
179
+ const result = await install(appRoot, { noSave: true })
180
+ const putBack = restoreFiles(appRoot, before)
181
+ const restored = putBack.length ? `; the install changed ${putBack.join(' and ')}, put back as pulled` : ''
182
+ if (!result.installed) return { ...result, fromCopy, summary: `${notFromLock}\`bun install --no-save\` (from the App’s own SDK copy under src/frontera/, keeping bun.lock as pulled) failed: ${result.summary}${restored}` }
183
+ return { ...result, fromCopy, summary: `${notFromLock}${copyInstallLine(fromCopy, result.summary, { repull })}${restored}` }
184
+ }
185
+ const result = await install(appRoot, { frozenLockfile: true })
186
+ if (result.installed || !result.lockMismatch) return result
187
+ return { ...result, summary: lockMismatchAdvice(appRoot) }
188
+ }
189
+
190
+ export async function installDependencies(target: string, options: InstallOptions = {}): Promise<InstallResult> {
89
191
  try {
90
- const child = Bun.spawn(['bun', 'install'], {
192
+ const child = Bun.spawn(['bun', 'install', ...(options.frozenLockfile ? ['--frozen-lockfile'] : []), ...(options.noSave ? ['--no-save'] : [])], {
91
193
  cwd: target,
92
194
  stdin: 'ignore',
93
195
  stdout: 'pipe',
@@ -104,7 +206,8 @@ export async function installDependencies(target: string): Promise<InstallResult
104
206
  .filter((line) => line.length > 0)
105
207
 
106
208
  if (exitCode !== 0) {
107
- return { installed: false, summary: lines.at(-1) ?? `bun install exited with ${exitCode}` }
209
+ const lockMismatch = !!options.frozenLockfile && lines.some((line) => /lockfile had changes|lockfile is frozen|frozen.?lockfile/i.test(line))
210
+ return { installed: false, summary: lines.at(-1) ?? `bun install exited with ${exitCode}`, ...(lockMismatch ? { lockMismatch } : {}) }
108
211
  }
109
212
  const installed = lines.filter((line) => /packages installed|Checked \d+ install/.test(line))
110
213
  return { installed: true, summary: installed.at(-1) ?? lines.at(-1) ?? 'dependencies installed' }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { dirname, join, resolve } from 'node:path'
3
3
 
4
4
  import { isTrustedBinding, recordBinding } from './binding-registry'
@@ -197,3 +197,20 @@ export function ensureGitignored(root: string): 'added' | 'present' | 'created'
197
197
  )
198
198
  return 'added'
199
199
  }
200
+
201
+ /**
202
+ * Lines `.frontera/.gitignore` keeps out of git, each added once, the file's
203
+ * other lines left as they are. `context.json` is always among them: which
204
+ * profile a directory is bound to is this machine's choice, and a copy of the
205
+ * project (a clone, a pull, a folder copied) must not carry it to another
206
+ * machine or another directory, where it would name someone else's runtime.
207
+ */
208
+ export function ignoreInFronteraDir(root: string, entries: readonly string[]): void {
209
+ const path = join(root, CONTEXT_DIR, '.gitignore')
210
+ mkdirSync(dirname(path), { recursive: true })
211
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
212
+ const have = new Set(text.split(/\r?\n/).map((l) => l.trim().replace(/^\//, '').replace(/\/$/, '')))
213
+ const missing = [...new Set([...entries, CONTEXT_FILE])].filter((e) => !have.has(e.replace(/^\//, '').replace(/\/$/, '')))
214
+ if (!missing.length) return
215
+ writeFileAtomic(path, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, 0o644)
216
+ }
package/src/project.ts CHANGED
@@ -11,7 +11,10 @@ export interface ProjectConfig {
11
11
  slug: string
12
12
  displayName: string
13
13
  description?: string
14
- /** Version the working tree was pulled from — feeds parentVersion. */
14
+ /**
15
+ * The version a deploy names as its parent: the last deployment from this
16
+ * folder, else the version the working tree was pulled from.
17
+ */
15
18
  parentVersion?: string | null
16
19
  /** Origins the app may reach at runtime; become the served CSP. */
17
20
  connectDomains: string[]
@@ -84,7 +87,8 @@ export function readProject(dir: string): ProjectConfig {
84
87
  slug,
85
88
  displayName: config.displayName ?? pkg.frontera?.displayName ?? slug,
86
89
  description: config.description ?? pkg.frontera?.description,
87
- parentVersion: state.parentVersion ?? null,
90
+ // A folder never deployed from keeps its lineage: the version it was pulled from is the parent.
91
+ parentVersion: state.parentVersion ?? state.pulledVersion ?? null,
88
92
  connectDomains: stringList(config.connectDomains ?? pkg.frontera?.connectDomains, 'connectDomains'),
89
93
  resourceDomains: stringList(config.resourceDomains ?? pkg.frontera?.resourceDomains, 'resourceDomains'),
90
94
  outputDirectory: safeOutputDirectory(
@@ -137,8 +141,29 @@ export function readPackageVersion(dir: string): string {
137
141
  }
138
142
 
139
143
  interface ProjectState {
144
+ /** The last deployment made from this folder (`app deploy`): what it was deployed as. */
140
145
  parentVersion?: string | null
141
146
  appId?: string
147
+ /**
148
+ * The version the working tree was pulled from (`app pull`, or a local
149
+ * project's pull of an App's code), kept apart from `parentVersion` so a
150
+ * pulled folder is not read as deployed from here.
151
+ */
152
+ pulledVersion?: string | null
153
+ /** The App it was pulled from, by its id where it was pulled. */
154
+ pulledFrom?: string | null
155
+ /**
156
+ * Each source file as the pull left it, by path in the folder, with its hash
157
+ * (`local-app-pulled.ts`): `local check` counts an App still like this as
158
+ * inherited from the workspace.
159
+ */
160
+ pulledFiles?: Record<string, string>
161
+ /**
162
+ * A deployment from here that a database restore took away
163
+ * (`local-app-restore.ts`): the next `frontera local dev` deploys again,
164
+ * and the deploy clears it.
165
+ */
166
+ restoredAway?: { deployment: string; restoredAt: string }
142
167
  }
143
168
 
144
169
  export function readState(dir: string): ProjectState {
package/src/provenance.ts CHANGED
@@ -13,6 +13,16 @@ export interface AppBuildProvenance {
13
13
  lockfilePath?: string
14
14
  lockfileDigest?: string
15
15
  packagedAt: string
16
+ /** The Git branch the working copy is on, read locally. Informational, never authorization. */
17
+ branch?: string
18
+ /** The commit's author, `Name <email>`, read locally. */
19
+ commitAuthor?: string
20
+ /** The Forge session (and its harness) the deploy ran in, when it ran in one. */
21
+ session?: { id: string; harness: string | null }
22
+ /** What the CLI checked before uploading (design 2026-10-18 §6). */
23
+ checks?: { build: { status: 'passed' | 'failed' | 'skipped'; detail: string } }
24
+ /** What the App calls, for the platform's contract check. */
25
+ contract?: { digest?: string; objectTypes: Record<string, string[]>; actions: string[]; functions: string[] }
16
26
  }
17
27
 
18
28
  const LOCKFILES = ['bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'] as const
@@ -85,9 +95,24 @@ export function collectBuildProvenance(root: string): AppBuildProvenance {
85
95
  encoding: 'utf8',
86
96
  stdio: ['ignore', 'pipe', 'ignore'],
87
97
  }).trim().length > 0
98
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
99
+ // A detached HEAD has no branch; its commit still identifies the source.
100
+ if (branch && branch !== 'HEAD') provenance.branch = branch
101
+ const author = execFileSync('git', ['log', '-1', '--format=%an <%ae>'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
102
+ if (author) provenance.commitAuthor = author
88
103
  } catch {
89
104
  // Source provenance is optional for projects outside Git.
90
105
  }
91
106
 
107
+ const session = forgeSessionOf(process.env)
108
+ if (session) provenance.session = session
109
+
92
110
  return provenance
93
111
  }
112
+
113
+ /** The Forge session a command runs in: Forge puts `FORGE_SESSION` (and `FORGE_HARNESS`) in every session's environment. */
114
+ export function forgeSessionOf(env: NodeJS.ProcessEnv): { id: string; harness: string | null } | null {
115
+ const id = env.FORGE_SESSION?.trim()
116
+ if (!id) return null
117
+ return { id: id.slice(0, 120), harness: env.FORGE_HARNESS?.trim() || null }
118
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
2
  import { join, relative, sep } from 'node:path'
3
3
 
4
- import { isExcluded } from './packaging'
4
+ import { gitignoreMatcher, isExcluded } from './packaging'
5
5
 
6
6
  /**
7
7
  * Has anybody looked at this app before it goes live?
@@ -34,6 +34,12 @@ import { isExcluded } from './packaging'
34
34
  * tree; neither error matters here, because nothing is being enforced. The
35
35
  * cheap answer to a reported question beats the expensive answer to a blocked
36
36
  * one.
37
+ *
38
+ * The tooling's own temporary changes are not source changes, so they must
39
+ * not move an mtime: what `app dev` and `local check` rewrite for a run (the
40
+ * SDK copy's `paths` in `tsconfig.json`, what the dev server or a build
41
+ * writes) goes back byte for byte with the time it had (`putBackAsWas`). A
42
+ * put-back that left a new mtime made every render before it stale.
37
43
  */
38
44
 
39
45
  /** Written by `render_app`; see RENDER_EVIDENCE_RELATIVE_PATH in the service. */
@@ -59,6 +65,18 @@ export interface RenderEvidence {
59
65
  path?: string
60
66
  /** What the browser console said at render time, if the renderer recorded it. */
61
67
  consoleErrors?: number
68
+ /** Requests that failed at render time. */
69
+ failedRequests?: number
70
+ /** The basic accessibility scan, kept only for a fresh render: serious and critical findings are `violations`. */
71
+ a11y?: { violations: number; findings?: unknown[] }
72
+ /** What was rendered: the App's dev server or one of its previews, and the address. Absent on a render recorded before this was kept. */
73
+ of?: 'dev-server' | 'preview'
74
+ url?: string
75
+ /**
76
+ * The last render was not of this App (another address, or another App's
77
+ * page): it is not attached, and this deployment counts as not rendered.
78
+ */
79
+ notThisApp?: { url: string; why: string }
62
80
  }
63
81
 
64
82
  interface RenderRecord {
@@ -66,17 +84,31 @@ interface RenderRecord {
66
84
  renderedAtMs?: number
67
85
  path?: string
68
86
  consoleErrors?: number
87
+ failedRequests?: number
88
+ a11y?: { violations?: unknown; findings?: unknown[] }
89
+ target?: { of?: unknown; url?: unknown; app?: unknown }
90
+ }
91
+
92
+ /**
93
+ * Files a framework rewrites on every run, which nobody authors. `next dev`
94
+ * and `next build` each write `next-env.d.ts` with their own types folder, so
95
+ * building after a render made every render stale.
96
+ */
97
+ export function isGeneratedFile(rel: string): boolean {
98
+ return rel === 'next-env.d.ts' || rel.endsWith('.tsbuildinfo')
69
99
  }
70
100
 
71
101
  /**
72
102
  * Newest mtime across the files that would be packaged.
73
103
  *
74
- * Same exclusion set as packaging, so `node_modules` churn and build output
75
- * cannot make a tree look edited — the ONE failure mode that would make this
76
- * report worthless, because it would report `stale` every single time and
77
- * everyone would stop reading it.
104
+ * Same exclusion set as packaging (the hard set and the project's
105
+ * `.gitignore`), so `node_modules` churn and build output cannot make a tree
106
+ * look edited — the ONE failure mode that would make this report worthless,
107
+ * because it would report `stale` every single time and everyone would stop
108
+ * reading it.
78
109
  */
79
110
  export function newestSourceChangeMs(root: string): number {
111
+ const ignored = gitignoreMatcher(root)
80
112
  let newest = 0
81
113
  const walk = (dir: string) => {
82
114
  let entries: string[]
@@ -102,6 +134,7 @@ export function newestSourceChangeMs(root: string): number {
102
134
  walk(full)
103
135
  continue
104
136
  }
137
+ if (ignored(rel) || isGeneratedFile(rel)) continue
105
138
  newest = Math.max(newest, stat.mtimeMs)
106
139
  }
107
140
  }
@@ -109,7 +142,13 @@ export function newestSourceChangeMs(root: string): number {
109
142
  return newest
110
143
  }
111
144
 
112
- export function readRenderEvidence(root: string): RenderEvidence {
145
+ /**
146
+ * The last render, as evidence of this App's current source. With `app` (the
147
+ * App's slug), a render of an address that is not its dev server or one of
148
+ * its previews, or a render made for another App, is not evidence of it: it
149
+ * comes back unrendered, saying what it was of.
150
+ */
151
+ export function readRenderEvidence(root: string, app?: string): RenderEvidence {
113
152
  const path = join(root, RENDER_EVIDENCE_PATH)
114
153
  if (!existsSync(path)) return { rendered: false, reason: 'none' }
115
154
 
@@ -126,13 +165,29 @@ export function readRenderEvidence(root: string): RenderEvidence {
126
165
  const renderedAtMs = record.renderedAtMs ?? (record.renderedAt ? Date.parse(record.renderedAt) : NaN)
127
166
  if (!Number.isFinite(renderedAtMs)) return { rendered: false, reason: 'none' }
128
167
 
168
+ const target = record.target
169
+ const url = typeof target?.url === 'string' ? target.url : undefined
170
+ if (app && target && url) {
171
+ const why = target.app !== undefined && target.app !== app
172
+ ? `it was rendered for ${String(target.app)}, not ${app}`
173
+ : target.of === 'elsewhere'
174
+ ? `it is not ${app}'s dev server or one of its previews`
175
+ : null
176
+ if (why) return { rendered: false, reason: 'none', notThisApp: { url, why } }
177
+ }
178
+ const of = target?.of === 'dev-server' || target?.of === 'preview' ? target.of : undefined
129
179
  const rendered = renderedAtMs >= newestSourceChangeMs(root)
130
180
  return {
131
181
  rendered,
182
+ ...(of ? { of } : {}),
183
+ ...(url ? { url } : {}),
132
184
  reason: rendered ? 'fresh' : 'stale',
133
185
  ...(record.renderedAt ? { renderedAt: record.renderedAt } : {}),
134
186
  ...(record.path ? { path: record.path } : {}),
135
187
  ...(typeof record.consoleErrors === 'number' ? { consoleErrors: record.consoleErrors } : {}),
188
+ ...(typeof record.failedRequests === 'number' ? { failedRequests: record.failedRequests } : {}),
189
+ // A scan of an earlier tree says nothing about this one.
190
+ ...(rendered && typeof record.a11y?.violations === 'number' ? { a11y: { violations: record.a11y.violations, findings: (record.a11y.findings ?? []).slice(0, 20) } } : {}),
136
191
  }
137
192
  }
138
193
 
@@ -167,9 +222,12 @@ export function readRenderThumbnail(root: string): Uint8Array | null {
167
222
  * moves on.
168
223
  */
169
224
  export function describeRenderEvidence(evidence: RenderEvidence): string {
225
+ if (evidence.notThisApp) {
226
+ return ` not attaching the last render, of ${evidence.notThisApp.url}: ${evidence.notThisApp.why}; render the App's dev server (app_render with no url) and deploy again`
227
+ }
170
228
  switch (evidence.reason) {
171
229
  case 'fresh':
172
- return ` rendered ${evidence.path ?? '/'}${
230
+ return ` rendered ${evidence.url ? `${evidence.url}${evidence.of === 'preview' ? ' (a preview)' : ''}` : evidence.path ?? '/'}${
173
231
  evidence.consoleErrors ? ` (${evidence.consoleErrors} console error${evidence.consoleErrors === 1 ? '' : 's'} at render time)` : ''
174
232
  }`
175
233
  case 'stale':
package/src/scopes.ts CHANGED
@@ -54,7 +54,7 @@ export function commandScope(meta: {
54
54
 
55
55
  /** For prose: the organization-scoped nouns that actually reach the service. */
56
56
  export function orgScopedServiceNouns(): string[] {
57
- return [...ORG_SCOPED_NOUNS].filter((n) => n !== 'auth' && n !== 'kit').sort()
57
+ return [...ORG_SCOPED_NOUNS].filter((n) => n !== 'auth' && n !== 'kit').sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
58
58
  }
59
59
 
60
60
  /**
package/src/template.ts CHANGED
@@ -574,14 +574,20 @@ const instance = useObjectInstance('SupportTicket', selectedId)
574
574
  const version = recordVersionOf(instance.data)
575
575
  \`\`\`
576
576
 
577
- **A list row does not carry the version.** It arrives as \`_meta.recordVersion\`
578
- from the INSTANCE route only, and only for an editable type. So a control that
579
- edits a row in a table fetches that row's instance first — selecting a row and
580
- submitting straight from \`useObjects\` data sends no version at all.
581
-
582
- That failure is silent: a missing version does not error, the compare-and-set
583
- is simply skipped, and two people overwrite each other with no refusal and no
584
- evidence. Use \`recordVersionOf\` rather than reaching into \`_meta\` yourself.
577
+ **A row from \`useObjects\` does not carry the version.** It arrives as
578
+ \`_meta.recordVersion\`, only for an editable type: always from the INSTANCE
579
+ route, and on a query's rows only when the query asks for it (\`recordVersions\`,
580
+ which Functions, workflows and the CLI ask for). The App SDK's queries do not
581
+ ask, so a control that edits a row in a table fetches that row's instance first:
582
+ selecting a row and submitting straight from \`useObjects\` data sends no
583
+ version at all.
584
+
585
+ An edit of an existing record always compares versions, so a missing version
586
+ is refused, not skipped: the submit fails and nothing changes. The platform
587
+ fills the key input and the \`expectedVersion\` input from the subject (the
588
+ record and the version you submit against), and refuses the submit when the
589
+ inputs you pass disagree with it. Use \`recordVersionOf\` rather than reaching
590
+ into \`_meta\` yourself.
585
591
 
586
592
  A stale value IS refused, and that refusal is the feature — report it as
587
593
  "someone else changed this, reload" rather than retrying with a fresher version
@@ -20,6 +20,16 @@
20
20
  * build to be green.
21
21
  */
22
22
 
23
+ import vendored from '../vendor/sdk-sources.json'
24
+
25
+ /**
26
+ * The `@frontera-sdk/*` range a Next App depends on: the release of the SDK
27
+ * this CLI carries, so a new App starts on the SDK the CLI was tested with
28
+ * rather than on a floor several releases old. The packages are public npm
29
+ * packages; only the legacy Vite scaffold vendors their source.
30
+ */
31
+ export const SDK_RANGE = `^${vendored.sdkVersion}`
32
+
23
33
  interface ScaffoldInput {
24
34
  /** Project name — the package name, the display name and the page title. */
25
35
  name: string
@@ -62,8 +72,8 @@ function projectFiles(name: string): Record<string, string> {
62
72
  deploy: 'bun run check && frontera app deploy',
63
73
  },
64
74
  dependencies: {
65
- '@frontera-sdk/blueprint': '^1.43.10',
66
- '@frontera-sdk/core': '^1.43.10',
75
+ '@frontera-sdk/blueprint': SDK_RANGE,
76
+ '@frontera-sdk/core': SDK_RANGE,
67
77
  '@tanstack/react-query': '^5.90.21',
68
78
  // Everything from here to `tailwind-merge` is what a shadcn
69
79
  // component imports. Every item in that registry declares NO
@@ -120,6 +130,11 @@ function projectFiles(name: string): Record<string, string> {
120
130
 
121
131
  'next.config.ts': `import type { NextConfig } from 'next'
122
132
 
133
+ // This folder is the App's project, also when it sits inside a Frontera
134
+ // solution that has a lockfile of its own: without this, Next picks the
135
+ // outermost lockfile as the workspace root and warns on every run.
136
+ const root = typeof __dirname === 'string' ? __dirname : process.cwd()
137
+
123
138
  const nextConfig: NextConfig = {
124
139
  output: 'export',
125
140
  trailingSlash: true,
@@ -127,6 +142,11 @@ const nextConfig: NextConfig = {
127
142
  // Current Frontera SDK packages publish TypeScript source. This is Next's
128
143
  // supported boundary for compiling those dependencies inside the App build.
129
144
  transpilePackages: ['@frontera-sdk/core', '@frontera-sdk/blueprint', '@frontera-sdk/agents'],
145
+ // AGENTS.md is this project's own. Left on, \`next dev\` rewrites it on every
146
+ // run, the tree is never clean, and a deploy refuses the build as stale.
147
+ agentRules: false,
148
+ turbopack: { root },
149
+ outputFileTracingRoot: root,
130
150
  }
131
151
 
132
152
  export default nextConfig
@@ -213,6 +233,9 @@ export default nextConfig
213
233
  '.gitignore': `node_modules/
214
234
  .next/
215
235
  out/
236
+ # Written by \`next dev\` and \`next build\`, each with its own types folder.
237
+ next-env.d.ts
238
+ *.tsbuildinfo
216
239
  .frontera/
217
240
  .env
218
241
  .env.*
@@ -1032,16 +1055,22 @@ bun run check
1032
1055
  \`\`\`
1033
1056
 
1034
1057
  \`\`\`bash
1035
- frontera app deploy --no-promote
1058
+ frontera app deploy
1036
1059
  \`\`\`
1037
1060
 
1038
- Deploying without promoting publishes an immutable version and returns a
1039
- preview URL. Promote it once it looks right:
1061
+ A deploy uploads an immutable preview deployment and returns its link, plus
1062
+ the links of this branch's and this session's previews. It never changes what
1063
+ users see. Promote it once it looks right (it needs the promote permission,
1064
+ and the workspace's required checks):
1040
1065
 
1041
1066
  \`\`\`bash
1042
- frontera app promote <version>
1067
+ frontera app promote <deployment>
1043
1068
  \`\`\`
1044
1069
 
1070
+ \`frontera app deployments\` lists them; \`frontera app rollback --reason "…"\`
1071
+ returns production to the previous one and pins it until
1072
+ \`frontera app rollback --undo\`.
1073
+
1045
1074
  ## Layout
1046
1075
 
1047
1076
  \`\`\`text