@frontera-sdk/cli 1.52.44 → 1.52.46

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 (110) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/kit/vendor.ts +62 -48
  33. package/src/commands/local/check.ts +57 -11
  34. package/src/commands/local/data.ts +75 -7
  35. package/src/commands/local/dev.ts +77 -9
  36. package/src/commands/local/embeddings.ts +74 -0
  37. package/src/commands/local/index-commands.ts +133 -11
  38. package/src/commands/local/init.ts +22 -2
  39. package/src/commands/local/new.ts +78 -0
  40. package/src/commands/local/publish.ts +34 -0
  41. package/src/commands/local/steps.ts +105 -0
  42. package/src/commands/local/sync-request.ts +69 -23
  43. package/src/commands/local/sync.ts +2 -0
  44. package/src/commands/media/index-commands.ts +58 -3
  45. package/src/commands/registry.ts +11 -3
  46. package/src/commands/trace/index-commands.ts +56 -0
  47. package/src/commands/workspace/index-commands.ts +14 -7
  48. package/src/config.ts +4 -1
  49. package/src/dev-broker.ts +15 -1
  50. package/src/device-login.ts +143 -0
  51. package/src/exit.ts +36 -8
  52. package/src/flag-help.ts +31 -0
  53. package/src/forge/claude-adapter.ts +17 -4
  54. package/src/forge/permission-rules.ts +158 -0
  55. package/src/forge/touches.ts +7 -6
  56. package/src/function-outside-imports.ts +13 -2
  57. package/src/function-source.ts +90 -0
  58. package/src/harness.ts +4 -4
  59. package/src/key-reach.ts +64 -0
  60. package/src/local/local-actions.ts +15 -3
  61. package/src/local/local-apply-error.ts +26 -0
  62. package/src/local/local-apps.ts +9 -3
  63. package/src/local/local-blueprint-schemas.ts +15 -1
  64. package/src/local/local-confidential.ts +81 -0
  65. package/src/local/local-data-load.ts +130 -0
  66. package/src/local/local-dev-references.ts +24 -4
  67. package/src/local/local-embedding-setup.ts +62 -0
  68. package/src/local/local-jobs.ts +108 -0
  69. package/src/local/local-keys.ts +5 -3
  70. package/src/local/local-new.ts +148 -0
  71. package/src/local/local-org-settings.ts +1 -1
  72. package/src/local/local-person-steps.ts +340 -0
  73. package/src/local/local-personas.ts +16 -5
  74. package/src/local/local-portability.ts +22 -1
  75. package/src/local/local-publish.ts +26 -0
  76. package/src/local/local-request-evidence.ts +35 -6
  77. package/src/local/local-runtime-history.ts +4 -3
  78. package/src/local/local-said-once.ts +51 -0
  79. package/src/local/local-scaffold.ts +40 -2
  80. package/src/local/local-schemas.ts +60 -11
  81. package/src/local/local-seed.ts +1 -1
  82. package/src/local/local-stand-ins.ts +44 -0
  83. package/src/local/local-studio-server.ts +13 -7
  84. package/src/local/local-supervisor.ts +22 -0
  85. package/src/local/local-sync-models.ts +5 -2
  86. package/src/local/local-sync-names.ts +3 -0
  87. package/src/local/local-sync-portable.ts +64 -15
  88. package/src/local/local-sync-precheck.ts +30 -1
  89. package/src/local/local-sync-push.ts +81 -8
  90. package/src/local/local-sync-remote.ts +10 -7
  91. package/src/local/local-test-models.ts +173 -0
  92. package/src/local/local-test-runner.ts +232 -25
  93. package/src/local/local-test-scope.ts +174 -0
  94. package/src/local/local-ungranted-reads.ts +55 -0
  95. package/src/local/local-writers.ts +171 -0
  96. package/src/main.ts +75 -17
  97. package/src/mcp/browser/ax-snapshot.ts +13 -1
  98. package/src/mcp/browser/page-scripts.ts +1 -0
  99. package/src/mcp/mcp-conversations.ts +171 -0
  100. package/src/mcp/mcp-server.ts +4 -1
  101. package/src/mcp/mcp-traces.ts +7 -2
  102. package/src/mcp/tools/agent-tools.ts +94 -2
  103. package/src/mcp/tools/project-tools.ts +149 -13
  104. package/src/mcp/tools/test-tools.ts +64 -13
  105. package/src/output.ts +22 -7
  106. package/src/pack.ts +16 -0
  107. package/src/project-context.ts +18 -5
  108. package/src/project.ts +4 -0
  109. package/src/remote-guard.ts +52 -0
  110. package/src/vendor/kit-assets.json +67 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.44",
3
+ "version": "1.52.46",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.52.44",
44
- "@frontera-sdk/functions": "1.52.44",
43
+ "@frontera-sdk/core": "1.52.46",
44
+ "@frontera-sdk/functions": "1.52.46",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.52.44",
52
+ "@frontera-sdk/forge-contracts": "1.52.46",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -144,10 +144,12 @@ export class AppsApi {
144
144
  slug: string,
145
145
  displayName: string,
146
146
  appId?: string | null,
147
+ /** Used only when this creates the App: its icon from `frontera.config.json` (X6). */
148
+ opts: { icon?: string } = {},
147
149
  ): Promise<{ id: string; slug: string; created: boolean }> {
148
150
  return this.client.request<{ id: string; slug: string; created: boolean }>('/v1/platform-apps/ensure', {
149
151
  method: 'POST',
150
- body: appId ? { slug, displayName, appId } : { slug, displayName },
152
+ body: { ...(appId ? { slug, displayName, appId } : { slug, displayName }), ...(opts.icon ? { icon: opts.icon } : {}) },
151
153
  })
152
154
  }
153
155
 
@@ -29,6 +29,19 @@ export interface AutomationVersionSummary {
29
29
  }
30
30
 
31
31
  /** One agent↔automation binding, as the bindings routes return it. */
32
+ /** A Custom App bound to a Function: it may start the Function as the person using it. */
33
+ export interface AppFunctionBinding {
34
+ appId: string
35
+ appSlug: string
36
+ appDisplayName: string
37
+ enabled: boolean
38
+ createdBy: string
39
+ createdAt: string
40
+ /** Whether it would start anything now: false while the Function has nothing live or the binding is off. */
41
+ projects?: boolean
42
+ inertReason?: 'no_live_version' | 'disabled' | null
43
+ }
44
+
32
45
  export interface AgentAutomationBinding {
33
46
  agentId: string
34
47
  agentSlug: string
@@ -387,6 +400,25 @@ export class AutomationApi {
387
400
  )
388
401
  }
389
402
 
403
+ /** Which Custom Apps may start this Function (X6): the App lane's own permission, beside the agent bindings. */
404
+ appBindings(slug: string): Promise<AppFunctionBinding[]> {
405
+ return this.client.request<AppFunctionBinding[]>(`/v1/functions/${encodeURIComponent(slug)}/app-bindings`)
406
+ }
407
+
408
+ bindApp(slug: string, appSlug: string, options: { enabled?: boolean } = {}): Promise<AppFunctionBinding> {
409
+ return this.client.request<AppFunctionBinding>(
410
+ `/v1/functions/${encodeURIComponent(slug)}/app-bindings/${encodeURIComponent(appSlug)}`,
411
+ { method: 'PUT', body: options },
412
+ )
413
+ }
414
+
415
+ unbindApp(slug: string, appSlug: string): Promise<{ unbound: boolean }> {
416
+ return this.client.request<{ unbound: boolean }>(
417
+ `/v1/functions/${encodeURIComponent(slug)}/app-bindings/${encodeURIComponent(appSlug)}`,
418
+ { method: 'DELETE' },
419
+ )
420
+ }
421
+
390
422
  setEnabled(slug: string, enabled: boolean): Promise<{ slug: string; enabled: boolean }> {
391
423
  return this.client.request<{ slug: string; enabled: boolean }>(
392
424
  `/v1/functions/${encodeURIComponent(slug)}/enabled`,
@@ -66,7 +66,7 @@ export function credentialFailure(
66
66
  return person
67
67
  ? new CliError('The session token was refused — it has expired or been signed out.', {
68
68
  code: 'UNAUTHORIZED',
69
- hint: 'sign in again, then set FRONTERA_TOKEN to the new session token',
69
+ hint: 'sign in again with `frontera auth login --browser`',
70
70
  })
71
71
  : new CliError(
72
72
  'The credential was refused. An organization key that has been revoked or has '
@@ -478,6 +478,30 @@ export class GovernedActionApi {
478
478
  )
479
479
  }
480
480
 
481
+ /** The Action's definition id: on the draft first, where an Action lives before it has ever published, else the release. */
482
+ async actionDefinitionId(apiName: string): Promise<string | null> {
483
+ const drafted = (await this.listDraftActions().catch(() => [])).find((a) => a.apiName === apiName)
484
+ if (typeof drafted?.id === 'string') return drafted.id
485
+ const published = await this.publishedAction(apiName).catch(() => null)
486
+ return (published?.definition as { id?: string } | undefined)?.id ?? null
487
+ }
488
+
489
+ /** The Action's current deployment decision, or null when it has none. */
490
+ async currentDeployment(actionDefinitionId: string): Promise<{ stateId: string; state: string; generation: number; bindingRevisionId: string | null } | null> {
491
+ const page = await this.call<{ entries?: Array<{ stateId: string; state: string; generation: number; bindingRevisionId: string | null }> }>(
492
+ `/v1/blueprint/governed-actions/admin/deployments?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}`,
493
+ )
494
+ return page?.entries?.[0] ?? null
495
+ }
496
+
497
+ /** Stand a deployment decision down: one that was never bound, or a draining one (the service refuses the rest). */
498
+ retireDeployment(stateId: string, expectedGeneration: number, reason: string): Promise<{ stateId?: string; state?: string; generation?: number }> {
499
+ return this.call(`/v1/blueprint/governed-actions/admin/deployments/${encodeURIComponent(stateId)}/retire`, {
500
+ method: 'POST',
501
+ body: { expectedGeneration, reason },
502
+ })
503
+ }
504
+
481
505
  /**
482
506
  * Bindings at their newest revision, each with `deployment`: the CURRENT
483
507
  * state of the Action it binds (null when the Action has none). Every
@@ -29,6 +29,15 @@ export interface MediaSet {
29
29
  itemCount?: number
30
30
  }
31
31
 
32
+ /** A file staged for an Action: its preallocated item id is the Action's upload input. */
33
+ export interface StagedUpload {
34
+ mediaItemId: string
35
+ path: string
36
+ mimeType: string
37
+ sizeBytes: number
38
+ state: string
39
+ }
40
+
32
41
  export interface MediaItem {
33
42
  mediaItemId: string
34
43
  path: string
@@ -140,6 +149,21 @@ export class MediaApi {
140
149
  return (await this.call<{ item: MediaItem }>(`${this.set(mediaSetId)}/items`, { form })).item
141
150
  }
142
151
 
152
+ /**
153
+ * One file staged for an Action to attach (L6): nothing is in the set until
154
+ * an Action request that carries the returned id as its upload input applies.
155
+ */
156
+ async stage(
157
+ mediaSetId: string,
158
+ file: { bytes: Uint8Array; filename: string; mime: string; logicalPath: string; markings?: string },
159
+ ): Promise<StagedUpload> {
160
+ const form = new FormData()
161
+ form.set('file', new File([new Blob([file.bytes.buffer as ArrayBuffer])], file.filename, { type: file.mime }))
162
+ form.set('logicalPath', file.logicalPath)
163
+ if (file.markings) form.set('markings', file.markings)
164
+ return (await this.call<{ upload: StagedUpload }>(`${this.set(mediaSetId)}/uploads`, { form })).upload
165
+ }
166
+
143
167
  extraction(mediaSetId: string): Promise<ExtractionStatus> {
144
168
  return this.call(`${this.set(mediaSetId)}/extraction`)
145
169
  }
@@ -359,6 +359,19 @@ export class PlatformApi {
359
359
  })
360
360
  }
361
361
 
362
+ /** The Agent's behavior settings as its draft and live version hold them (`operating.json`'s source). */
363
+ agentOperatingConfig(id: string) {
364
+ return this.client.request<{ draft?: unknown; live?: unknown } | null>(`/v1/config/agents/${encodeURIComponent(id)}/operating`)
365
+ }
366
+
367
+ /** Stage the Agent's behavior settings on its draft; `null` takes them all away. They take effect when it is published. */
368
+ stageAgentOperatingConfig(id: string, operatingConfig: Record<string, unknown> | null) {
369
+ return this.client.request<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/operating`, {
370
+ method: 'PUT',
371
+ body: { operatingConfig },
372
+ })
373
+ }
374
+
362
375
  discardAgentDraft(id: string) {
363
376
  return this.client.request<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/draft-v2`, {
364
377
  method: 'DELETE',
@@ -28,6 +28,8 @@ import { configDir, type Env } from './paths'
28
28
 
29
29
  export interface Binding {
30
30
  profile: string
31
+ /** The organization profile bound beside it for organization-scoped commands (X3). */
32
+ organizationProfile?: string
31
33
  boundAt: string
32
34
  }
33
35
 
@@ -59,9 +61,9 @@ function read(env: Env): BindingFile {
59
61
  }
60
62
  }
61
63
 
62
- export function recordBinding(root: string, profile: string, env: Env = process.env): void {
64
+ export function recordBinding(root: string, profile: string, env: Env = process.env, organizationProfile?: string): void {
63
65
  const file = read(env)
64
- file.roots[key(root)] = { profile, boundAt: new Date().toISOString() }
66
+ file.roots[key(root)] = { profile, ...(organizationProfile ? { organizationProfile } : {}), boundAt: new Date().toISOString() }
65
67
  writeFileAtomic(bindingsPath(env), `${JSON.stringify(file, null, 2)}\n`, 0o600)
66
68
  }
67
69
 
@@ -85,3 +87,8 @@ export function forgetBinding(root: string, env: Env = process.env): void {
85
87
  export function isTrustedBinding(root: string, profile: string, env: Env = process.env): boolean {
86
88
  return bindingFor(root, env)?.profile === profile
87
89
  }
90
+
91
+ /** This machine bound `organizationProfile` beside the root's profile (`auth use --org-profile`, X3). */
92
+ export function isTrustedOrganizationBinding(root: string, organizationProfile: string, env: Env = process.env): boolean {
93
+ return bindingFor(root, env)?.organizationProfile === organizationProfile
94
+ }
@@ -446,6 +446,8 @@ export function declaredPropertyType(property: Record<string, unknown>, sharedPr
446
446
  }
447
447
 
448
448
  export function actionWithoutMintedIds(document: Record<string, unknown>): Record<string, unknown> {
449
+ // `attachment` is the project's deployment choice for the local runtime (L6), not part of the Action's contract.
450
+ const { attachment: _deployment, ...contract } = document
449
451
  const drop = (value: unknown): unknown => {
450
452
  if (Array.isArray(value)) return value.map(drop)
451
453
  if (!value || typeof value !== 'object') return value
@@ -459,7 +461,7 @@ export function actionWithoutMintedIds(document: Record<string, unknown>): Recor
459
461
  }
460
462
  return out
461
463
  }
462
- return drop(document) as Record<string, unknown>
464
+ return drop(contract) as Record<string, unknown>
463
465
  }
464
466
 
465
467
  export function actionBody(file: AuthoredFile): Record<string, unknown> {
@@ -31,7 +31,22 @@ export interface ObjectTypeShape {
31
31
  id: string
32
32
  apiName: string
33
33
  primaryKeyPropertyId: string | null
34
- properties: Array<{ id?: string; apiName?: string }>
34
+ properties: Array<{ id?: string; apiName?: string; valueType?: string; mediaSetId?: string | null }>
35
+ }
36
+
37
+ /**
38
+ * What an attachment Action fills (L6): the file arrives in `uploadInput` (a
39
+ * staged upload's id, or a run's `{ fileId }`), and the Media write gate puts
40
+ * it in `mediaSetId` and fills `property` (a media reference), `itemIdProperty`
41
+ * (a string that keeps the item's id), or both. Stated at deploy, because an
42
+ * Action's own contract has no input kind that says "this is a file".
43
+ */
44
+ export interface MediaAttachmentChoice {
45
+ property?: string
46
+ itemIdProperty?: string
47
+ uploadInput: string
48
+ /** Defaults to the media reference property's own set. */
49
+ mediaSetId?: string
35
50
  }
36
51
 
37
52
  export interface OntologyEditPlan {
@@ -45,6 +60,7 @@ export interface OntologyEditPlan {
45
60
  set: Record<string, string>
46
61
  pkParameter: string
47
62
  expectedVersionParameter?: string
63
+ mediaAttachment?: { propertyApiName?: string; itemIdProperty?: string; mediaSetId: string; uploadParameter: string }
48
64
  impactBounds: { maxAffectedRows: number; maxTouchedRows: number }
49
65
  capabilities: Record<string, unknown>
50
66
  }
@@ -87,6 +103,7 @@ const OP_FOR_EFFECT: Readonly<Record<string, OntologyEditPlan['op']>> = {
87
103
  export function deriveOntologyEditPlan(
88
104
  action: PublishedActionDefinition,
89
105
  objectType: ObjectTypeShape,
106
+ attachment?: MediaAttachmentChoice,
90
107
  ): OntologyEditPlan {
91
108
  const op = OP_FOR_EFFECT[action.effect.kind]
92
109
  if (!op) {
@@ -132,8 +149,13 @@ export function deriveOntologyEditPlan(
132
149
  }
133
150
  }
134
151
 
135
- // The plan must map at least one parameter, so constants alone cannot be deployed.
136
- if (op !== 'delete' && Object.keys(set).length === 0) {
152
+ const mediaAttachment = attachment ? attachmentOf(action, objectType, attachment, set) : undefined
153
+ if (mediaAttachment && op === 'delete') {
154
+ throw new PlanDerivationError(`Action "${action.apiName}" deletes its record, so it has nothing to attach a file to.`, 'Attach the file with an Action that creates or updates the record.')
155
+ }
156
+
157
+ // The plan must map at least one parameter (or attach a file), so constants alone cannot be deployed.
158
+ if (op !== 'delete' && Object.keys(set).length === 0 && !mediaAttachment) {
137
159
  throw new PlanDerivationError(
138
160
  constants.length
139
161
  ? `Action "${action.apiName}" sets only constants (${constants.join(', ')}), and its write path needs at least one property set from an input.`
@@ -199,6 +221,12 @@ export function deriveOntologyEditPlan(
199
221
  'Add a required input { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 } }: the record version the change was decided on (a query row\'s _meta.recordVersion).',
200
222
  )
201
223
  }
224
+ if (mediaAttachment && [pkParameter, expectedVersionParameter].includes(mediaAttachment.uploadParameter)) {
225
+ throw new PlanDerivationError(
226
+ `Action "${action.apiName}" takes the file in ${mediaAttachment.uploadParameter}, which already ${mediaAttachment.uploadParameter === pkParameter ? 'addresses the record' : 'carries the record version'}.`,
227
+ 'Give the file its own input: every mapped parameter carries exactly one role.',
228
+ )
229
+ }
202
230
  if (expectedVersionParameter && Object.values(set).includes(expectedVersionParameter)) {
203
231
  throw new PlanDerivationError(
204
232
  `Action "${action.apiName}" writes a property from expectedVersion, which carries the record version.`,
@@ -211,9 +239,10 @@ export function deriveOntologyEditPlan(
211
239
  kind: 'ontology_edit',
212
240
  targetCompatibility: {
213
241
  kind: 'ontology_edit',
242
+ // The attachment's properties count as used, as the service's `ontologyEditUsedProperties` counts them.
214
243
  expectedFingerprint: ontologyEditCompatibilityFingerprint(
215
244
  objectType.id,
216
- Object.keys(set),
245
+ [...Object.keys(set), ...(mediaAttachment?.propertyApiName ? [mediaAttachment.propertyApiName] : []), ...(mediaAttachment?.itemIdProperty ? [mediaAttachment.itemIdProperty] : [])],
217
246
  ),
218
247
  },
219
248
  objectTypeId: objectType.id,
@@ -222,6 +251,7 @@ export function deriveOntologyEditPlan(
222
251
  set,
223
252
  pkParameter,
224
253
  ...(expectedVersionParameter ? { expectedVersionParameter } : {}),
254
+ ...(mediaAttachment ? { mediaAttachment } : {}),
225
255
  // One subject, because the Action itself declares `impact.maxSubjects: 1`
226
256
  // and the schema admits no other value.
227
257
  impactBounds: { maxAffectedRows: 1, maxTouchedRows: 1 },
@@ -237,3 +267,40 @@ export function deriveOntologyEditPlan(
237
267
  },
238
268
  }
239
269
  }
270
+
271
+ /** The plan's `mediaAttachment` for this choice, checked against the Action and its object type. */
272
+ function attachmentOf(
273
+ action: PublishedActionDefinition,
274
+ objectType: ObjectTypeShape,
275
+ choice: MediaAttachmentChoice,
276
+ set: Record<string, string>,
277
+ ): NonNullable<OntologyEditPlan['mediaAttachment']> {
278
+ if (!choice.property && !choice.itemIdProperty) {
279
+ throw new PlanDerivationError('An attachment fills a media reference property, an item id property, or both.', 'Pass --attach <media property>, --attach-item-id <text property>, or both.')
280
+ }
281
+ if (!action.inputs.some((i) => i.apiName === choice.uploadInput)) {
282
+ throw new PlanDerivationError(`Action "${action.apiName}" takes no "${choice.uploadInput}" input for the file.`, 'Add a string input for the staged upload (`frontera media stage` prints its id), publish, then deploy.')
283
+ }
284
+ if (Object.values(set).includes(choice.uploadInput)) {
285
+ throw new PlanDerivationError(`Action "${action.apiName}" already writes a property from ${choice.uploadInput}.`, 'Give the file its own input: every mapped parameter carries exactly one role.')
286
+ }
287
+ const property = (name: string) => objectType.properties.find((p) => p.apiName === name)
288
+ for (const name of [choice.property, choice.itemIdProperty]) {
289
+ if (!name) continue
290
+ if (!property(name)) throw new PlanDerivationError(`Object type "${objectType.apiName}" has no property "${name}".`, 'Name a property of the Action\'s object type.')
291
+ if (Object.prototype.hasOwnProperty.call(set, name)) throw new PlanDerivationError(`Action "${action.apiName}" already sets ${name} from an input, so the attachment can't fill it.`, 'Remove that change from the Action, or attach into another property.')
292
+ }
293
+ if (choice.property && choice.property === choice.itemIdProperty) {
294
+ throw new PlanDerivationError('The media reference and the item id cannot be the same property.', 'Name two properties, or one of them.')
295
+ }
296
+ const mediaSetId = choice.mediaSetId ?? (choice.property ? property(choice.property)?.mediaSetId ?? undefined : undefined)
297
+ if (!mediaSetId) {
298
+ throw new PlanDerivationError('The attachment names no Media Set.', 'Pass --media-set <id> (`frontera media list` shows the ids).')
299
+ }
300
+ return {
301
+ ...(choice.property ? { propertyApiName: choice.property } : {}),
302
+ ...(choice.itemIdProperty ? { itemIdProperty: choice.itemIdProperty } : {}),
303
+ mediaSetId,
304
+ uploadParameter: choice.uploadInput,
305
+ }
306
+ }
@@ -0,0 +1,64 @@
1
+ import { localProject } from '../local/local-project'
2
+ import { syncClient, unwrap, type SyncClient } from '../local/local-sync-client'
3
+ import { getSyncKey, readLink } from '../local/local-sync-link'
4
+ import type { ArtifactKind } from './model'
5
+
6
+ /**
7
+ * Whether a new Blueprint artifact's API name is already the organization's
8
+ * (Forge improvement plan X11). A workspace sees only its slice of the
9
+ * organization's Blueprint, so `blueprint new object-type Activity` wrote a
10
+ * file a change request then refused: the organization had an `Activity`
11
+ * outside the slice (P5 A9). Read with the project's organization key from
12
+ * `frontera local sync connect`; without a link or that key it is not
13
+ * checked, and says so.
14
+ */
15
+ export type NameCheck =
16
+ | { checked: false; why: string }
17
+ | { checked: true; taken: false }
18
+ | { checked: true; taken: true; as: string }
19
+
20
+ /** Where each kind's names are in the organization's Blueprint definition. */
21
+ const KEYS: Record<ArtifactKind, string> = {
22
+ 'object-type': 'objects',
23
+ 'link-type': 'relationships',
24
+ 'shared-field': 'sharedProperties',
25
+ 'semantic-type': 'semanticTypes',
26
+ metric: 'metrics',
27
+ action: 'actions',
28
+ }
29
+
30
+ /** The API names a definition holds for a kind. */
31
+ export function namesIn(definition: Record<string, unknown> | null | undefined, kind: ArtifactKind): string[] {
32
+ const list = definition?.[KEYS[kind]]
33
+ return Array.isArray(list) ? list.flatMap((e) => (typeof (e as { apiName?: unknown })?.apiName === 'string' ? [(e as { apiName: string }).apiName] : [])) : []
34
+ }
35
+
36
+ export async function orgNameCheck(root: string, kind: ArtifactKind, apiName: string, deps: { client?: SyncClient | null } = {}): Promise<NameCheck> {
37
+ let client = deps.client ?? null
38
+ if (!client) {
39
+ const project = localProject(root)
40
+ const link = readLink(project.root)
41
+ if (!link) return { checked: false, why: 'the project is linked to no workspace' }
42
+ const key = getSyncKey(project, 'organization')
43
+ if (!key) return { checked: false, why: 'the project has no organization key (`frontera local sync connect --org-key -`)' }
44
+ client = syncClient(link.platform, key)
45
+ }
46
+ try {
47
+ const found = await client.find<unknown>('/v1/blueprint/lifecycle')
48
+ if (!found) return { checked: true, taken: false }
49
+ const lifecycle = unwrap<{ draft?: { definition?: Record<string, unknown> } | null; activeRelease?: { definition?: Record<string, unknown> } | null }>(found)
50
+ const names = new Set([...namesIn(lifecycle.activeRelease?.definition, kind), ...namesIn(lifecycle.draft?.definition, kind)])
51
+ // The same name in other letters is the same name to a reader, and to a URL.
52
+ const as = [...names].find((n) => n.toLowerCase() === apiName.toLowerCase())
53
+ return as ? { checked: true, taken: true, as } : { checked: true, taken: false }
54
+ } catch (err) {
55
+ return { checked: false, why: `the organization's Blueprint couldn't be read (${(err as Error).message})` }
56
+ }
57
+ }
58
+
59
+ /** What `blueprint new` says about the check, or null when there is nothing to say. */
60
+ export function nameCheckWords(kind: ArtifactKind, apiName: string, check: NameCheck): string | null {
61
+ if (!check.checked || !check.taken) return null
62
+ const word = kind.replace('-', ' ')
63
+ return `warning: the organization already has a ${word} ${check.as}${check.as === apiName ? '' : ` (the same name to a reader)`}. If it is outside this workspace's Blueprint, a change request refuses a new one of that name: give this one a more specific API name (Sales${apiName[0]!.toUpperCase()}${apiName.slice(1)}), or ask a Blueprint Steward to grant the workspace the organization's`
64
+ }
@@ -22,7 +22,35 @@ import {
22
22
 
23
23
  const READABLE = new Set(['.yaml', '.yml', '.json'])
24
24
 
25
+ /**
26
+ * Where a file fails to parse, as an editor jumps to it: `path:line:col: why`
27
+ * (Forge improvement plan N9). One YAML error used to fail every item in the
28
+ * Blueprint with no file name. Null when the text parses.
29
+ */
30
+ export function parseProblem(path: string, text: string): string | null {
31
+ try {
32
+ if (extname(path) === '.json') JSON.parse(text)
33
+ else parseYaml(text)
34
+ return null
35
+ } catch (err) {
36
+ const e = err as { message?: string; linePos?: Array<{ line: number; col: number }> }
37
+ const at = e.linePos?.[0] ?? (() => {
38
+ // JSON: "… at position 41" (or "line 3 column 5").
39
+ const lc = /line (\d+) column (\d+)/.exec(e.message ?? '')
40
+ if (lc) return { line: Number(lc[1]), col: Number(lc[2]) }
41
+ const pos = /position (\d+)/.exec(e.message ?? '')
42
+ if (!pos) return null
43
+ const before = text.slice(0, Number(pos[1])).split('\n')
44
+ return { line: before.length, col: before[before.length - 1]!.length + 1 }
45
+ })()
46
+ const why = (e.message ?? 'does not parse').split('\n')[0]!.replace(/ at line \d+, column \d+:?$/, '').trim()
47
+ return `${path}${at ? `:${at.line}:${at.col}` : ''}: ${why}`
48
+ }
49
+ }
50
+
25
51
  export function parseDocument(path: string, text: string): Record<string, unknown> {
52
+ const problem = parseProblem(path, text)
53
+ if (problem) throw new CliError(problem, { code: 'BLUEPRINT_FILE_INVALID', hint: 'fix the file at that line; nothing in the Blueprint was applied' })
26
54
  const parsed = extname(path) === '.json' ? JSON.parse(text) : parseYaml(text)
27
55
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
28
56
  throw new CliError(`${path} does not contain a mapping.`, {
@@ -29,11 +29,12 @@ export const actionDeploy: Command = {
29
29
  // organization key carrying `actionMutationPlan`/`actionBinding` runs this.
30
30
  authLane: 'api-key',
31
31
  args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
32
- flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
32
+ flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string', attach: 'string', 'attach-item-id': 'string', 'file-input': 'string', 'media-set': 'string' },
33
33
  summary: 'Build and validate the write path for a published Action',
34
34
  examples: [
35
35
  'frontera action deploy escalateTicket --dry-run',
36
36
  'frontera action deploy escalateTicket',
37
+ 'frontera action deploy attachPhoto --attach photo --file-input upload',
37
38
  ],
38
39
  },
39
40
 
@@ -55,9 +56,24 @@ export const actionDeploy: Command = {
55
56
  })
56
57
  }
57
58
 
59
+ // An attachment Action (L6): the file arrives in --file-input, and the write fills --attach and/or --attach-item-id.
60
+ const attachProperty = flagString(ctx, 'attach')
61
+ const attachItemId = flagString(ctx, 'attach-item-id')
62
+ const fileInput = flagString(ctx, 'file-input')
63
+ const mediaSet = flagString(ctx, 'media-set')
64
+ if ((attachProperty || attachItemId || mediaSet) && !fileInput) {
65
+ throw new UsageError('an attachment needs the input the file arrives in', `frontera action deploy ${apiName} --attach <media property> --file-input <input>`)
66
+ }
67
+ if (fileInput && !attachProperty && !attachItemId) {
68
+ throw new UsageError('--file-input names where the file arrives, and nothing says what it fills', `add --attach <media property> or --attach-item-id <text property>`)
69
+ }
70
+ const attachment = fileInput
71
+ ? { uploadInput: fileInput, ...(attachProperty ? { property: attachProperty } : {}), ...(attachItemId ? { itemIdProperty: attachItemId } : {}), ...(mediaSet ? { mediaSetId: mediaSet } : {}) }
72
+ : undefined
73
+
58
74
  let plan
59
75
  try {
60
- plan = deriveOntologyEditPlan(definition, objectType)
76
+ plan = deriveOntologyEditPlan(definition, objectType, attachment)
61
77
  } catch (err) {
62
78
  if (err instanceof PlanDerivationError) {
63
79
  throw new CliError(err.message, { code: 'VALIDATION_ERROR', hint: err.hint })
@@ -65,9 +81,10 @@ export const actionDeploy: Command = {
65
81
  throw err
66
82
  }
67
83
 
68
- const writes = Object.entries(plan.set)
69
- .map(([property, parameter]) => `${property} ← ${parameter}`)
70
- .join(', ')
84
+ const writes = [
85
+ ...Object.entries(plan.set).map(([property, parameter]) => `${property} ← ${parameter}`),
86
+ ...(plan.mediaAttachment ? [`${[plan.mediaAttachment.propertyApiName, plan.mediaAttachment.itemIdProperty].filter(Boolean).join(' and ')} ← the file in ${plan.mediaAttachment.uploadParameter} (Media Set ${plan.mediaAttachment.mediaSetId})`] : []),
87
+ ].join(', ')
71
88
 
72
89
  if (flagBool(ctx, 'dry-run')) {
73
90
  return {
@@ -6,6 +6,7 @@ import { actionGrant } from './grant'
6
6
  import { actionList } from './list'
7
7
  import { actionPrepare } from './prepare'
8
8
  import { actionRequests } from './requests'
9
+ import { actionRetire } from './retire'
9
10
  import { actionReview } from './review'
10
11
  import { actionSubmit } from './submit'
11
12
  import type { Command } from '../types'
@@ -41,6 +42,7 @@ export const actionCommands: readonly Command[] = [
41
42
  actionReject,
42
43
  actionCancel,
43
44
  actionPrepare,
45
+ actionRetire,
44
46
  actionDeploy,
45
47
  actionReview,
46
48
  actionGrant,
@@ -0,0 +1,60 @@
1
+ import { GovernedActionApi } from '../../api/governed-action-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import { flagString, type Command } from '../types'
4
+
5
+ /**
6
+ * Retire an Action's deployment decision (Forge improvement plan X11).
7
+ *
8
+ * `action prepare` records that a drafted Action ships switched off, and the
9
+ * decision pins the Action's contract as it was then: editing the drafted
10
+ * Action after preparing it left it stranded, since publish refuses the
11
+ * changed contract and a second prepare refuses a second decision. Retiring
12
+ * the decision stands it down, so `action prepare` can record one for the
13
+ * contract as it is now.
14
+ *
15
+ * Only a decision that was never bound, or one already draining, retires:
16
+ * the service refuses an active deployment, which is switched off first. It
17
+ * arms nothing and removes nothing from the Blueprint. It takes a signed-in
18
+ * person with `actionBinding: retire`.
19
+ */
20
+ export const actionRetire: Command = {
21
+ meta: {
22
+ noun: 'action',
23
+ verb: 'retire',
24
+ authLane: 'session',
25
+ args: [{ name: 'action', required: true, description: 'The Action’s API name' }],
26
+ flags: { reason: 'string' },
27
+ summary: 'Retire an Action’s deployment decision, so a changed draft can be prepared again',
28
+ details: [
29
+ 'For an Action prepared before its draft changed: retire, then `frontera action prepare <action>` records a decision for the contract as it is now.',
30
+ 'An active deployment is refused: switch it off in Console first.',
31
+ ],
32
+ examples: [
33
+ 'frontera action retire escalateTicket --reason "draft changed"',
34
+ 'frontera action prepare escalateTicket',
35
+ ],
36
+ },
37
+
38
+ async run(ctx) {
39
+ const apiName = ctx.positional[0]
40
+ if (!apiName) throw new UsageError('missing <action>', 'frontera action retire escalateTicket --reason "…"')
41
+ const reason = flagString(ctx, 'reason')?.trim()
42
+ if (!reason) throw new UsageError('say why with --reason', 'the reason is kept with the deployment’s history: frontera action retire escalateTicket --reason "the draft changed after it was prepared"')
43
+
44
+ const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
45
+ const definitionId = await client.actionDefinitionId(apiName)
46
+ if (!definitionId) throw new CliError(`No Action "${apiName}" on the draft or in the release.`, { code: 'ACTION_NOT_FOUND', hint: 'frontera action list' })
47
+ const current = await client.currentDeployment(definitionId)
48
+ if (!current) {
49
+ return { data: { action: apiName, retired: false }, text: `"${apiName}" carries no deployment decision: nothing to retire. \`frontera action prepare ${apiName}\` records one.` }
50
+ }
51
+ if (current.state === 'active') {
52
+ throw new CliError(`"${apiName}" is deployed and switched on; an active deployment isn't retired.`, { code: 'CONFLICT', hint: 'switch it off in Console (the Action’s deployment) first, then retire it' })
53
+ }
54
+ const retired = await client.retireDeployment(current.stateId, current.generation, reason)
55
+ return {
56
+ data: { action: apiName, retired: true, from: current.state, deployment: retired },
57
+ text: `Retired the ${current.state} deployment decision of "${apiName}".\n\nNext:\n frontera action prepare ${apiName}`,
58
+ }
59
+ },
60
+ }
@@ -57,6 +57,7 @@ export function renderComposition(snapshot: Row, lookups: Lookups): string {
57
57
  ['id', str(config, 'id') || (lookups.identity?.id ?? '')],
58
58
  ['status', str(config, 'lifecycleStatus')],
59
59
  ['default mode', str(config, 'defaultMode')],
60
+ ['fallback mode', str(config, 'fallbackMode')],
60
61
  ['live version', lookups.identity?.version ? String(lookups.identity.version) : ''],
61
62
  ] as Array<[string, string]>) {
62
63
  if (v) lines.push(`${k.padEnd(14)}${v}`)