@frontera-sdk/cli 1.53.5 → 1.53.7

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 (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
@@ -0,0 +1,253 @@
1
+ /**
2
+ * What a publish needs before anything is posted, read from one validation report.
3
+ *
4
+ * The service records every refused publish as a failed attempt, kept and shown in
5
+ * Blueprint > Changes. So `blueprint publish` reads the report `validate` answers,
6
+ * which already carries the blockers and the change ids, and posts only a publish
7
+ * that can pass: one with no blockers, and an instruction for every change that
8
+ * needs one.
9
+ */
10
+
11
+ /** A change between the active release and the draft (`evolution.changes[]`). */
12
+ export interface EvolutionChange {
13
+ id: string
14
+ code: string
15
+ classification: string
16
+ subject: { kind: string; id: string; label: string }
17
+ message: string
18
+ /** The value the draft holds at the change's path; a new Action's whole definition. */
19
+ after?: unknown
20
+ }
21
+
22
+ /** `POST /v1/blueprint/lifecycle/validate`, the parts a publish reads. */
23
+ export interface ValidationReport {
24
+ id: string
25
+ draftRevision: number
26
+ status: string
27
+ blockers?: Array<{ code: string; message: string; path?: string; changeId?: string }>
28
+ evolution?: { changes?: EvolutionChange[] }
29
+ bindingReadiness?: { actions?: Array<{ actionDefinitionId: string; actionApiName: string; reason: string }> }
30
+ }
31
+
32
+ export interface PublishBlocker {
33
+ code: string
34
+ /** The Action or object it is about, by name; absent when the report names none. */
35
+ subject?: string
36
+ message: string
37
+ /** What to do about it, and the commands that do it, in order. */
38
+ next?: { why: string; commands: string[] }
39
+ }
40
+
41
+ export interface NewAction {
42
+ apiName: string
43
+ /** The `action_added_environment_validation` change the publish answers for it. */
44
+ changeId: string
45
+ invokeCapability?: string
46
+ /** Its effect only edits Blueprint records (create, update, delete); absent when the report does not say. */
47
+ editsBlueprint?: boolean
48
+ }
49
+
50
+ /** A published Action this release changes and carries switched off. */
51
+ export interface ChangedAction {
52
+ apiName: string
53
+ /** As on `NewAction`: known when the release changes the Action's effect kind. */
54
+ editsBlueprint?: boolean
55
+ }
56
+
57
+ export interface PublishPlan {
58
+ reportId: string
59
+ draftRevision: number
60
+ blockers: PublishBlocker[]
61
+ /** Actions with no deployment decision: the publish records that each ships switched off. */
62
+ autoFixable: string[]
63
+ /** Non-additive changes a person explains with `--instruction`. */
64
+ instructionsNeeded: EvolutionChange[]
65
+ newActions: NewAction[]
66
+ /** Published Actions this release changes and carries switched off: each runs again only after a new write path. */
67
+ changedSwitchedOff: ChangedAction[]
68
+ /** The report lists no changes at all: the draft is the active release. */
69
+ unchanged: boolean
70
+ }
71
+
72
+ /** A new Action's own environment change, which its ships-switched-off decision answers. */
73
+ const NEW_ACTION_ENVIRONMENT_CHANGE = 'action_added_environment_validation'
74
+
75
+ /** The effect kinds whose write path `action deploy` builds (`ontology_edit`), which its author may review. */
76
+ const BLUEPRINT_EDIT_EFFECTS = new Set(['create', 'update', 'delete'])
77
+
78
+ /** Whether each Action's effect only edits Blueprint records, by Action id, where a change says: a new Action's definition, or a changed effect kind. */
79
+ function editsBlueprintById(changes: EvolutionChange[]): Map<string, boolean> {
80
+ const edits = new Map<string, boolean>()
81
+ for (const change of changes) {
82
+ if (change.subject.kind !== 'action') continue
83
+ const kind = change.code === 'action_effect_kind_changed'
84
+ ? change.after
85
+ : (change.after as { effect?: { kind?: unknown } } | undefined)?.effect?.kind
86
+ if (typeof kind === 'string') edits.set(change.subject.id, BLUEPRINT_EDIT_EFFECTS.has(kind))
87
+ }
88
+ return edits
89
+ }
90
+
91
+ /** The words the change-request Apply records for the same change. */
92
+ function newActionInstruction(apiName: string): string {
93
+ return `New Action ${apiName}: it ships switched off with no write path. A person deploys it, binds it for this environment and turns it on after publish.`
94
+ }
95
+
96
+ /** A printed command aimed at the profile the publish ran with, so it acts where the publish did. */
97
+ const aimed = (command: string, profile: string | undefined) => (profile ? `${command} --profile ${profile}` : command)
98
+
99
+ /** What clears a changed Action's old deployment, the running one included (`action retire`); `signIn` is `signInStep`'s answer. */
100
+ const retireTheOldDeployment = (name: string, signIn: string | null) => ({
101
+ why: 'The Action changed after its deployment was decided, and a deployment cannot follow a new contract. A signed-in person retires the old one, switching it off if it runs; the next publish records a decision for the Action as it is now and prints the deploy step that turns it on.',
102
+ commands: [
103
+ ...(signIn ? [signIn] : []),
104
+ `frontera action retire ${name} --reason "the Action changed" --yes`,
105
+ 'frontera blueprint publish',
106
+ ],
107
+ })
108
+
109
+ /** Blocker code to the step that clears it. A code not listed gets the service's message alone. */
110
+ const NEXT_STEPS: Record<string, (name: string, signIn: string | null) => { why: string; commands: string[] }> = {
111
+ ACTION_UNBOUND: (name) => ({
112
+ why: 'Record that it ships switched off. That takes a key allowed to create Action deployments.',
113
+ commands: [`frontera action prepare ${name}`],
114
+ }),
115
+ ACTION_BINDING_DIGEST_MISMATCH: retireTheOldDeployment,
116
+ ACTION_BINDING_CONSUMER_INVALID: retireTheOldDeployment,
117
+ ACTION_BINDING_SUSPENDED: () => ({ why: 'Its deployment is suspended.', commands: ['frontera action list'] }),
118
+ ACTION_BINDING_REVOKED: () => ({ why: 'Its deployment is revoked.', commands: ['frontera action list'] }),
119
+ ACTION_BINDING_VALIDATION_STALE: () => ({ why: 'Its deployment needs a fresh validation.', commands: ['frontera action list'] }),
120
+ ACTION_BINDING_STATE_CONTRADICTORY: () => ({ why: 'Its deployment state does not add up.', commands: ['frontera action list'] }),
121
+ }
122
+
123
+ export function publishPlan(report: ValidationReport, signIn: string | null, profile?: string): PublishPlan {
124
+ const changes = report.evolution?.changes ?? []
125
+ const actions = report.bindingReadiness?.actions ?? []
126
+ // Ids to names: a blocker names its subject only by an id in its path.
127
+ const names = new Map<string, string>([
128
+ ...changes.map((change) => [change.subject.id, change.subject.label] as const),
129
+ ...actions.map((action) => [action.actionDefinitionId, action.actionApiName] as const),
130
+ ])
131
+ const blockers = (report.blockers ?? []).map((blocker): PublishBlocker => {
132
+ const subject = (blocker.path ?? '').split('.').map((segment) => names.get(segment)).find(Boolean)
133
+ const next = NEXT_STEPS[blocker.code]?.(subject ?? '<action>', signIn)
134
+ return {
135
+ code: blocker.code,
136
+ ...(subject ? { subject } : {}),
137
+ message: blocker.message,
138
+ ...(next ? { next: { ...next, commands: next.commands.map((command) => aimed(command, profile)) } } : {}),
139
+ }
140
+ })
141
+ const newActionIds = new Set(changes.filter((change) => change.code === NEW_ACTION_ENVIRONMENT_CHANGE).map((change) => change.subject.id))
142
+ const changedActionIds = new Set(changes.filter((change) => change.subject.kind === 'action').map((change) => change.subject.id))
143
+ const editsBlueprint = editsBlueprintById(changes)
144
+ return {
145
+ reportId: report.id,
146
+ draftRevision: report.draftRevision,
147
+ blockers,
148
+ autoFixable: actions.filter((action) => action.reason === 'ACTION_UNBOUND').map((action) => action.actionApiName),
149
+ instructionsNeeded: changes.filter((change) => change.classification !== 'additive' && change.code !== NEW_ACTION_ENVIRONMENT_CHANGE),
150
+ newActions: changes.filter((change) => change.code === NEW_ACTION_ENVIRONMENT_CHANGE).map((change) => {
151
+ const capability = (change.after as { governance?: { invokeCapability?: unknown } } | undefined)?.governance?.invokeCapability
152
+ const edits = editsBlueprint.get(change.subject.id)
153
+ return {
154
+ apiName: change.subject.label,
155
+ changeId: change.id,
156
+ ...(typeof capability === 'string' ? { invokeCapability: capability } : {}),
157
+ ...(edits === undefined ? {} : { editsBlueprint: edits }),
158
+ }
159
+ }),
160
+ changedSwitchedOff: actions
161
+ .filter((action) => action.reason === 'ACTION_DISABLED' && changedActionIds.has(action.actionDefinitionId) && !newActionIds.has(action.actionDefinitionId))
162
+ .map((action) => {
163
+ const edits = editsBlueprint.get(action.actionDefinitionId)
164
+ return { apiName: action.actionApiName, ...(edits === undefined ? {} : { editsBlueprint: edits }) }
165
+ }),
166
+ // Only a report that carries the list: one without it says nothing either way.
167
+ unchanged: Array.isArray(report.evolution?.changes) && changes.length === 0,
168
+ }
169
+ }
170
+
171
+ /** One instruction per change the publish must explain: the person's text, and a new Action's own. */
172
+ export function migrationInstructions(plan: PublishPlan, instruction: string | undefined): Array<{ changeId: string; instruction: string }> {
173
+ return [
174
+ ...plan.newActions.map((action) => ({ changeId: action.changeId, instruction: newActionInstruction(action.apiName) })),
175
+ ...(instruction ? plan.instructionsNeeded.map((change) => ({ changeId: change.id, instruction })) : []),
176
+ ]
177
+ }
178
+
179
+ const KIND_WORDS: Record<string, [string, string]> = {
180
+ object: ['object type', 'object types'],
181
+ action: ['Action', 'Actions'],
182
+ relationship: ['link type', 'link types'],
183
+ metric: ['metric', 'metrics'],
184
+ shared_property: ['shared field', 'shared fields'],
185
+ semantic_type: ['semantic type', 'semantic types'],
186
+ source_system: ['source system', 'source systems'],
187
+ }
188
+
189
+ /** `source_mapping_changed on 3 object types: Customer, Order, Ticket`, one line per code and kind. */
190
+ export function groupedChanges(changes: EvolutionChange[]): string[] {
191
+ const groups = new Map<string, EvolutionChange[]>()
192
+ for (const change of changes) {
193
+ const key = `${change.code}\u0000${change.subject.kind}`
194
+ groups.set(key, [...(groups.get(key) ?? []), change])
195
+ }
196
+ return [...groups.values()].map((group) => {
197
+ const labels = [...new Set(group.map((change) => change.subject.label))]
198
+ const [one, many] = KIND_WORDS[group[0]!.subject.kind] ?? [group[0]!.subject.kind, group[0]!.subject.kind]
199
+ const messages = new Set(group.map((change) => change.message))
200
+ return `${group[0]!.code} on ${labels.length} ${labels.length === 1 ? one : many}: ${labels.join(', ')}`
201
+ + (messages.size === 1 ? ` (${group[0]!.message.replace(/\.$/, '')})` : '')
202
+ })
203
+ }
204
+
205
+ /** `✕ escalateTicket: <message>`, then the step that clears it, indented under it; a step two blockers share is said once. */
206
+ export function blockerLines(blockers: PublishBlocker[]): string[] {
207
+ const said = new Set<string>()
208
+ return blockers.flatMap((blocker) => {
209
+ const key = JSON.stringify([blocker.subject, blocker.next])
210
+ const next = blocker.next && !said.has(key) ? blocker.next : undefined
211
+ said.add(key)
212
+ return [
213
+ ` ✕ ${blocker.subject ? `${blocker.subject}: ` : ''}${blocker.message}`,
214
+ ...(next ? [` ${next.why}`, ...next.commands.map((command) => ` ${command}`)] : []),
215
+ ]
216
+ })
217
+ }
218
+
219
+ /**
220
+ * The step that gives an Action a write path, worded for who reviews it: its
221
+ * author may for one that only edits Blueprint records, which `action deploy`
222
+ * builds; one that calls another system is built for that system, outside the
223
+ * CLI, and a second person reviews it.
224
+ */
225
+ function deployStep(apiName: string, editsBlueprint: boolean | undefined, what: string): { step: string; command?: string } {
226
+ if (editsBlueprint === false) {
227
+ return { step: `It calls another system, so \`frontera action deploy\` does not build ${what}: one is built for that system, and a second person reviews it and turns it on` }
228
+ }
229
+ const who = editsBlueprint ? 'it only edits Blueprint records, so you may review your own' : 'you may review your own when it only edits Blueprint records; one that calls another system needs a second person'
230
+ return { step: `Build, review and turn on ${what}; ${who}`, command: `frontera action deploy ${apiName}` }
231
+ }
232
+
233
+ const aimedSteps = (steps: Array<{ step: string; command?: string }>, profile: string | undefined) =>
234
+ steps.map((step) => (step.command ? { ...step, command: aimed(step.command, profile) } : step))
235
+
236
+ /** What turns a changed Action on again: a fresh write path for the contract it has now. */
237
+ export function changedActionSteps(action: ChangedAction, signIn: string | null, profile?: string): Array<{ step: string; command?: string }> {
238
+ return aimedSteps([
239
+ ...(signIn ? [{ step: 'Sign in as yourself, if you have not', command: signIn }] : []),
240
+ deployStep(action.apiName, action.editsBlueprint, 'a write path for what it is now'),
241
+ ], profile)
242
+ }
243
+
244
+ /** What turns a new Action on, in order, with who runs each step. */
245
+ export function newActionSteps(action: NewAction, signIn: string | null, profile?: string): Array<{ step: string; command?: string }> {
246
+ return aimedSteps([
247
+ ...(signIn ? [{ step: 'Sign in as yourself, if you have not', command: signIn }] : []),
248
+ deployStep(action.apiName, action.editsBlueprint, 'its write path'),
249
+ ...(action.invokeCapability
250
+ ? [{ step: 'Let a role run it', command: `frontera action grant ${action.invokeCapability} --role <role>` }]
251
+ : []),
252
+ ], profile)
253
+ }
@@ -39,6 +39,25 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
39
39
  detail: options.prune ? 'delete' : 'delete — needs --prune, skipped',
40
40
  })
41
41
  }
42
+ for (const change of plan.editable) {
43
+ const dropped = change.from.filter((name) => !change.to.includes(name))
44
+ rows.push({
45
+ marker: MARKER.update,
46
+ kind: 'object-type',
47
+ apiName: change.objectApiName,
48
+ // Narrowing is named, because an edit already stored against a dropped
49
+ // property stops being read: the one consequence the new set cannot show.
50
+ detail: `editable: ${change.to.length ? change.to.join(', ') : '(none)'}`
51
+ + (dropped.length ? ` — no longer editable: ${dropped.join(', ')}` : ''),
52
+ })
53
+ }
54
+
55
+ for (const change of plan.rebind) {
56
+ const move = `dataset: ${change.from ?? '(none)'} → ${change.to}`
57
+ rows.push('refused' in change
58
+ ? { marker: '✕', kind: 'object-type', apiName: change.objectApiName, detail: `${move} — cannot apply: ${change.refused}. Next: ${change.next}` }
59
+ : { marker: MARKER.update, kind: 'object-type', apiName: change.objectApiName, detail: move })
60
+ }
42
61
 
43
62
  const withheld = withheldNote(plan)
44
63
 
@@ -1,23 +1,45 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
3
  import { GovernedActionApi } from '../../api/governed-action-api'
4
+ import { credentialStore } from '../../credential-store'
5
+ import { readDeviceSession } from '../../device-login'
4
6
  import { CliError, UsageError } from '../../errors'
5
7
  import { deriveOntologyEditPlan, PlanDerivationError } from '../../blueprint/ontology-edit-plan'
6
8
  import { validationFindings } from '../../local/local-actions'
9
+ import { selectedOrganization, setSelectedOrganization } from '../../organization'
10
+ import { canPrompt, promptConfirm } from '../../prompt'
7
11
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
12
+ import { activateReviewed, externalWritePathSentence, reviewRevision } from './review'
8
13
 
9
14
  /**
10
15
  * Arm the write path for one published Action.
11
16
  *
12
- * Stops at VALIDATED, deliberately. Review must be performed by someone who
13
- * authored none of the material — the service refuses a reviewer who did — so
14
- * a single command that also reviewed would either be refused or would be
15
- * pretending the separation happened. `action review` is the second half, run
16
- * with a second credential.
17
+ * It builds and validates the write path with the command's credential, which
18
+ * an organization key may be. Turning it on takes a person: the review and the
19
+ * activation run as the person's token, or the browser sign-in stored for the
20
+ * profile, and only after a go (a yes at the terminal to a question naming the
21
+ * Action and the host, or `--yes`). Without one it stops at VALIDATED and
22
+ * prints the command that turns it on. The plan it builds only edits Blueprint
23
+ * records (`ontology_edit`), which the person who built it may review; the
24
+ * service keeps the second-person review for a plan that calls another system,
25
+ * and its refusal stops here, naming the review command a second person runs.
26
+ * While the Action runs, drains or last ran a write path that calls another
27
+ * system, which a second person reviewed, it stops once the new one is
28
+ * validated and names the review a second person runs; the service refuses to
29
+ * turn the new one on otherwise, even after the old one is retired. There it
30
+ * builds as the person, since the service turns on no write path an API key
31
+ * built in that one's place.
17
32
  */
18
33
 
19
- function api(ctx: CommandContext): GovernedActionApi {
20
- return new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
34
+ function api(ctx: CommandContext, token = ctx.token): GovernedActionApi {
35
+ return new GovernedActionApi(ctx.apiUrl, token, ctx.workspaceId)
36
+ }
37
+
38
+ /** Who turns the write path on: the command's own person token, else the profile's browser sign-in. */
39
+ async function personOf(ctx: CommandContext): Promise<{ token: string; email?: string } | null> {
40
+ if (!ctx.token.startsWith('sk-')) return { token: ctx.token }
41
+ if (!ctx.profile || process.env.FRONTERA_TOKEN?.trim()) return null
42
+ return readDeviceSession(credentialStore(), ctx.profile, ctx.apiUrl)
21
43
  }
22
44
 
23
45
  export const actionDeploy: Command = {
@@ -30,10 +52,15 @@ export const actionDeploy: Command = {
30
52
  authLane: 'api-key',
31
53
  args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
32
54
  flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string', attach: 'string', 'attach-item-id': 'string', 'file-input': 'string', 'media-set': 'string' },
33
- summary: 'Build and validate the write path for a published Action',
55
+ summary: 'Build the write path for a published Action and, as a signed-in person, turn it on',
56
+ details: [
57
+ 'It asks before turning the write path on, naming the Action and the host; --yes turns it on without asking. With no terminal and no --yes, it stops once the write path is validated.',
58
+ 'When the Action runs, is still finishing requests on, or last ran a write path that calls another system, it builds the new one as the signed-in person rather than with an API key, stops once it is validated and prints the review a second person runs. Retiring the old one first does not change that.',
59
+ ],
34
60
  examples: [
35
61
  'frontera action deploy escalateTicket --dry-run',
36
62
  'frontera action deploy escalateTicket',
63
+ 'frontera action deploy escalateTicket --yes',
37
64
  'frontera action deploy attachPhoto --attach photo --file-input upload',
38
65
  ],
39
66
  },
@@ -96,18 +123,39 @@ export const actionDeploy: Command = {
96
123
  }
97
124
  }
98
125
 
126
+ // A deploy aimed with --profile (a change request's step, from a folder bound to its local runtime) aims what it prints too.
127
+ const at = typeof ctx.flags.profile === 'string' ? ` --profile ${ctx.flags.profile}` : ''
128
+ const external = await client.externalWritePath(definition.id)
129
+ const person = await personOf(ctx)
130
+ // A key in FRONTERA_TOKEN wins over a browser sign-in, so signing in changes nothing while it is set.
131
+ const keyInEnvironment = Boolean(process.env.FRONTERA_TOKEN?.trim())
132
+ if (external && !person) {
133
+ throw new CliError(`${externalWritePathSentence(`"${apiName}"`, external)} A write path built with an API key cannot replace it, and ${keyInEnvironment ? 'FRONTERA_TOKEN holds an API key' : 'nobody is signed in here'}, so nothing was built.`, {
134
+ code: 'CONFLICT',
135
+ hint: keyInEnvironment
136
+ ? 'unset FRONTERA_TOKEN or set it to a person\'s token, and deploy again; it builds the new one as that person, and a second person reviews it'
137
+ : `sign in with \`frontera auth login --browser${at}\` and deploy again; it builds the new one as you, and a second person reviews it`,
138
+ details: { action: apiName, replacedPlanKind: external.planKind, replacedState: external.state },
139
+ })
140
+ }
141
+ if (external && person && ctx.token !== person.token) {
142
+ if (!selectedOrganization() && external.organizationId) setSelectedOrganization(external.organizationId)
143
+ ctx.output.note(`building it as ${person.email ?? 'you'} (browser sign-in), since no write path an API key built replaces one that calls another system`)
144
+ }
145
+ const builder = external && person ? api(ctx, person.token) : client
146
+
99
147
  // Client-minted, so a retry after a network failure can name the same
100
148
  // artifact instead of orphaning a half-built one.
101
149
  const planId = flagString(ctx, 'plan-id') ?? randomUUID()
102
150
  const bindingId = flagString(ctx, 'binding-id') ?? randomUUID()
103
151
 
104
- const planRevision = await client.createMutationPlanRevision(planId, plan)
105
- const binding = await client.createBindingRevision(bindingId, {
152
+ const planRevision = await builder.createMutationPlanRevision(planId, plan)
153
+ const binding = await builder.createBindingRevision(bindingId, {
106
154
  actionDefinitionId: definition.id,
107
155
  actionContractDigest: published.contractDigest,
108
156
  mutationPlanRevisionId: planRevision.id,
109
157
  })
110
- const validation = await client.validateBindingRevision(binding.id, 1)
158
+ const validation = await builder.validateBindingRevision(binding.id, 1)
111
159
 
112
160
  if (validation.status !== 'valid') {
113
161
  // Each failed check, by its code and the platform's message, one per line; the full report in `--json` details.
@@ -124,21 +172,91 @@ export const actionDeploy: Command = {
124
172
  )
125
173
  }
126
174
 
175
+ const built = {
176
+ action: apiName,
177
+ planId,
178
+ planRevisionId: planRevision.id,
179
+ bindingId,
180
+ bindingRevisionId: binding.id,
181
+ stateId: binding.stateId,
182
+ validationReportId: validation.id,
183
+ }
184
+ const summary = ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}`
185
+ const review = `frontera action review ${binding.id} --report ${validation.id}${at}`
186
+ const again = `frontera action deploy ${apiName} --yes${at}`
187
+ const secondPerson = `a second person who may review Actions turns it on with: ${review}`
188
+
189
+ if (external) {
190
+ throw new CliError(
191
+ `${externalWritePathSentence(`"${apiName}"`, external)} `
192
+ + 'The one just built only edits Blueprint records, and a second person reviews it before it is turned on.',
193
+ {
194
+ code: 'CONFLICT',
195
+ hint: secondPerson,
196
+ details: { ...built, status: validation.status, replacedPlanKind: external.planKind, replacedState: external.state },
197
+ },
198
+ )
199
+ }
200
+
201
+ if (!person) {
202
+ return {
203
+ data: { ...built, status: validation.status, activated: false, next: review },
204
+ text: `Built and validated the write path for "${apiName}".\n${summary}\n\n`
205
+ + 'An API key cannot turn it on; a signed-in person can, and because it only edits Blueprint records, that may be you. '
206
+ + (keyInEnvironment ? 'Unset FRONTERA_TOKEN, which holds an API key, or set it to a person\'s token, then run:\n' : `Sign in with \`frontera auth login --browser${at}\`, then run:\n`)
207
+ + ` ${review}`,
208
+ }
209
+ }
210
+ // The build named the key's organization; the person's calls name the same one.
211
+ if (!selectedOrganization() && binding.organizationId) setSelectedOrganization(binding.organizationId)
212
+ const asPerson = api(ctx, person.token)
213
+
214
+ if (!flagBool(ctx, 'yes')) {
215
+ const current = await asPerson.currentDeployment(definition.id)
216
+ const host = new URL(ctx.apiUrl).host
217
+ const question = `Turn on the write path for "${apiName}" on ${host}${current?.state === 'active' ? ', replacing the one that runs now' : ''}?`
218
+ const go = (ctx.interactive ?? canPrompt()) && ctx.flags['no-input'] !== true ? await promptConfirm(question) : false
219
+ if (!go) {
220
+ return {
221
+ data: { ...built, status: validation.status, activated: false, next: again },
222
+ text: `Built and validated the write path for "${apiName}", and did not turn it on.\n${summary}\n\n`
223
+ + `To build it again and turn it on, run:\n ${again}`,
224
+ }
225
+ }
226
+ }
227
+ if (person.email) ctx.output.note(`turning it on as ${person.email} (browser sign-in)`)
228
+
229
+ try {
230
+ await reviewRevision(asPerson, { bindingRevisionId: binding.id, validationReportId: validation.id, activating: true })
231
+ } catch (err) {
232
+ if (!(err instanceof CliError)) throw err
233
+ throw new CliError(`Built and validated the write path for "${apiName}", but did not review it: ${err.message}`, {
234
+ code: err.code,
235
+ hint: `another person who may review Actions turns it on with: ${review}`,
236
+ details: { ...built, status: validation.status },
237
+ cause: err,
238
+ })
239
+ }
240
+ let stateId: string
241
+ try {
242
+ stateId = (await activateReviewed(asPerson, binding.id)).stateId
243
+ } catch (err) {
244
+ if (!(err instanceof CliError)) throw err
245
+ const needsSecondPerson = (err.details as { reason?: string } | undefined)?.reason === 'ACTION_DEPLOYMENT_REPLACES_EXTERNAL_WRITE_PATH'
246
+ throw new CliError(`Reviewed the write path for "${apiName}", but did not turn it on: ${err.message}`, {
247
+ code: err.code,
248
+ hint: needsSecondPerson
249
+ ? `build a fresh write path with \`frontera action deploy ${apiName}${at}\`; a second person reviews it with the command it prints`
250
+ : `run \`${again}\` to build a fresh write path and turn it on`,
251
+ details: { ...built, status: validation.status, reviewed: true },
252
+ cause: err,
253
+ })
254
+ }
255
+
127
256
  return {
128
- data: {
129
- action: apiName,
130
- planId,
131
- planRevisionId: planRevision.id,
132
- bindingId,
133
- bindingRevisionId: binding.id,
134
- stateId: binding.stateId,
135
- validationReportId: validation.id,
136
- status: validation.status,
137
- },
138
- text: `Built and validated the write path for "${apiName}".\n`
139
- + ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}\n\n`
140
- + 'Next, as a DIFFERENT user — the service refuses a reviewer who authored the material:\n'
141
- + ` frontera action review ${binding.id} --report ${validation.id}`,
257
+ data: { ...built, stateId, status: validation.status, activated: true },
258
+ text: `Built, reviewed and turned on the write path for "${apiName}".\n${summary}\n\n`
259
+ + 'A role that holds its capability can now invoke it.',
142
260
  }
143
261
  },
144
262
  }
@@ -17,9 +17,9 @@ import type { Command } from '../types'
17
17
  * Blueprint authoring reaches the Action's CONTRACT — `blueprint create action`
18
18
  * and `blueprint apply` both write it, and `blueprint publish` releases it. What
19
19
  * had no verb anywhere was arming the write: a mutation plan, a Binding pinned
20
- * to the contract digest, a validation against the live target, an independent
21
- * review and a state-machine activation. Five admin calls with hand-minted
22
- * UUIDs, or nothing.
20
+ * to the contract digest, a validation against the live target, a review and a
21
+ * state-machine activation. Five admin calls with hand-minted UUIDs, or
22
+ * nothing.
23
23
  *
24
24
  * Invoking — `available`, `submit`, `approve`, `reject`, `cancel` — is
25
25
  * session-lane. Both key kinds present a non-member principal — `wskey:…` and
@@ -46,11 +46,11 @@ export function whyNot(apiName: string, entry: { published: boolean; deploymentS
46
46
  const code = entry.reason && entry.reason !== 'ACTION_AVAILABLE' ? ` (${entry.reason})` : ''
47
47
  switch (entry.deploymentStatus) {
48
48
  case 'unbound':
49
- return `no write path${code} — \`frontera action deploy ${apiName}\`, then \`action review\``
49
+ return `no write path${code} — \`frontera action deploy ${apiName}\``
50
50
  case 'disabled':
51
- return `ships switched off${code} — \`frontera action deploy ${apiName}\`, then \`action review\``
51
+ return `ships switched off${code} — \`frontera action deploy ${apiName}\``
52
52
  case 'suspended':
53
- return `its deployment is suspended${code} — review it again with \`frontera action review\``
53
+ return `its deployment is suspended${code} — review it again with \`frontera action review\` (a second person, if it calls another system)`
54
54
  case 'revoked':
55
55
  return `its deployment was revoked${code} — \`frontera action deploy ${apiName}\` again`
56
56
  case 'active':
@@ -8,6 +8,8 @@ import { flagString, type Command } from '../types'
8
8
  * Publish refuses an Action with no deployment decision, and every other
9
9
  * deployment route acts on a state that already exists — so without this the
10
10
  * two requirements are circular and the first row has to be written by hand.
11
+ * `blueprint publish` records the same decision for each Action that has none,
12
+ * so this is the step on its own, before a publish.
11
13
  *
12
14
  * It arms nothing. `action deploy` is still the only thing that builds a write
13
15
  * path, and it runs AFTER the release, because a Binding is validated against
@@ -22,7 +24,7 @@ export const actionPrepare: Command = {
22
24
  authLane: 'api-key',
23
25
  args: [{ name: 'action', required: true, description: 'The drafted Action’s API name' }],
24
26
  flags: { reason: 'string' },
25
- summary: 'Record that a drafted Action ships disabled, so it can be published',
27
+ summary: 'Record that a drafted Action ships disabled (blueprint publish also does this)',
26
28
  examples: [
27
29
  'frontera action prepare escalateTicket',
28
30
  'frontera action prepare escalateTicket --reason "ships off"',
@@ -43,8 +45,8 @@ export const actionPrepare: Command = {
43
45
  data: decision,
44
46
  text: `"${apiName}" will publish disabled.\n`
45
47
  + ` state ${decision.stateId} · generation ${decision.generation}\n\n`
46
- + 'Next:\n'
47
- + ' frontera blueprint publish --label "…" \n'
48
+ + 'Next (`blueprint publish` records this decision itself, so this step is optional):\n'
49
+ + ' frontera blueprint publish --label "…"\n'
48
50
  + ` frontera action deploy ${apiName}`,
49
51
  }
50
52
  },