@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
@@ -6,7 +6,10 @@ import {
6
6
  linkTypeCreateBody,
7
7
  linkTypeStructure,
8
8
  objectTypeCreateBody,
9
+ objectTypeSensitivity,
10
+ fieldSensitivityOf,
9
11
  actionBody,
12
+ declaredPropertyType,
10
13
  metricCreateBody,
11
14
  metricPatchBody,
12
15
  readBacking,
@@ -18,6 +21,8 @@ import {
18
21
  sharedPropertyPatchBody,
19
22
  type AuthoringIds,
20
23
  } from '../../blueprint/compile'
24
+ import { metricRuleProblems } from '../../blueprint/metric-rules'
25
+ import { actionRuleProblems, type ActionRuleProblems, type InheritedConflict } from '../../blueprint/action-rules'
21
26
  import { pickCurrentRevision } from '../../blueprint/dataset-revision'
22
27
  import {
23
28
  assertPrunesLeaveNoDanglingReference,
@@ -38,11 +43,19 @@ import {
38
43
  type DefinitionBundle,
39
44
  } from '../../blueprint/model'
40
45
  import { toFiles } from '../../blueprint/projection'
46
+ import { sourceSystemHint } from '../../local/local-blueprint-errors'
47
+ import { inheritedFromPull, unchangedSincePull } from '../../local/local-check-inherited'
48
+ import { localProject } from '../../local/local-project'
49
+ import { localRuntimeAt } from '../../local/local-runtime-target'
41
50
  import { renderDrift, renderPlan } from '../../blueprint/render'
42
51
  import {
43
52
  SEMANTIC_TYPE_TEMPLATE_NAMES,
44
53
  isSemanticTypeTemplate,
45
54
  scaffold,
55
+ ACTION_TEMPLATES,
56
+ isActionTemplate,
57
+ type ActionTemplate,
58
+ type ScaffoldOptions,
46
59
  type SemanticTypeTemplate,
47
60
  } from '../../blueprint/scaffold'
48
61
  import { pathFor, readTree, removeFromTree, writeTree } from '../../blueprint/tree'
@@ -244,12 +257,36 @@ function existingPaths(directory: string): string[] {
244
257
  }
245
258
  }
246
259
 
260
+ /**
261
+ * What a pulled project says about its Actions' semantic-type conflicts: which
262
+ * are the workspace's (`inheritedFromPull`: the Action and what the conflict
263
+ * rests on unchanged since the pull), and whether this run writes to the
264
+ * project's own local runtime, which takes such an Action as the workspace
265
+ * runs it. Anywhere else the service's own check decides (it refuses only a
266
+ * conflict a write adds), so `plan` is never stricter than the platform.
267
+ */
268
+ function inheritance(ctx: CommandContext): { conflict?: InheritedConflict; local: boolean } {
269
+ let root: string
270
+ try {
271
+ root = localProject(ctx.cwd).root
272
+ } catch {
273
+ return { local: false }
274
+ }
275
+ const unchanged = unchangedSincePull(root)
276
+ if (!unchanged.refs.size) return { local: false }
277
+ return { conflict: inheritedFromPull(unchanged), local: localRuntimeAt(ctx.cwd, ctx.apiUrl) !== null }
278
+ }
279
+
247
280
  async function computePlan(ctx: CommandContext): Promise<{
248
281
  plan: Plan
249
282
  files: AuthoredFile[]
250
283
  /** The draft the plan was computed against — `apply` indexes it for id resolution. */
251
284
  definition: DefinitionBundle
252
285
  drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>
286
+ /** The Action files' rule problems (`action-rules.ts`): what the draft would refuse, and what would not deploy. */
287
+ actionProblems: ActionRuleProblems
288
+ /** The Actions written to the local runtime as the workspace runs them (`inheritedActions`, on the local runtime only). */
289
+ inheritedWrites: ReadonlySet<string>
253
290
  }> {
254
291
  const client = api(ctx)
255
292
  const directory = root(ctx)
@@ -260,6 +297,15 @@ async function computePlan(ctx: CommandContext): Promise<{
260
297
  })
261
298
  }
262
299
  const files = readTree(directory)
300
+ // A measure's filters the draft would refuse: said by `plan`, and before `apply` writes anything.
301
+ const metricProblems = metricRuleProblems(files)
302
+ if (metricProblems.length) {
303
+ throw new CliError(`${metricProblems.length} metric problem${metricProblems.length === 1 ? '' : 's'} the draft would refuse, so nothing was planned or applied:\n${metricProblems.map((p) => ` ✕ ${p}`).join('\n')}`, {
304
+ code: 'VALIDATION_ERROR',
305
+ hint: 'fix each line in its file; a measure\'s `filters` is one condition, e.g. { property: state, op: ne, value: closed }, or several combined as { and: [ …conditions ] }',
306
+ details: { metrics: metricProblems },
307
+ })
308
+ }
263
309
  const { definition, revision } = await client.draft()
264
310
  const plan = withheldPrunes(diff(files, definition, revision), directory)
265
311
 
@@ -290,7 +336,87 @@ async function computePlan(ctx: CommandContext): Promise<{
290
336
  drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
291
337
  }
292
338
  }
293
- return { plan, files, definition, drift }
339
+ const inherits = inheritance(ctx)
340
+ const actionProblems = actionRuleProblems(files, inherits.conflict)
341
+ const inheritedWrites = new Set(inherits.local ? actionProblems.inheritedActions ?? [] : [])
342
+ return { plan, files, definition, drift, actionProblems: await withServiceActionChecks(client, plan, files, actionProblems, inheritedWrites), inheritedWrites }
343
+ }
344
+
345
+ /**
346
+ * The draft route's own checks of each Action this plan writes, run by the
347
+ * service with nothing written (`checkAction`), added to the file rules
348
+ * `actionRuleProblems` checks here. The file rules are a copy of what the
349
+ * route refuses, and a copy misses some: a rule the route has and the copy
350
+ * doesn't passes `plan` and is refused at apply.
351
+ *
352
+ * An Action that names something this same plan creates (an object type, a
353
+ * property added to one, another Action) cannot be checked before that is
354
+ * written; the service says so (`ACTION_REFERENCE_NOT_ON_DRAFT`) and the
355
+ * write checks it in order. A platform without the check is not asked.
356
+ */
357
+ async function withServiceActionChecks(
358
+ client: BlueprintAuthoringApi,
359
+ plan: Plan,
360
+ files: AuthoredFile[],
361
+ problems: ActionRuleProblems,
362
+ inheritedWrites: ReadonlySet<string> = new Set(),
363
+ ): Promise<ActionRuleProblems> {
364
+ const writing = plan.operations.filter((o) => o.kind === 'action' && o.operation !== 'delete' && !problems.refusedActions.includes(o.apiName))
365
+ if (writing.length === 0) return problems
366
+ const planned = new Set(plan.operations.filter((o) => o.operation !== 'delete').map((o) => `${o.kind}:${o.apiName}`))
367
+ const createdHere = (missing: { what?: unknown; name?: unknown } | undefined): boolean => {
368
+ const name = typeof missing?.name === 'string' ? missing.name : ''
369
+ if (missing?.what === 'object type') return planned.has(`object-type:${name}`)
370
+ if (missing?.what === 'property' || missing?.what === 'lifecycle transition') return planned.has(`object-type:${name.split('.')[0]}`)
371
+ if (missing?.what === 'action') return planned.has(`action:${name}`)
372
+ return false
373
+ }
374
+ const refused = [...problems.refused]
375
+ const refusedActions = [...problems.refusedActions]
376
+ for (const operation of writing) {
377
+ const file = files.find((f) => f.kind === 'action' && f.apiName === operation.apiName)
378
+ if (!file) continue
379
+ try {
380
+ await client.checkAction(actionBody(file), inheritedWrites.has(operation.apiName))
381
+ } catch (err) {
382
+ if (!(err instanceof CliError)) throw err
383
+ // An older platform (no such route, or one its key lane does not open), or nothing adopted yet: the write is the check.
384
+ if (err.code === 'NOT_FOUND' || err.code === 'FORBIDDEN') return problems
385
+ const details = err.details as { reason?: unknown; missing?: { what?: unknown; name?: unknown }; issues?: unknown } | undefined
386
+ // A name this plan creates is not a problem, but the fields the platform refused alongside it are.
387
+ const fieldIssues = Array.isArray(details?.issues) && details.issues.length > 0
388
+ if (details?.reason === 'ACTION_REFERENCE_NOT_ON_DRAFT' && createdHere(details.missing) && !fieldIssues) continue
389
+ if (err.code !== 'VALIDATION_ERROR' && err.code !== 'CONFLICT') throw err
390
+ // One line: `local dev` leaves each refused file off by these lines (`refusedByRules`).
391
+ refused.push(`${file.path}: ${err.message.replace(/\s*\n\s*/g, ' ')}`)
392
+ refusedActions.push(operation.apiName)
393
+ }
394
+ }
395
+ return { ...problems, refused, refusedActions }
396
+ }
397
+
398
+ /**
399
+ * The Action rule problems, said under the plan: a refusal as an error line,
400
+ * what would not deploy as a warning (`local dev` carries `warning:` lines).
401
+ */
402
+ function renderActionProblems(problems: ActionRuleProblems): string {
403
+ return [
404
+ ...problems.refused.map((p) => ` error: ${p}`),
405
+ ...problems.undeployable.map((p) => ` warning: ${p} (it applies, but its write path won't deploy)`),
406
+ ...(problems.inherited ?? []).map((p) => ` warning: ${p}`),
407
+ ].join('\n')
408
+ }
409
+
410
+ /** Refused before the first write: an Action the draft route would refuse, in a file this run applies. */
411
+ function assertActionsApplicable(problems: ActionRuleProblems, operations: ModelOperation[]): void {
412
+ const applying = new Set(operations.filter((o) => o.kind === 'action').map((o) => o.apiName))
413
+ if (!problems.refusedActions.some((name) => applying.has(name))) return
414
+ const refused = problems.refused
415
+ throw new CliError(`${refused.length} governed Action rule${refused.length === 1 ? ' is' : 's are'} broken, so nothing was applied:\n${refused.map((p) => ` ✕ ${p}`).join('\n')}`, {
416
+ code: 'VALIDATION_ERROR',
417
+ hint: 'fix each line in its file, then `frontera blueprint plan` checks it again without writing',
418
+ details: { refused, undeployable: problems.undeployable },
419
+ })
294
420
  }
295
421
 
296
422
  /**
@@ -333,7 +459,7 @@ export const blueprintPlan: Command = {
333
459
  examples: ['frontera blueprint plan', 'frontera blueprint plan --json'],
334
460
  },
335
461
  async run(ctx) {
336
- const { plan, files, definition, drift } = await computePlan(ctx)
462
+ const { plan, files, definition, drift, actionProblems } = await computePlan(ctx)
337
463
  // Every refusal `apply` will raise, raised here too, and for the reason `plan`
338
464
  // exists: a plan that cannot be applied is not one a reader should be shown as if
339
465
  // it could. Two independent checks — a prune that would dangle a reference, and an
@@ -343,10 +469,10 @@ export const blueprintPlan: Command = {
343
469
  assertPrunesLeaveNoDanglingReference(files, plan.prunes)
344
470
  orderForPrune(plan.prunes, liveCompensations(definition))
345
471
  }
346
- const body = [renderPlan(plan, { prune: ctx.flags.prune === true }), renderDrift(drift)]
472
+ const body = [renderPlan(plan, { prune: ctx.flags.prune === true }), renderDrift(drift), renderActionProblems(actionProblems)]
347
473
  .filter(Boolean)
348
474
  .join('\n')
349
- return { data: { ...plan, drift }, text: body }
475
+ return { data: { ...plan, drift, actionProblems }, text: body }
350
476
  },
351
477
  }
352
478
 
@@ -367,6 +493,8 @@ export async function applyOperation(
367
493
  file: AuthoredFile,
368
494
  revision: number,
369
495
  ids: AuthoringIds,
496
+ /** The Actions written as the workspace runs them (`inheritedWrites`). */
497
+ inheritedWrites: ReadonlySet<string> = new Set(),
370
498
  ): Promise<number> {
371
499
  const resolveDataset = (name: string) => resolveRevision(client, name)
372
500
 
@@ -419,6 +547,8 @@ export async function applyOperation(
419
547
  )
420
548
  }
421
549
  ids.sharedPropertyIdByApiName.set(operation.apiName, id)
550
+ const createdType = (file.document as { propertyType?: unknown }).propertyType
551
+ if (typeof createdType === 'string') ids.sharedPropertyTypeByApiName?.set(operation.apiName, createdType)
422
552
  return nextRevision(created, revision)
423
553
  }
424
554
  assertApplicable('shared-field', file, operation.liveDocument)
@@ -436,7 +566,7 @@ export async function applyOperation(
436
566
  await objectTypeCreateBody(file, revision, resolveDataset, ids),
437
567
  revision,
438
568
  )
439
- return nextRevision(created, revision)
569
+ return applyCalculatedFields(client, operation.apiName, file, nextRevision(created, revision))
440
570
  }
441
571
  // Refused BEFORE the first write, not after some of them. Everything this
442
572
  // executor can express is enumerated below; a difference outside that set has no
@@ -457,9 +587,10 @@ export async function applyOperation(
457
587
  current = nextRevision(updated, revision)
458
588
  }
459
589
  current = await applyObjectKeys(client, operation.apiName, file, operation.liveDocument, current)
460
- return applyPropertyPlan(
590
+ current = await applyPropertyPlan(
461
591
  client, operation.apiName, file, operation.liveDocument, current, ids,
462
592
  )
593
+ return applyCalculatedFields(client, operation.apiName, file, current)
463
594
  }
464
595
 
465
596
  if (operation.kind === 'link-type') {
@@ -484,9 +615,10 @@ export async function applyOperation(
484
615
  if (operation.operation !== 'create') {
485
616
  assertApplicable('action', { ...file, document: body }, operation.liveDocument)
486
617
  }
618
+ const inherited = inheritedWrites.has(operation.apiName)
487
619
  const result = operation.operation === 'create'
488
- ? await client.createAction(body, revision)
489
- : await client.updateAction(operation.apiName, body, revision)
620
+ ? await client.createAction(body, revision, inherited)
621
+ : await client.updateAction(operation.apiName, body, revision, inherited)
490
622
  return nextRevision(result, revision)
491
623
  }
492
624
 
@@ -598,10 +730,15 @@ export function assertApplicable(
598
730
  // The SAME predicate the plan used to decide this artifact changed. An equality
599
731
  // comparison here would disagree with it about key order and about the fields a file
600
732
  // omits, and refuse the run naming a field the plan never called changed.
601
- const unmappable = Object.keys(file.document).filter((field) =>
602
- !applicable.has(field) && !satisfies(file.document[field], live[field], field))
733
+ // An object type's `governance.sensitivity` has a route (the update's `sensitivity`);
734
+ // the rest of governance (ownership) has none, so only that part is compared here.
735
+ const comparable = kind === 'object-type' ? withoutSensitivity(file.document) : file.document
736
+ const against = kind === 'object-type' ? withoutSensitivity(live) : live
737
+ const unmappable = Object.keys(comparable).filter((field) =>
738
+ !applicable.has(field) && !satisfies(comparable[field], against[field], field))
603
739
  if (kind === 'object-type') unmappable.push(...unmappablePropertyFields(file.document, live))
604
740
  if (unmappable.length === 0) return
741
+ const absent = NOT_CARRIED[kind]
605
742
  throw new CliError(
606
743
  `${file.path}: ${unmappable.join(', ')} ${unmappable.length === 1 ? 'differs' : 'differ'} from the draft, `
607
744
  + 'and no authoring route can apply that.',
@@ -610,15 +747,40 @@ export function assertApplicable(
610
747
  hint: kind === 'metric' && unmappable.includes('objectType')
611
748
  ? 'A metric measures one object type for its lifetime. To move it, delete the file and '
612
749
  + '`apply --prune`, then write it again against the new object type.'
613
- : 'Revert those fields to match the draft, or change them in the Console. '
614
- + 'Applying would report a change that did not happen.',
750
+ : absent && unmappable.every((field) => absent.has(field))
751
+ ? `A ${kind} has no ${[...absent].join(' or ')}: the Blueprint keeps no such value for it. `
752
+ + `Remove ${unmappable.map((field) => `\`${field}\``).join(' and ')} from ${file.path} and apply again.`
753
+ : `Remove ${unmappable.length === 1 ? 'that field' : 'those fields'} from ${file.path}, or set `
754
+ + `${unmappable.length === 1 ? 'it' : 'them'} back to the draft's value, and apply again: a file that `
755
+ + 'leaves a field out keeps the draft\'s. Applying would report a change that did not happen.',
615
756
  },
616
757
  )
617
758
  }
618
759
 
760
+ /**
761
+ * Fields a kind's file names in other kinds' shapes but that the kind itself does
762
+ * not have: no route sets them, and no screen does either, so the only fix is to
763
+ * leave them out. A metric's draft entry has no lifecycle status or visibility
764
+ * (`blueprintMetricDefinitionSchemaV1` is strict), and the metric file schema no
765
+ * longer lists them; this names the fix for a file written before it changed.
766
+ */
767
+ const NOT_CARRIED: Partial<Record<ArtifactKind, Set<string>>> = {
768
+ metric: new Set(['status', 'visibility']),
769
+ }
770
+
771
+ /** A document with `governance.sensitivity` (and a top-level `sensitivity`) left out: the part of governance no route takes. */
772
+ export function withoutSensitivity(document: Record<string, unknown>): Record<string, unknown> {
773
+ const { sensitivity: _top, ...rest } = document
774
+ const governance = rest.governance
775
+ if (!governance || typeof governance !== 'object' || Array.isArray(governance)) return rest
776
+ const { sensitivity: _nested, ...others } = governance as Record<string, unknown>
777
+ return Object.keys(others).length ? { ...rest, governance: others } : (({ governance: _g, ...noGovernance }) => noGovernance)(rest)
778
+ }
779
+
619
780
  /**
620
781
  * Property fields the field commands do not carry. A property's `calculation`
621
- * (a location included) and its own `sensitivity` have no route from a file:
782
+ * can only be ADDED (a location, by `applyCalculatedFields`), not changed, and
783
+ * its own `sensitivity` has no route from a file:
622
784
  * applying one would report a classification or a formula that never changed.
623
785
  * Named as `properties.<apiName>.<field>`.
624
786
  */
@@ -629,13 +791,76 @@ function unmappablePropertyFields(document: Record<string, unknown>, live: Recor
629
791
  const liveProperties = Array.isArray(live.properties) ? live.properties as Array<Record<string, unknown>> : []
630
792
  const liveByName = new Map(liveProperties.map((property) => [String(property.apiName), property]))
631
793
  return declared.flatMap((property) => {
632
- const counterpart = liveByName.get(String(property.apiName)) ?? {}
794
+ const live = liveByName.get(String(property.apiName))
795
+ const counterpart = live ?? {}
633
796
  return UNAPPLICABLE_PROPERTY_FIELDS
797
+ // A location calculation not on the draft yet is added (`applyCalculatedFields`); one there can't be changed.
798
+ .filter((field) => !(field === 'calculation' && !live && isLocation(property.calculation)))
634
799
  .filter((field) => property[field] !== undefined && !satisfies(property[field], counterpart[field], field))
635
800
  .map((field) => `properties.${String(property.apiName)}.${field}`)
636
801
  })
637
802
  }
638
803
 
804
+ /** A location calculation as a file writes it: `{ kind: location, latitude, longitude }`, by apiName. */
805
+ function isLocation(value: unknown): value is { kind: 'location'; latitude: string; longitude: string } {
806
+ const v = value as { kind?: unknown; latitude?: unknown; longitude?: unknown } | null
807
+ return !!v && v.kind === 'location' && typeof v.latitude === 'string' && typeof v.longitude === 'string'
808
+ }
809
+
810
+ /**
811
+ * The type's calculated location fields that the draft does not have yet:
812
+ * each added by `add_field` with its calculation, naming its latitude and
813
+ * longitude fields by this draft's ids. The create route and the property
814
+ * plan leave them out, since they read no column.
815
+ */
816
+ async function applyCalculatedFields(client: BlueprintAuthoringApi, apiName: string, file: AuthoredFile, revision: number): Promise<number> {
817
+ const declared = Array.isArray(file.document.properties) ? (file.document.properties as Array<Record<string, unknown>>) : []
818
+ const calculated = declared.filter((p) => p.calculation !== undefined)
819
+ if (!calculated.length) return revision
820
+ const detail = await client.getObjectTypeDetail(apiName)
821
+ if (!detail?.id) return revision
822
+ const idByApiName = new Map(detail.properties.filter((p) => p.apiName && p.id).map((p) => [String(p.apiName), String(p.id)]))
823
+ let current = revision
824
+ for (const property of calculated) {
825
+ const name = String(property.apiName)
826
+ if (idByApiName.has(name)) continue
827
+ const calc = property.calculation
828
+ if (!isLocation(calc)) {
829
+ throw new CliError(`${file.path}: property "${name}" has a calculation that cannot be authored from a file.`, {
830
+ code: 'USAGE',
831
+ hint: 'A file adds a location: calculation: { kind: location, latitude: <field>, longitude: <field> }.',
832
+ })
833
+ }
834
+ const latitudePropertyId = idByApiName.get(calc.latitude)
835
+ const longitudePropertyId = idByApiName.get(calc.longitude)
836
+ if (!latitudePropertyId || !longitudePropertyId) {
837
+ throw new CliError(`${file.path}: "${name}" is a location from ${calc.latitude} and ${calc.longitude}, and ${!latitudePropertyId ? calc.latitude : calc.longitude} is not one of ${apiName}'s fields.`, {
838
+ code: 'USAGE',
839
+ hint: 'A location names two number fields of the same type, by apiName.',
840
+ })
841
+ }
842
+ const result = await client.draftCommand({
843
+ kind: 'add_field',
844
+ objectId: detail.id,
845
+ field: {
846
+ id: crypto.randomUUID(),
847
+ apiName: name,
848
+ displayName: String(property.displayName ?? name),
849
+ valueType: 'geopoint',
850
+ propertyType: 'attribute',
851
+ sourceColumn: '',
852
+ calculation: { kind: 'location', latitudePropertyId, longitudePropertyId },
853
+ required: false,
854
+ unique: false,
855
+ status: 'active',
856
+ visibility: 'normal',
857
+ },
858
+ }, current)
859
+ current = result.revision
860
+ }
861
+ return current
862
+ }
863
+
639
864
  /**
640
865
  * `primaryKey` and `title` — the two references a file states by property apiName.
641
866
  *
@@ -722,6 +947,9 @@ export function objectTypeMetadataChanges(
722
947
  if (Array.isArray(groups) && JSON.stringify(groups) !== JSON.stringify(live?.groups ?? [])) {
723
948
  patch.groups = groups
724
949
  }
950
+ // The classification: the update's top-level `sensitivity`, sent when it differs from the draft's.
951
+ const sensitivity = objectTypeSensitivity(document)
952
+ if (sensitivity && !satisfies(sensitivity, live ? objectTypeSensitivity(live) : undefined)) patch.sensitivity = sensitivity
725
953
  return patch
726
954
  }
727
955
 
@@ -768,6 +996,8 @@ function planProperties(
768
996
  live: Array<Record<string, unknown>>,
769
997
  backing: ReturnType<typeof readBacking>,
770
998
  idByApiName: Map<string, string>,
999
+ /** The live object type's classification: a field at it takes it, rather than holding its own. */
1000
+ liveObjectSensitivity?: unknown,
771
1001
  ): PropertyPlan {
772
1002
  const declared = Array.isArray(file.document.properties)
773
1003
  ? (file.document.properties as Array<Record<string, unknown>>)
@@ -781,7 +1011,8 @@ function planProperties(
781
1011
  const name = String(property.apiName)
782
1012
  const counterpart = liveByName.get(name)
783
1013
  if (!counterpart) {
784
- plan.adds.push(property)
1014
+ // Added after the plan, once its latitude and longitude fields are there (`applyCalculatedFields`).
1015
+ if (!property.calculation) plan.adds.push(property)
785
1016
  continue
786
1017
  }
787
1018
  const schemaPatch: Record<string, unknown> = {}
@@ -836,6 +1067,16 @@ function planProperties(
836
1067
  else metadataPatch.semanticType = wanted
837
1068
  }
838
1069
  }
1070
+ // The field's own classification, against what it holds now. A read gives every field its
1071
+ // effective one, so a value at the object type's is "takes the object's" on both sides; the
1072
+ // file's absence (or `null`, or its object's own value) is that too, and travels as `null`.
1073
+ // Not on a location, which takes its latitude's and longitude's.
1074
+ if (!property.calculation) {
1075
+ const wanted = fieldSensitivityOf(property, objectTypeSensitivity(file.document)).sensitivity ?? null
1076
+ const liveClass = liveObjectSensitivity && typeof liveObjectSensitivity === 'object' ? (liveObjectSensitivity as { classification?: unknown }).classification : undefined
1077
+ const current = typeof counterpart.sensitivity === 'string' && counterpart.sensitivity !== liveClass ? counterpart.sensitivity : null
1078
+ if (wanted !== current) metadataPatch.sensitivity = wanted
1079
+ }
839
1080
  const id = idByApiName.get(name)
840
1081
  // A declared property with no live id is either brand new (handled above) or the
841
1082
  // draft moved; skipping it quietly is how a change goes missing.
@@ -881,7 +1122,7 @@ async function applyPropertyPlan(
881
1122
  const liveProperties = Array.isArray(liveDocument?.properties)
882
1123
  ? (liveDocument.properties as Array<Record<string, unknown>>)
883
1124
  : []
884
- const plan = planProperties(file, liveProperties, backing, idByApiName)
1125
+ const plan = planProperties(file, liveProperties, backing, idByApiName, objectTypeSensitivity(liveDocument ?? {}))
885
1126
 
886
1127
  // Every add needs a column, and the service offers no name-match fallback. Checked
887
1128
  // for ALL of them before the first write, so a partial run cannot start.
@@ -940,7 +1181,7 @@ async function applyPropertyPlan(
940
1181
  id: crypto.randomUUID(),
941
1182
  apiName: name,
942
1183
  displayName: String(property.displayName ?? name),
943
- propertyType: (property.propertyType as string | undefined) ?? 'attribute',
1184
+ propertyType: declaredPropertyType(property, ids.sharedPropertyTypeByApiName),
944
1185
  valueType: String(property.valueType ?? 'string'),
945
1186
  sourceColumn: column,
946
1187
  ...(sourceField === undefined || sourceField === null ? {} : { sourceField }),
@@ -955,8 +1196,10 @@ async function applyPropertyPlan(
955
1196
  // Same collapse: `add_field`'s uuid is not nullable, and a field being
956
1197
  // created has no meaning to detach from.
957
1198
  ...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
1199
+ // Its own classification, when the file gives one other than the object type's.
1200
+ ...fieldSensitivityOf(property, objectTypeSensitivity(file.document)),
958
1201
  },
959
- }, current)
1202
+ }, current).catch((err) => { throw stewardRefusal(err, file, name, fieldSensitivityOf(property, objectTypeSensitivity(file.document)).sensitivity !== undefined) })
960
1203
  current = result.revision
961
1204
  }
962
1205
  for (const change of plan.schemaChanges) {
@@ -984,7 +1227,7 @@ async function applyPropertyPlan(
984
1227
  ...semanticTypeOnWire(file, change.apiName, rest, ids),
985
1228
  ...(sharedPropertyId === undefined ? {} : { sharedPropertyId }),
986
1229
  },
987
- }, current)
1230
+ }, current).catch((err) => { throw stewardRefusal(err, file, change.apiName, 'sensitivity' in rest) })
988
1231
  current = result.revision
989
1232
  }
990
1233
  for (const removal of plan.removes) {
@@ -996,6 +1239,20 @@ async function applyPropertyPlan(
996
1239
  return current
997
1240
  }
998
1241
 
1242
+ /**
1243
+ * A refusal of a command that classifies a field, said as what it needs: a
1244
+ * field's classification is a Blueprint Steward's to set, as its object
1245
+ * type's is, and a key without that role is refused for it alone.
1246
+ */
1247
+ function stewardRefusal(err: unknown, file: AuthoredFile, field: string, classifies: boolean): unknown {
1248
+ if (!classifies || !(err instanceof CliError) || (err.code !== 'FORBIDDEN' && err.code !== 'UNAUTHORIZED')) return err
1249
+ return new CliError(`${file.path}: field "${field}" sets its own classification (sensitivity), which only a Blueprint Steward may do: ${err.message}`, {
1250
+ code: err.code,
1251
+ hint: 'apply it with a credential of a Blueprint Steward, or leave `sensitivity` out so the field takes its object type\'s',
1252
+ cause: err,
1253
+ })
1254
+ }
1255
+
999
1256
  /**
1000
1257
  * The file's `semanticType` name, replaced by the wire's `semanticTypeId`.
1001
1258
  *
@@ -1068,7 +1325,9 @@ export const blueprintApply: Command = {
1068
1325
  async run(ctx) {
1069
1326
  const client = api(ctx)
1070
1327
  const prune = ctx.flags.prune === true
1071
- const { plan, files, definition, drift } = await computePlan(ctx)
1328
+ const { plan, files, definition, drift, actionProblems, inheritedWrites } = await computePlan(ctx)
1329
+ assertActionsApplicable(actionProblems, plan.operations)
1330
+ const actionWarnings = renderActionProblems({ refused: [], undeployable: actionProblems.undeployable, refusedActions: [], ...(actionProblems.inherited ? { inherited: actionProblems.inherited } : {}) })
1072
1331
 
1073
1332
  // BEFORE the first write, not before the prune loop: the operations land first,
1074
1333
  // so discovering the contradiction between them and the prunes at the end would
@@ -1076,7 +1335,7 @@ export const blueprintApply: Command = {
1076
1335
  if (prune) assertPrunesLeaveNoDanglingReference(files, plan.prunes)
1077
1336
 
1078
1337
  if (isEmpty(plan)) {
1079
- return { data: { ...plan, applied: [] }, text: renderPlan(plan) }
1338
+ return { data: { ...plan, applied: [], actionProblems }, text: [renderPlan(plan), actionWarnings].filter(Boolean).join('\n') }
1080
1339
  }
1081
1340
 
1082
1341
  // Drift is REPORTED, and blocks only the artifacts it is about. It used to throw
@@ -1117,6 +1376,8 @@ export const blueprintApply: Command = {
1117
1376
 
1118
1377
  const byKey = new Map(files.map((file) => [`${file.kind}:${file.apiName}`, file]))
1119
1378
  const applied: string[] = []
1379
+ // The step being written when a write fails: said, so a caller can tell which file was refused.
1380
+ let current: string | null = null
1120
1381
  try {
1121
1382
  // Seeded from the revision the PLAN was computed against, then advanced by what
1122
1383
  // each write returns. Never re-read — see `nextRevision`.
@@ -1129,18 +1390,27 @@ export const blueprintApply: Command = {
1129
1390
  const ids: AuthoringIds = {
1130
1391
  sharedPropertyIdByApiName: live.sharedPropertyIdByApiName,
1131
1392
  semanticTypeIdByApiName: live.semanticTypeIdByApiName,
1393
+ sharedPropertyTypeByApiName: new Map(
1394
+ (definition.sharedProperties ?? []).flatMap((shared) => {
1395
+ const type = (shared as { propertyType?: unknown }).propertyType
1396
+ return typeof type === 'string' ? [[shared.apiName, type] as const] : []
1397
+ }),
1398
+ ),
1132
1399
  }
1133
1400
  for (const operation of ordered) {
1134
1401
  const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
1135
- revision = await applyOperation(client, operation, file, revision, ids)
1136
- applied.push(`${operation.operation} ${operation.kind} ${operation.apiName}`)
1402
+ current = `${operation.operation} ${operation.kind} ${operation.apiName}`
1403
+ revision = await applyOperation(client, operation, file, revision, ids, inheritedWrites)
1404
+ applied.push(current)
1137
1405
  }
1138
1406
  if (prune) {
1139
1407
  for (const operation of orderedPrunes) {
1408
+ current = `delete ${operation.kind} ${operation.apiName}`
1140
1409
  revision = await pruneOperation(client, operation, revision)
1141
- applied.push(`delete ${operation.kind} ${operation.apiName}`)
1410
+ applied.push(current)
1142
1411
  }
1143
1412
  }
1413
+ current = null
1144
1414
  } catch (err) {
1145
1415
  // What landed is as important as what failed: the draft is shared, and a
1146
1416
  // partial run that does not say where it stopped cannot be resumed safely.
@@ -1152,8 +1422,9 @@ export const blueprintApply: Command = {
1152
1422
  // failure — a draft that moved — handed out for every failure, so a
1153
1423
  // validation refusal read as "Validation failed / re-plan", which no
1154
1424
  // amount of re-planning fixes.
1155
- const inner = err instanceof CliError ? err.hint : undefined
1156
- throw new CliError(`${done}${(err as Error).message}`, {
1425
+ const inner = sourceSystemHint(`${(err as Error).message}\n${err instanceof CliError ? err.hint ?? '' : ''}`) ?? (err instanceof CliError ? err.hint : undefined)
1426
+ const stopped = current ? `Stopped at: ${current}\n` : ''
1427
+ throw new CliError(`${done}${stopped}${(err as Error).message}`, {
1157
1428
  code: err instanceof CliError ? err.code : 'FAILURE',
1158
1429
  hint: inner ?? 'frontera blueprint plan — re-plan against the current draft, then re-apply.',
1159
1430
  })
@@ -1161,9 +1432,10 @@ export const blueprintApply: Command = {
1161
1432
 
1162
1433
  const revision = await client.revision()
1163
1434
  return {
1164
- data: { applied, revision },
1435
+ data: { applied, revision, actionProblems },
1165
1436
  text: `${renderPlan(plan, { prune })}\n\nApplied ${applied.length} change`
1166
- + `${applied.length === 1 ? '' : 's'}. Draft is at revision ${revision}.`,
1437
+ + `${applied.length === 1 ? '' : 's'}. Draft is at revision ${revision}.`
1438
+ + (actionWarnings ? `\n${actionWarnings}` : ''),
1167
1439
  }
1168
1440
  },
1169
1441
  }
@@ -1372,7 +1644,7 @@ export const blueprintNew: Command = {
1372
1644
  { name: 'kind', required: true, description: `One of: ${ARTIFACT_KINDS.join(', ')}` },
1373
1645
  { name: 'apiName', required: true, description: 'The artifact’s API name' },
1374
1646
  ],
1375
- flags: { dir: 'string', template: 'string' },
1647
+ flags: { dir: 'string', template: 'string', 'object-type': 'string' },
1376
1648
  offline: true,
1377
1649
  summary: 'Scaffold an artifact file that validates as written',
1378
1650
  examples: [
@@ -1380,6 +1652,8 @@ export const blueprintNew: Command = {
1380
1652
  'frontera blueprint new link-type customerHoldsPolicy',
1381
1653
  'frontera blueprint new shared-field accountCode',
1382
1654
  'frontera blueprint new semantic-type completionRate --template percentage',
1655
+ 'frontera blueprint new action assignTicket --object-type Ticket',
1656
+ 'frontera blueprint new action logTicket --template create --object-type Ticket',
1383
1657
  ],
1384
1658
  },
1385
1659
  async run(ctx) {
@@ -1403,8 +1677,8 @@ export const blueprintNew: Command = {
1403
1677
  hint: `${describeApiNameRule(kind)} — and it becomes the file name, so nothing else fits.`,
1404
1678
  })
1405
1679
  }
1406
- const template = semanticTypeTemplate(ctx.flags.template, kind)
1407
1680
  const directory = root(ctx)
1681
+ const options = scaffoldOptions(ctx, kind, directory)
1408
1682
  const relative = pathFor(kind, apiName)
1409
1683
  if (existsSync(resolve(directory, relative))) {
1410
1684
  throw new CliError(`${relative} already exists.`, {
@@ -1416,7 +1690,7 @@ export const blueprintNew: Command = {
1416
1690
  kind,
1417
1691
  apiName,
1418
1692
  path: relative,
1419
- document: scaffold(kind, apiName, template === undefined ? {} : { template }),
1693
+ document: scaffold(kind, apiName, options),
1420
1694
  }
1421
1695
  writeTree(directory, [file])
1422
1696
  return {
@@ -1426,6 +1700,32 @@ export const blueprintNew: Command = {
1426
1700
  },
1427
1701
  }
1428
1702
 
1703
+ /**
1704
+ * `--template` and `--object-type`, read for the kind they apply to. An Action's
1705
+ * subject type, when the tree holds it, also gives the key input its name.
1706
+ */
1707
+ function scaffoldOptions(ctx: CommandContext, kind: ArtifactKind, directory: string): ScaffoldOptions {
1708
+ const objectType = typeof ctx.flags['object-type'] === 'string' ? ctx.flags['object-type'] : undefined
1709
+ if (objectType !== undefined && kind !== 'action') {
1710
+ throw new CliError(`--object-type applies to action, not ${kind}.`, { code: 'USAGE', hint: 'frontera blueprint new action <apiName> --object-type <ObjectType>' })
1711
+ }
1712
+ if (kind !== 'action') {
1713
+ const template = semanticTypeTemplate(ctx.flags.template, kind)
1714
+ return template === undefined ? {} : { template }
1715
+ }
1716
+ const declared = ctx.flags.template
1717
+ if (declared !== undefined && !isActionTemplate(String(declared))) {
1718
+ throw new CliError(`Unknown action template "${String(declared)}".`, { code: 'USAGE', hint: `One of: ${ACTION_TEMPLATES.join(', ')} (edit changes a record; create makes one).` })
1719
+ }
1720
+ const options: ScaffoldOptions = { ...(declared === undefined ? {} : { actionTemplate: String(declared) as ActionTemplate }) }
1721
+ if (objectType) {
1722
+ options.objectType = objectType
1723
+ const type = existsSync(directory) ? readTree(directory).find((f) => f.kind === 'object-type' && f.apiName === objectType) : undefined
1724
+ if (typeof type?.document.primaryKey === 'string') options.primaryKey = type.document.primaryKey
1725
+ }
1726
+ return options
1727
+ }
1728
+
1429
1729
  /**
1430
1730
  * `--template`, validated where it is read.
1431
1731
  *