@frontera-sdk/cli 1.52.39 → 1.52.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) 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 +48 -4
  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/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -1,5 +1,8 @@
1
+ import { posix } from 'node:path'
2
+
1
3
  import { parse as parseYaml } from 'yaml'
2
4
 
5
+ import { actionSemanticConflicts, type SemanticFacts } from '../blueprint/action-semantic-fit'
3
6
  import { unnamedObjectBindings } from './local-objects'
4
7
  import { governedActionIds, referencesIn } from './local-sync-refs'
5
8
 
@@ -38,7 +41,7 @@ export interface RuntimeCapabilities {
38
41
  media: boolean
39
42
  /** Built-in connections signed in with OAuth or a service account (Google Sheets, BigQuery…). */
40
43
  connections: boolean
41
- /** Calculated properties (a location from latitude and longitude); `blueprint apply` cannot create one yet. */
44
+ /** Calculated properties other than a location (which `blueprint apply` adds); none has a route from a file yet. */
42
45
  calculatedFields: boolean
43
46
  /** Governed Actions: `local` deploys those that write the Blueprint's own records (D3). */
44
47
  governedActions: 'local' | 'none'
@@ -78,6 +81,26 @@ export type Need =
78
81
  | { kind: 'app-code'; external?: boolean }
79
82
  /** An Agent's object tools whose object definition the pull couldn't name (an older platform). */
80
83
  | { kind: 'objects-unnamed'; count: number }
84
+ /**
85
+ * A Function's code imports files the pull couldn't bring (its source in the
86
+ * workspace doesn't hold them), by project path without extension: it can't
87
+ * build here, as `local dev` says with FUNCTION_IMPORT_MISSING.
88
+ */
89
+ | { kind: 'imports-missing'; paths: string[] }
90
+ /**
91
+ * A governed Action the local runtime refuses as written (the Action draft
92
+ * route's checks, such as an input value the set property's semantic type
93
+ * refuses): `local dev` tries it and it fails to apply, with that reason.
94
+ */
95
+ | { kind: 'action-refused'; reasons: string[] }
96
+ /**
97
+ * A governed Action that offers a value the semantic type of the property it
98
+ * writes refuses, as the workspace runs it: it deploys here as it is there,
99
+ * the conflict said as a warning inherited from the workspace, and a request
100
+ * with such a value is refused when it is submitted, here as there. (An
101
+ * index from before this read the same Action as `action-refused`.)
102
+ */
103
+ | { kind: 'action-inherited'; reasons: string[] }
81
104
 
82
105
  export interface Portability {
83
106
  class: PortabilityClass
@@ -108,8 +131,17 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
108
131
  const fit = ctx.actions?.get(need.name)
109
132
  // Unknown here: what the build says about it decides; at fetch it comes down with the Blueprint.
110
133
  if (!fit || fit.runsLocally) return null
134
+ if (fit.refused) return { class: 'adapted', text: `goes without governed Action ${need.name} here: the local runtime refuses it as written` }
111
135
  return { class: 'adapted', text: `governed Action ${need.name} is switched off here: ${fit.reason}` }
112
136
  }
137
+ case 'imports-missing':
138
+ return { class: 'platform-only', text: `won’t build here: it imports ${listed(need.paths)}, which its source in the workspace doesn’t hold, so the pull couldn’t bring ${need.paths.length === 1 ? 'it' : 'them'}` }
139
+ case 'action-refused':
140
+ // Not left off: `local dev` still deploys it, and the runtime refuses it, so the build says it failed.
141
+ return { class: 'platform-only', text: `the local runtime refuses it as written: ${need.reasons.join(' ')}` }
142
+ case 'action-inherited':
143
+ // It runs here as it does in the workspace: a warning, not an adaptation.
144
+ return { class: 'portable', text: `runs here as the workspace runs it, with a warning inherited from the workspace: ${need.reasons.join(' ')} The Blueprint’s author fixes the Action’s contract in the workspace` }
113
145
  case 'action-unnamed':
114
146
  return { class: 'platform-only', text: `runs ${need.count === 1 ? 'a governed Action' : `${need.count} governed Actions`} this key can’t name: add an organization key to bring ${need.count === 1 ? 'it' : 'them'}` }
115
147
  case 'connection':
@@ -123,19 +155,19 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
123
155
  case 'media':
124
156
  return caps.media ? null : { class: 'platform-only', text: 'reads the platform’s media files, so the local runtime leaves it off' }
125
157
  case 'media-stub':
126
- return caps.media ? null : { class: 'stubbed', text: `its files stay on the platform: an empty Media Set, ${need.set}, stands in for them here` }
158
+ return caps.media ? null : { class: 'stubbed', text: `its files stay on the platform: a stand-in Media Set, ${need.set}, holds placeholder files for them here` }
127
159
  case 'calculated':
128
160
  return caps.calculatedFields
129
161
  ? null
130
- : { class: 'adapted', text: `${need.fields.join(', ')} ${need.fields.length === 1 ? 'is' : 'are'} left off: calculated fields aren’t applied locally yet` }
162
+ : { class: 'adapted', text: `${need.fields.join(', ')} ${need.fields.length === 1 ? 'is' : 'are'} left off: only a location is a calculated field a file can add` }
131
163
  case 'markings':
132
164
  return { class: 'adapted', text: `reads every row: its markings come from the platform’s dataset, and ${need.dataset} has none` }
133
165
  case 'external-effect':
134
166
  return { class: 'stubbed', text: 'switched off here: it changes a system outside the local runtime' }
135
167
  case 'app-draft':
136
- return { class: 'adapted', text: 'saved as the owner’s draft here and opened as its preview, its Agents running their drafts: a Chat App publishes only published Agents, and the local runtime’s Agents are drafts' }
168
+ return { class: 'adapted', text: 'saved as the owner’s draft here: Studio opens it at /apps/<slug> as its draft preview, its Agents running their drafts. It publishes once its Agents are published (a Chat App publishes only published Agents)' }
137
169
  case 'app-code':
138
- return { class: 'adapted', text: `saved here with the Functions it may start, without its code: an App’s code is its own project, deployed to this runtime on its own${need.external ? '; it is hosted on its own domain, whose sign-in stays on the platform' : ''}` }
170
+ return { class: 'adapted', text: `saved here with the Functions it may start; its code is its own project in apps/<slug>/, which a pull brings unless --no-app-code, where \`frontera app dev\` runs it against this runtime and \`frontera app deploy\` deploys it here${need.external ? '; it is hosted on its own domain, whose sign-in stays on the platform' : ''}` }
139
171
  case 'objects-unnamed':
140
172
  return { class: 'adapted', text: `goes without ${need.count === 1 ? 'one of its object tools' : `${need.count} of its object tools`} here: the pull couldn’t read ${need.count === 1 ? 'its object definition' : 'their object definitions'} on the platform` }
141
173
  case 'context-unread':
@@ -143,6 +175,39 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
143
175
  }
144
176
  }
145
177
 
178
+ const listed = (paths: string[]) => (paths.length > 1 ? `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}` : paths.join(''))
179
+
180
+ /**
181
+ * Why an item won't run locally at all, as a pull's summary says it: the
182
+ * reason, what fixes it, and whose fix it is. Empty when it runs, even adapted.
183
+ */
184
+ export interface Blocker {
185
+ reason: string
186
+ fix: string
187
+ /** Who makes the fix: the item's owner in the workspace, or whoever works in this project. */
188
+ owner: string
189
+ }
190
+
191
+ export function blockers(needs: readonly Need[]): Blocker[] {
192
+ return needs.flatMap((n): Blocker[] => {
193
+ if (n.kind === 'imports-missing') {
194
+ return [{
195
+ reason: `it imports ${listed(n.paths)}, which its source in the workspace doesn’t hold, so it can’t build here`,
196
+ fix: `deploy it again in the workspace from its full source with a current CLI, then pull it; or add ${n.paths.length === 1 ? 'the file' : 'the files'} here if you have the source`,
197
+ owner: 'whoever deploys this Function in the workspace',
198
+ }]
199
+ }
200
+ if (n.kind === 'action-refused') {
201
+ return [{
202
+ reason: `the local runtime refuses it as written: ${n.reasons.join(' ')}`,
203
+ fix: 'change the Action (or the semantic type it writes) so the values agree, in the workspace’s Blueprint, then pull it',
204
+ owner: 'the Blueprint’s author in the workspace',
205
+ }]
206
+ }
207
+ return []
208
+ })
209
+ }
210
+
146
211
  /** The class an item gets from its needs: the most limiting one wins. */
147
212
  export function classify(needs: Need[], ctx: Context = {}): Portability {
148
213
  let cls: PortabilityClass = 'portable'
@@ -172,7 +237,7 @@ export interface ObjectTypeFit {
172
237
  leftOff: boolean
173
238
  /** It reads a Media Set's catalog: the runtime holds it over an empty Media Set of this API name. */
174
239
  mediaStub?: string | null
175
- /** Calculated properties, by apiName: the runtime holds the type without them. */
240
+ /** Calculated properties other than a location, by apiName: the runtime holds the type without them. */
176
241
  calculated: string[]
177
242
  /** Its rows are read by markings from the platform's dataset: here it reads every row. */
178
243
  readsEveryRow: boolean
@@ -206,7 +271,8 @@ export function objectTypeFit(doc: Doc, hasLocalDataset: (dataset: string) => bo
206
271
  const set = mediaCatalogSet(doc)
207
272
  return set ? { leftOff: false, mediaStub: set, calculated: [], readsEveryRow: false } : { leftOff: true, calculated: [], readsEveryRow: false }
208
273
  }
209
- const calculated = properties.filter((p) => p.calculation).map((p) => String(p.apiName ?? ''))
274
+ // A location (from latitude and longitude) is added by `blueprint apply`; another calculation has no route from a file.
275
+ const calculated = properties.filter((p) => p.calculation && (p.calculation as { kind?: unknown }).kind !== 'location').map((p) => String(p.apiName ?? ''))
210
276
  const dataset = doc.backing?.dataset
211
277
  const readsEveryRow = doc.rowAccess === 'markings' && !doc.markingsColumn && typeof dataset === 'string' && hasLocalDataset(dataset)
212
278
  return { leftOff: false, calculated, readsEveryRow }
@@ -232,8 +298,16 @@ export interface ActionFit {
232
298
  runsLocally: boolean
233
299
  /** Why not, in words. */
234
300
  reason?: string
301
+ /** The local runtime refuses the file as written (not merely switches it off). */
302
+ refused?: boolean
235
303
  /** The object type it acts on. */
236
304
  subject: string | null
305
+ /**
306
+ * The values it offers that the semantic types it writes refuse, as the
307
+ * workspace has them (`changed: false`): it deploys as the workspace runs it,
308
+ * and these are said as warnings inherited from the workspace.
309
+ */
310
+ inheritedWarnings?: string[]
237
311
  }
238
312
 
239
313
  const ONTOLOGY_EDITS = new Set(['create', 'update', 'delete'])
@@ -246,18 +320,55 @@ const ONTOLOGY_EDITS = new Set(['create', 'update', 'delete'])
246
320
  * command, or an edit with nothing to set would be bound to a connector on the
247
321
  * platform, which calls a real system: here it is published switched off.
248
322
  */
249
- export function actionFit(doc: Doc): ActionFit {
323
+ export function actionFit(doc: Doc, semantic?: SemanticFacts, opts: { changed?: boolean } = {}): ActionFit {
250
324
  const subject = typeof doc.subject?.objectType === 'string' ? doc.subject.objectType : null
325
+ // A value the semantic type it writes refuses (`actionSemanticConflicts`). The person's own change
326
+ // is refused before anything else, as the platform refuses a write that adds one: the runtime holds
327
+ // no such Action. The workspace's Action (as pulled) deploys as the workspace runs it, with a warning.
328
+ const conflicts = semantic ? actionSemanticConflicts(doc, semantic) : []
329
+ if (conflicts.length && opts.changed) return { runsLocally: false, refused: true, reason: `the local runtime refuses it: ${conflicts.map((c) => c.message).join(' ')}`, subject }
330
+ const inherited = conflicts.length ? { inheritedWarnings: conflicts.map((c) => c.message) } : {}
251
331
  const effect = typeof doc.effect?.kind === 'string' ? doc.effect.kind : null
252
- if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject }
332
+ if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject, ...inherited }
253
333
  const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Doc) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : []))
254
334
  const sets = changes.some((c: Doc) => c?.kind === 'property_set' && c?.value?.kind === 'input')
255
- if (effect !== 'delete' && !sets) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject }
256
- return { runsLocally: true, subject }
335
+ if (effect !== 'delete' && !sets) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject, ...inherited }
336
+ return { runsLocally: true, subject, ...inherited }
257
337
  }
258
338
 
259
- export function actionNeeds(doc: Doc): Need[] {
260
- return actionFit(doc).runsLocally ? [] : [{ kind: 'external-effect' }]
339
+ /** What a pulled Action needs here: it is the workspace's as pulled, so a semantic-type conflict is inherited. */
340
+ export function actionNeeds(doc: Doc, semantic?: SemanticFacts): Need[] {
341
+ const conflicts = semantic ? actionSemanticConflicts(doc, semantic) : []
342
+ const inherited: Need[] = conflicts.length ? [{ kind: 'action-inherited', reasons: conflicts.map((c) => c.message) }] : []
343
+ return [...inherited, ...(actionFit(doc).runsLocally ? [] : [{ kind: 'external-effect' } as const])]
344
+ }
345
+
346
+ // ── Functions ────────────────────────────────────────────────────────────────
347
+
348
+ const CODE_FILE = /\.(ts|tsx|mts|js|mjs)$/
349
+ const RELATIVE_SPEC = /(?:from|import)\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g
350
+ const RESOLVE = ['', '.ts', '.tsx', '.mts', '.js', '.mjs', '.json', '/index.ts', '/index.js']
351
+
352
+ /**
353
+ * The project files a Function's code imports that neither it nor any other
354
+ * pulled item holds, by project path without extension: what `local dev`'s
355
+ * bundler would fail to resolve (FUNCTION_IMPORT_MISSING). Tests are left
356
+ * out (a test's import fails the unit step, not the build), and so is the
357
+ * project's `vendor/` SDK, which every project has.
358
+ */
359
+ export function missingFunctionImports(files: Record<string, string>, has: (path: string) => boolean): string[] {
360
+ const missing = new Set<string>()
361
+ for (const [path, text] of Object.entries(files)) {
362
+ if (!CODE_FILE.test(path) || /\.test\.[a-z]+$/.test(path) || path.endsWith('.d.ts')) continue
363
+ for (const m of text.matchAll(RELATIVE_SPEC)) {
364
+ const base = posix.normalize(posix.join(posix.dirname(path), m[1]!))
365
+ if (base.startsWith('vendor/') || base.split('/').includes('node_modules')) continue
366
+ const stem = base.replace(/\.(js|mjs)$/, '')
367
+ const found = base.startsWith('../') ? false : [...RESOLVE.map((e) => `${base}${e}`), ...RESOLVE.map((e) => `${stem}${e}`)].some((c) => files[c] !== undefined || has(c))
368
+ if (!found) missing.add(stem.replace(/\.(ts|tsx|mts)$/, ''))
369
+ }
370
+ }
371
+ return [...missing].sort()
261
372
  }
262
373
 
263
374
  // ── Agents and workflows ─────────────────────────────────────────────────────
@@ -330,17 +441,31 @@ export function itemNeeds(kind: string, files: Record<string, string>, connectio
330
441
  export function portabilityOf(
331
442
  kind: string,
332
443
  files: Record<string, string>,
333
- opts: { connections?: ReadonlySet<string>; builtIns?: ReadonlyMap<string, BuiltInKind>; hasLocalDataset?: (dataset: string) => boolean; context?: Context } = {},
444
+ opts: {
445
+ connections?: ReadonlySet<string>
446
+ builtIns?: ReadonlyMap<string, BuiltInKind>
447
+ hasLocalDataset?: (dataset: string) => boolean
448
+ context?: Context
449
+ /** The pulled Blueprint's semantic types, shared fields and object types: an Action is checked against them. */
450
+ semantic?: SemanticFacts
451
+ /** Whether the pull (or the project) holds a file, by project path: a Function's imports are checked against it. */
452
+ hasFile?: (path: string) => boolean
453
+ } = {},
334
454
  ): Portability {
335
455
  if (kind === 'blueprint') {
336
456
  const needs = Object.entries(files).flatMap(([path, text]): Need[] => {
337
457
  const doc = parse(path, text) as Doc | null
338
458
  if (!doc || typeof doc !== 'object') return []
339
459
  if (path.startsWith('blueprint/object-types/')) return objectTypeNeeds(doc, opts.hasLocalDataset ?? (() => true))
340
- if (path.startsWith('blueprint/actions/')) return actionNeeds(doc)
460
+ if (path.startsWith('blueprint/actions/')) return actionNeeds(doc, opts.semantic)
341
461
  return []
342
462
  })
343
463
  return classify(needs, opts.context)
344
464
  }
345
- return classify(itemNeeds(kind, files, opts.connections, opts.builtIns), opts.context)
465
+ const needs = itemNeeds(kind, files, opts.connections, opts.builtIns)
466
+ if (kind === 'function') {
467
+ const paths = missingFunctionImports(files, opts.hasFile ?? (() => false))
468
+ if (paths.length) needs.unshift({ kind: 'imports-missing', paths })
469
+ }
470
+ return classify(needs, opts.context)
346
471
  }
@@ -1,10 +1,11 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { createServer } from 'node:net'
4
- import { join } from 'node:path'
4
+ import { join, sep } from 'node:path'
5
5
 
6
6
  import { writeFileAtomic } from '../fs-atomic'
7
7
  import { dataDir } from '../paths'
8
+ import { removeRuntimeResources } from './local-docker'
8
9
 
9
10
  /**
10
11
  * Sticky ports, one block per local runtime.
@@ -14,6 +15,9 @@ import { dataDir } from '../paths'
14
15
  * across restarts while two projects can run side by side. Same here: each
15
16
  * runtime claims a block of ten consecutive ports starting at 54100 and keeps
16
17
  * it, recorded in `~/.local/share/frontera/local-ports.json`.
18
+ *
19
+ * A block is freed by `frontera local remove`, and, when a new runtime needs
20
+ * one, for every project whose folder is gone.
17
21
  */
18
22
 
19
23
  export type PortName = 'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces'
@@ -58,20 +62,108 @@ function blockFrom(start: number): PortBlock {
58
62
  }
59
63
  }
60
64
 
61
- /** The block this project already owns, or a fresh one nobody else has claimed. */
62
- export function claimPorts(projectId: string, root: string): PortBlock {
65
+ /** A block freed because its project folder is gone. */
66
+ export interface ReclaimedBlock {
67
+ /** The runtime's id: the block's key, and what its containers and volumes are named by. */
68
+ key: string
69
+ root: string
70
+ start: number
71
+ }
72
+
73
+ export interface PortClaim {
74
+ ports: PortBlock
75
+ /** Blocks freed to make this claim; empty when the project already had one. */
76
+ reclaimed: ReclaimedBlock[]
77
+ }
78
+
79
+ /**
80
+ * Mount points a folder can vanish under without being deleted: an external
81
+ * or network drive that is not mounted right now. A project under one whose
82
+ * mount point is missing is left alone, so unplugging a drive never deletes a
83
+ * runtime's data.
84
+ */
85
+ function onAbsentMount(root: string): boolean {
86
+ const parts = root.split(sep).filter(Boolean)
87
+ const mountDepth = parts[0] === 'Volumes' || parts[0] === 'mnt' ? 2
88
+ : parts[0] === 'media' ? 3
89
+ : parts[0] === 'run' && parts[1] === 'media' ? 4
90
+ : 0
91
+ if (!mountDepth || parts.length < mountDepth) return false
92
+ return !existsSync(sep + parts.slice(0, mountDepth).join(sep))
93
+ }
94
+
95
+ /** Whether a block's project folder is gone, so the block and its runtime's data may go too. */
96
+ function isStale(entry: { root?: unknown }): entry is { start: number; root: string } {
97
+ return typeof entry.root === 'string' && entry.root.length > 0 && !existsSync(entry.root) && !onAbsentMount(entry.root)
98
+ }
99
+
100
+ function removeQuietly(key: string): void {
101
+ try {
102
+ removeRuntimeResources(key)
103
+ } catch {
104
+ // Without a container runtime there is nothing of it to remove; the block is free either way.
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The block this project already owns, or a fresh one nobody else has claimed.
110
+ *
111
+ * A fresh claim first frees every block whose project folder no longer
112
+ * exists, deleting that runtime's containers and volumes with it
113
+ * (`cleanup`): they are named by the block's key, and nothing can reach them
114
+ * once the folder is gone.
115
+ */
116
+ export function claimPortBlock(
117
+ projectId: string,
118
+ root: string,
119
+ cleanup: (key: string) => void = removeQuietly,
120
+ ): PortClaim {
63
121
  const registry = readRegistry()
64
122
  const existing = registry.blocks[projectId]
65
- if (existing) return blockFrom(existing.start)
123
+ if (existing) return { ports: blockFrom(existing.start), reclaimed: [] }
124
+ const reclaimed: ReclaimedBlock[] = []
125
+ for (const [key, entry] of Object.entries(registry.blocks)) {
126
+ if (!isStale(entry)) continue
127
+ delete registry.blocks[key]
128
+ reclaimed.push({ key, root: entry.root, start: entry.start })
129
+ }
130
+ // The registry first: a cleanup that fails part-way never leaves a block claimed by nothing.
131
+ const write = () => writeFileAtomic(registryPath(), `${JSON.stringify(registry, null, 2)}\n`, 0o644)
132
+ if (reclaimed.length) write()
133
+ for (const block of reclaimed) cleanup(block.key)
66
134
  const taken = new Set(Object.values(registry.blocks).map((b) => b.start))
67
135
  for (let i = 0; i < MAX_BLOCKS; i++) {
68
136
  const start = BASE + i * BLOCK
69
137
  if (taken.has(start)) continue
70
138
  registry.blocks[projectId] = { start, root }
71
- writeFileAtomic(registryPath(), `${JSON.stringify(registry, null, 2)}\n`, 0o644)
72
- return blockFrom(start)
139
+ write()
140
+ return { ports: blockFrom(start), reclaimed }
73
141
  }
74
- throw new Error('no free local port block left; remove stale entries from ~/.local/share/frontera/local-ports.json')
142
+ throw new Error(
143
+ `no free local port block left: all ${MAX_BLOCKS} belong to projects whose folders still exist; `
144
+ + 'run `frontera local remove` in a project you no longer need',
145
+ )
146
+ }
147
+
148
+ /** `claimPortBlock`, for a caller with no one to tell what was freed. */
149
+ export function claimPorts(projectId: string, root: string): PortBlock {
150
+ return claimPortBlock(projectId, root).ports
151
+ }
152
+
153
+ /** The one line that says which blocks a claim freed, or null when it freed none. */
154
+ export function reclaimedNote(reclaimed: readonly ReclaimedBlock[]): string | null {
155
+ if (!reclaimed.length) return null
156
+ const names = reclaimed.map((b) => b.key)
157
+ const shown = names.length > 3 ? `${names.slice(0, 3).join(', ')} and ${names.length - 3} more` : names.join(', ')
158
+ return reclaimed.length === 1
159
+ ? `Freed the local ports of ${shown}, whose project folder ${reclaimed[0]!.root} is gone, and deleted its containers and volumes.`
160
+ : `Freed the local ports of ${reclaimed.length} runtimes whose project folders are gone (${shown}), and deleted their containers and volumes.`
161
+ }
162
+
163
+ /** The block a project owns, or null. */
164
+ export function portBlockOf(projectId: string): PortBlock | null {
165
+ const entry = readRegistry().blocks[projectId]
166
+ return entry ? blockFrom(entry.start) : null
75
167
  }
76
168
 
77
169
  export function releasePorts(projectId: string): void {
@@ -0,0 +1,234 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { agentActionGapLine, agentActionGaps } from './local-agent-action-grants'
5
+ import { agentLeanPlugins, agentLeanPluginsLine } from './local-agent-lean-plugins'
6
+ import { emptyWorkflowLine, emptyWorkflows, workspaceWorkflows } from './local-empty-workflows'
7
+ import { earlierPullShadows, sdkShadowLine } from './local-app-code'
8
+ import { vendoredShadows } from './local-app-sdk'
9
+ import { staleSampleDataLine } from './local-sample-data'
10
+ import { sampleDatasetNames } from './local-synthetic-data'
11
+ import { readSnapshot, type RemoteSnapshot } from './local-sync-snapshot'
12
+ import { vendoredSdkLine, vendoredSdkState } from './local-vendored-sdk'
13
+
14
+ /**
15
+ * What a coding agent needs to know about a project before it trusts what
16
+ * the local runtime shows, said by `local dev` and `local status` (and the
17
+ * MCP status tool) rather than left in generated files: what a pull could not
18
+ * bring and why, sample data an older generator made, a vendored SDK that is
19
+ * not this CLI's, Apps whose code stays on the platform or won't install as
20
+ * pulled, and Agents told to submit governed Actions their grants never offer.
21
+ */
22
+ export interface ProjectNotices {
23
+ /** From the pull's index (`.frontera/sync/remote.json`): one line each. */
24
+ pull: string[]
25
+ /**
26
+ * Each pulled Agent's own notes from the pull's index (no behavior settings
27
+ * on the platform, a connection that stays there), one line per note with
28
+ * the Agents it is about. Said by `local status`, not as a warning: what
29
+ * came, not something to fix.
30
+ */
31
+ agentNotes?: string[]
32
+ /** Each App the folder describes without its code, with how to get the code. */
33
+ apps: string[]
34
+ sampleData: string | null
35
+ sdk: string | null
36
+ /** Each Agent whose instructions or procedures name governed Actions it can't submit (`agentActionGaps`), or whose Plugins aren't offered (`agentLeanPlugins`), with the fix. */
37
+ agents: string[]
38
+ /** Each workflow with no steps: it applies, but does nothing (`emptyWorkflows`). */
39
+ workflows: string[]
40
+ }
41
+
42
+ export function projectNotices(root: string): ProjectNotices {
43
+ const snap = readSnapshot(root)
44
+ return {
45
+ pull: snap ? pullNoteLines(root, snap) : [],
46
+ agentNotes: snap ? agentPullNoteLines(root, snap) : [],
47
+ apps: snap ? appCodeLines(root, snap) : [],
48
+ sampleData: staleSampleDataLine(root, sampleDatasetNames(root)),
49
+ sdk: vendoredSdkLine(vendoredSdkState(root)),
50
+ agents: [...agentActionGaps(root).map(agentActionGapLine), ...agentLeanPlugins(root).map(agentLeanPluginsLine)],
51
+ workflows: emptyWorkflows(root).map((slug, _i, all) => emptyWorkflowLine(slug, all.length ? workspaceWorkflows(root) : null)),
52
+ }
53
+ }
54
+
55
+ /** The notices as lines, each with what it is about first; empty when there are none. */
56
+ export function noticeLines(n: ProjectNotices): string[] {
57
+ return [...n.pull, ...n.apps, ...(n.sampleData ? [n.sampleData] : []), ...(n.sdk ? [n.sdk] : []), ...n.agents, ...(n.workflows ?? [])]
58
+ }
59
+
60
+ const BEHAVIOR_UNREAD = /behavior settings couldn’t be read on the platform(?: \((.*?)\))?, so no operating\.json was pulled/
61
+ const CONTEXT_UNREAD = /Context and data couldn’t be read on the platform: ([^.]*)/
62
+
63
+ /**
64
+ * The pull's notes an agent has to act on or work around: Agents whose
65
+ * behavior settings or Context could not be read (and why), and what the
66
+ * whole pull left out. The rest of the index's notes describe what came, and
67
+ * `local dev` says it as it applies each item.
68
+ */
69
+ export function pullNoteLines(root: string, snap: RemoteSnapshot): string[] {
70
+ const lines: string[] = []
71
+ const agentsHere = (ref: string) => existsSync(join(root, 'agents', ref.slice('agent/'.length), 'agent.json'))
72
+ const unread = new Map<string, string[]>()
73
+ for (const [ref, item] of Object.entries(snap.items)) {
74
+ if (!ref.startsWith('agent/') || !agentsHere(ref)) continue
75
+ for (const note of item.notes ?? []) {
76
+ const behavior = BEHAVIOR_UNREAD.exec(note)
77
+ if (behavior) {
78
+ const why = behavior[1] ?? 'the platform didn’t say why'
79
+ unread.set(why, [...(unread.get(why) ?? []), ref.slice('agent/'.length)])
80
+ }
81
+ const context = CONTEXT_UNREAD.exec(note)
82
+ if (context) lines.push(`${ref}: its Context and data couldn’t be read on the platform (${context[1]!.trim()}), so here it may read every object type the project holds, more than it may there`)
83
+ }
84
+ }
85
+ for (const [why, slugs] of unread) {
86
+ const own = slugs.filter((s) => existsSync(join(root, 'agents', s, 'operating.json')))
87
+ lines.push(
88
+ `${slugs.length === 1 ? `agent/${slugs[0]}: its` : `${slugs.length} Agents (${slugs.join(', ')}): their`} behavior settings weren’t pulled (${why}), so the project has no operating.json from the workspace`
89
+ + `${own.length ? `; ${own.map((s) => `agents/${s}/operating.json`).join(', ')} ${own.length === 1 ? 'was' : 'were'} written here and may differ from the workspace’s` : ''}`
90
+ + `. Nothing here can show the workspace’s settings: \`frontera local sync pull ${slugs.map((s) => `agent/${s}`).join(' ')}\` reads them again (a platform from this release on lets the project’s workspace key read them)`,
91
+ )
92
+ }
93
+ for (const note of snap.notes ?? []) lines.push(`the pull: ${shortList(pullNoteText(note, snap.workspace, root))}`)
94
+ return lines
95
+ }
96
+
97
+ /**
98
+ * Each pulled Agent's notes in the pull index that `pullNoteLines` doesn't
99
+ * already say (behavior settings or Context that couldn't be read), one
100
+ * line per note: "agent/vera: has no behavior settings in its snapshot on
101
+ * the platform (operating.json): …", or "agent/leo, agent/nova and
102
+ * agent/vera: each has …" when several Agents share it. Only Agents whose
103
+ * folder is in the project.
104
+ */
105
+ export function agentPullNoteLines(root: string, snap: RemoteSnapshot): string[] {
106
+ const byNote = new Map<string, string[]>()
107
+ for (const [ref, item] of Object.entries(snap.items).sort(([a], [b]) => a.localeCompare(b))) {
108
+ if (!ref.startsWith('agent/') || !existsSync(join(root, 'agents', ref.slice('agent/'.length), 'agent.json'))) continue
109
+ for (const note of item.notes ?? []) {
110
+ if (BEHAVIOR_UNREAD.test(note) || CONTEXT_UNREAD.test(note)) continue
111
+ byNote.set(note, [...(byNote.get(note) ?? []), ref])
112
+ }
113
+ }
114
+ // "Has no …" reads on after the Agent's name in lower case; a name or an acronym first keeps its case.
115
+ const lower = (note: string) => (/^[A-Z][a-z]/.test(note) ? `${note[0]!.toLowerCase()}${note.slice(1)}` : note)
116
+ return [...byNote].map(([note, refs]) => {
117
+ // "No behavior settings on the platform", for an Agent given an operating.json here since: it no longer runs on the defaults here.
118
+ const own = /\boperating\.json\b/.test(note) ? refs.map((r) => `agents/${r.slice('agent/'.length)}/operating.json`).filter((f) => existsSync(join(root, f))) : []
119
+ const since = own.length ? `; since then ${andList(own)} ${own.length === 1 ? 'was' : 'were'} written here, so ${own.length === 1 ? 'that Agent runs' : 'those Agents run'} on ${own.length === 1 ? 'it' : 'them'} here, not on the defaults` : ''
120
+ return `${refs.length === 1 ? `${refs[0]}: ${lower(note)}` : `${andList(refs)}: each ${lower(note)}`}${since}`
121
+ })
122
+ }
123
+
124
+ /** "a, b and c". */
125
+ const andList = (items: string[]) => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`)
126
+
127
+ /** A pull index note that governed Actions stayed out, as the CLI and older CLIs wrote it. */
128
+ const UNGRANTED = /^(\d+) governed Actions? acts? on object types (?:this|the) workspace isn’t granted, so (?:it stays|they stay) out: (.+)$/
129
+
130
+ /**
131
+ * The Agents and Apps in the project whose files name any of these governed
132
+ * Actions (an Agent's instructions or procedures, an App's description), as
133
+ * "Agent desk" and "App ops-console": what would miss them here.
134
+ */
135
+ function namedBy(root: string, actions: string[]): string[] {
136
+ const escape = (name: string) => name.replace(/[.*+?^$()|[\]\\{}]/g, '\\$&')
137
+ const pattern = new RegExp(`\\b(?:${actions.map(escape).join('|')})\\b`)
138
+ const named = (text: string) => !!text && pattern.test(text)
139
+ const read = (file: string) => {
140
+ try {
141
+ return readFileSync(file, 'utf8')
142
+ } catch {
143
+ return ''
144
+ }
145
+ }
146
+ const out: string[] = []
147
+ for (const slug of folderNames(join(root, 'agents'))) {
148
+ if (['agent.json', 'operating.json'].some((f) => named(read(join(root, 'agents', slug, f))))) out.push(`Agent ${slug}`)
149
+ }
150
+ for (const f of folderNames(join(root, 'apps')).filter((n) => n.endsWith('.json'))) {
151
+ if (named(read(join(root, 'apps', f)))) out.push(`App ${f.replace(/\.json$/, '')}`)
152
+ }
153
+ return out
154
+ }
155
+
156
+ function folderNames(dir: string): string[] {
157
+ try {
158
+ return readdirSync(dir).filter((n) => !n.startsWith('.')).sort()
159
+ } catch {
160
+ return []
161
+ }
162
+ }
163
+
164
+ /**
165
+ * A note of the pull's index, naming the workspace it means: the one the
166
+ * project was pulled from, which a copy linked to no workspace (or to
167
+ * another) can't call "this workspace". Notes from an older CLI said "this".
168
+ * The note that governed Actions stayed out says it plainly and with its next
169
+ * step: which of the project's Agents and Apps name them (read from `root`),
170
+ * and that a workspace admin grants the object types before a pull brings
171
+ * them. Its list of names stays last, for `shortList`.
172
+ */
173
+ export function pullNoteText(note: string, workspace: string | null | undefined, root?: string): string {
174
+ const ungranted = UNGRANTED.exec(note)
175
+ if (ungranted) {
176
+ const n = Number(ungranted[1])
177
+ const names = ungranted[2]!.split(', ')
178
+ const one = n === 1
179
+ const where = workspace ? `this copy’s workspace (${workspace.slice(0, 8)})` : 'this copy’s workspace'
180
+ const users = root ? namedBy(root, names) : []
181
+ const who = users.length
182
+ ? `${andList(users)} name${users.length === 1 ? 's' : ''} ${one ? 'it' : 'some of them'} and can’t submit ${one ? 'it' : 'those'} here`
183
+ : `No Agent or App here names ${one ? 'it' : 'them'}`
184
+ return `${n} governed Action${one ? ' acts' : 's act'} on object types ${where} isn’t granted, so ${one ? 'it wasn’t' : 'they weren’t'} pulled. ${who}; to bring ${one ? 'it' : 'them'}, a workspace admin grants those object types to the workspace, then pull again. ${one ? 'It is' : 'They are'}: ${names.join(', ')}`
185
+ }
186
+ if (!workspace) return note
187
+ return note.replace(/\b(?:this|the) workspace(?= is(?:n’t)? granted)/g, `the workspace it was pulled from (${workspace.slice(0, 8)})`)
188
+ }
189
+
190
+ /** A note's list of names cut to the first few: "…stay out: a, b, c, d, … (75)". */
191
+ export function shortList(note: string): string {
192
+ const at = note.lastIndexOf(': ')
193
+ if (at < 0) return note
194
+ const names = note.slice(at + 2).split(', ')
195
+ return names.length > 5 ? `${note.slice(0, at + 2)}${names.slice(0, 4).join(', ')}, … (${names.length})` : note
196
+ }
197
+
198
+ /**
199
+ * Each App the project describes (`apps/<slug>.json`, as a pull writes it)
200
+ * without its code in `apps/<slug>/` (a pull with `--no-app-code`, or one
201
+ * whose source the platform didn't hold): the file is what the App is and the
202
+ * Functions it may start. Said with the version the workspace deployed and
203
+ * the pull that brings that code. A pulled App whose code is here but whose
204
+ * package.json and bun.lock disagree is said too, with the command that
205
+ * settles it, before a render or a check finds it.
206
+ */
207
+ export function appCodeLines(root: string, snap: RemoteSnapshot): string[] {
208
+ return Object.entries(snap.items).flatMap(([ref, item]) => {
209
+ if (!ref.startsWith('app/')) return []
210
+ const slug = ref.slice('app/'.length)
211
+ const file = join(root, 'apps', `${slug}.json`)
212
+ if (!existsSync(file)) return []
213
+ // Its code is here: said only when it won't install as it is (package.json and bun.lock disagree).
214
+ if (existsSync(join(root, 'apps', slug))) {
215
+ const shadows = vendoredShadows(join(root, 'apps', slug))
216
+ // An earlier pull's folder, unchanged since: the pull that brings the deployed source again is the first fix.
217
+ const repull = shadows.length && earlierPullShadows(root, slug).length ? { repull: ref } : {}
218
+ return shadows.length ? [`${ref}: ${sdkShadowLine(`apps/${slug}`, shadows, repull)}`] : []
219
+ }
220
+ let version: string | undefined
221
+ try {
222
+ const doc = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
223
+ version = typeof doc.version === 'string' && doc.version ? doc.version : undefined
224
+ } catch {
225
+ version = undefined
226
+ }
227
+ version ??= item.version?.replace(/^v/, '')
228
+ return [
229
+ version
230
+ ? `${ref}: apps/${slug}.json is the App’s description only; its code (version ${version}) isn’t in the project (pulled with --no-app-code or by an earlier CLI, or the platform held no source for it). \`frontera local sync pull ${ref}\` brings it into apps/${slug}/, where \`frontera local dev\` builds it like any App folder`
231
+ : `${ref}: apps/${slug}.json is the App’s description only, and it was never deployed in the workspace, so the platform holds no code for it: \`frontera app init\` starts one`,
232
+ ]
233
+ })
234
+ }