@frontera-sdk/cli 1.53.6 → 1.53.8

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 (34) hide show
  1. package/package.json +4 -4
  2. package/src/api/blueprint-authoring-api.ts +19 -19
  3. package/src/api/governed-action-api.ts +78 -3
  4. package/src/blueprint/dataset-revision.ts +66 -0
  5. package/src/blueprint/diff.ts +134 -6
  6. package/src/blueprint/publish-plan.ts +253 -0
  7. package/src/blueprint/render.ts +19 -0
  8. package/src/commands/action/deploy.ts +143 -25
  9. package/src/commands/action/index-commands.ts +3 -3
  10. package/src/commands/action/list.ts +3 -3
  11. package/src/commands/action/prepare.ts +5 -3
  12. package/src/commands/action/retire.ts +78 -26
  13. package/src/commands/action/review.ts +86 -25
  14. package/src/commands/blueprint/authoring.ts +110 -24
  15. package/src/commands/blueprint/bind.ts +10 -46
  16. package/src/commands/blueprint/declarative.ts +124 -30
  17. package/src/commands/blueprint/editable.ts +3 -5
  18. package/src/commands/knowledge/index-commands.ts +2 -3
  19. package/src/commands/local/dev.ts +1 -21
  20. package/src/commands/local/steps.ts +11 -7
  21. package/src/commands/local/sync-request.ts +28 -5
  22. package/src/commands/local/sync.ts +19 -3
  23. package/src/commands/types.ts +2 -0
  24. package/src/device-login.ts +19 -1
  25. package/src/exit.ts +5 -0
  26. package/src/local/local-actions.ts +6 -5
  27. package/src/local/local-blueprint-schemas.ts +2 -2
  28. package/src/local/local-dev-references.ts +0 -23
  29. package/src/local/local-person-steps.ts +92 -8
  30. package/src/local/local-sync-link.ts +76 -0
  31. package/src/local/local-sync-precheck.ts +1 -1
  32. package/src/local/local-sync-push.ts +8 -2
  33. package/src/main.ts +1 -0
  34. package/src/vendor/kit-assets.json +6 -6
@@ -1,21 +1,29 @@
1
- import { GovernedActionApi } from '../../api/governed-action-api'
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { GovernedActionApi, type ExternalWritePath } from '../../api/governed-action-api'
2
4
  import { CliError, UsageError } from '../../errors'
3
- import { flagString, type Command } from '../types'
5
+ import { canPrompt, promptConfirm } from '../../prompt'
6
+ import { flagBool, flagString, type Command } from '../types'
4
7
 
5
8
  /**
6
- * Retire an Action's deployment decision (Forge improvement plan X11).
9
+ * Retire an Action's deployment, so a changed Action can publish (Forge
10
+ * improvement plan X11).
7
11
  *
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.
12
+ * A deployment pins the Action's contract as it was when it was decided:
13
+ * prepared, or deployed and turned on. Publish refuses a changed contract while
14
+ * a deployment still pins the old one, and a deployment cannot follow a new
15
+ * contract. Retiring stands it down, and the next `blueprint publish` records
16
+ * that the Action as it is now ships switched off.
14
17
  *
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`.
18
+ * A running deployment is switched off first, as the service's `disable` does
19
+ * it: the running one drains and a disabled decision takes its place. Then
20
+ * every draining deployment of the Action and the decision are retired. A run
21
+ * cut short is finished by running it again. It takes a signed-in person with
22
+ * `actionBinding: activate` and `retire`, and removes nothing from the Blueprint.
23
+ * Switching off a running deployment is asked first, naming the Action and the
24
+ * host; `--yes` answers for a caller with no terminal. When the write path the
25
+ * Action last ran calls another system, it says that a second person reviews
26
+ * the one that replaces it, which retiring does not change.
19
27
  */
20
28
  export const actionRetire: Command = {
21
29
  meta: {
@@ -24,14 +32,17 @@ export const actionRetire: Command = {
24
32
  authLane: 'session',
25
33
  args: [{ name: 'action', required: true, description: 'The Action’s API name' }],
26
34
  flags: { reason: 'string' },
27
- summary: 'Retire an Action’s deployment decision, so a changed draft can be prepared again',
35
+ summary: 'Switch off and retire an Action’s deployment, so a changed Action can publish',
28
36
  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.',
37
+ 'For an Action changed after it was prepared or deployed: retire, then `frontera blueprint publish` records a decision for the Action as it is now.',
38
+ 'A running deployment is switched off first; the Action does not run again until `frontera action deploy <action>` turns it on.',
39
+ 'It asks before switching off a running deployment, naming the Action and the host; --yes switches it off without asking.',
31
40
  ],
32
41
  examples: [
33
- 'frontera action retire escalateTicket --reason "draft changed"',
34
- 'frontera action prepare escalateTicket',
42
+ 'frontera action retire escalateTicket --reason "the Action changed"',
43
+ 'frontera action retire escalateTicket --reason "the Action changed" --yes',
44
+ 'frontera blueprint publish',
45
+ 'frontera action deploy escalateTicket',
35
46
  ],
36
47
  },
37
48
 
@@ -39,22 +50,63 @@ export const actionRetire: Command = {
39
50
  const apiName = ctx.positional[0]
40
51
  if (!apiName) throw new UsageError('missing <action>', 'frontera action retire escalateTicket --reason "…"')
41
52
  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"')
53
+ if (!reason) throw new UsageError('say why with --reason', 'the reason is kept with the deployment’s history: frontera action retire escalateTicket --reason "the Action changed"')
43
54
 
44
55
  const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
45
56
  const definitionId = await client.actionDefinitionId(apiName)
46
57
  if (!definitionId) throw new CliError(`No Action "${apiName}" on the draft or in the release.`, { code: 'ACTION_NOT_FOUND', hint: 'frontera action list' })
47
58
  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.` }
59
+ const running = current?.state === 'active'
60
+ const at = typeof ctx.flags.profile === 'string' ? ` --profile ${ctx.flags.profile}` : ''
61
+ if (running && !flagBool(ctx, 'yes')) {
62
+ const said = `"${apiName}" is running on ${new URL(ctx.apiUrl).host}; retiring it switches it off until it is deployed again`
63
+ const go = (ctx.interactive ?? canPrompt()) && ctx.flags['no-input'] !== true ? await promptConfirm(`${said}. Switch it off?`) : false
64
+ if (!go) {
65
+ throw new CliError(`${said}, so nothing was retired.`, {
66
+ code: 'USAGE',
67
+ hint: `to switch it off, add --yes: frontera action retire ${apiName} --reason ${JSON.stringify(reason)} --yes${at}`,
68
+ })
69
+ }
70
+ }
71
+ const decision = current?.state === 'active'
72
+ ? await client.disableDeployment(randomUUID(), {
73
+ bindingId: randomUUID(),
74
+ actionDefinitionId: definitionId,
75
+ actionContractDigest: current.actionContractDigest,
76
+ expectedCurrentStateId: current.stateId,
77
+ expectedCurrentGeneration: current.generation,
78
+ reason,
79
+ })
80
+ : current
81
+ const draining = await client.drainingDeployments(definitionId)
82
+ for (const deployment of draining) await client.retireDeployment(deployment.stateId, deployment.generation, reason)
83
+ const retired = decision ? await client.retireDeployment(decision.stateId, decision.generation, reason) : null
84
+
85
+ if (!current && draining.length === 0) {
86
+ return { data: { action: apiName, retired: false }, text: `"${apiName}" carries no deployment decision: nothing to retire. \`frontera blueprint publish\` records one.` }
50
87
  }
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' })
88
+ // Read after the writes landed, so a failure here is reported as a note, not as a failed retire.
89
+ let external: ExternalWritePath | null = null
90
+ let unread: string | null = null
91
+ try {
92
+ external = await client.externalWritePath(definitionId)
93
+ } catch (err) {
94
+ unread = err instanceof Error ? err.message : String(err)
53
95
  }
54
- const retired = await client.retireDeployment(current.stateId, current.generation, reason)
96
+ const secondPerson = unread !== null
97
+ ? `Could not read whether the last write path "${apiName}" ran calls another system (${unread}). If it does, a second person reviews the one that replaces it; \`frontera action deploy ${apiName}${at}\` reads it again before it builds.\n\n`
98
+ : external
99
+ ? `The last write path "${apiName}" ran calls another system (${external.planKind ?? 'a kind the platform cannot read'}), which a second person reviewed, so a second person reviews the one that replaces it. `
100
+ + `\`frontera action deploy ${apiName}${at}\` builds it and prints the command they run.\n\n`
101
+ : ''
102
+ const what = running
103
+ ? `Switched off the running deployment of "${apiName}" and retired it. "${apiName}" does not run until it is deployed again.`
104
+ : current
105
+ ? `Retired the ${current.state} deployment decision of "${apiName}".`
106
+ : `Retired the deployment of "${apiName}" that was still draining.`
55
107
  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}`,
108
+ data: { action: apiName, retired: true, from: current?.state ?? 'draining', switchedOff: running, deployment: retired, externalWritePath: external, ...(unread !== null ? { externalWritePathError: unread } : {}) },
109
+ text: `${what}\n\n${secondPerson}Next:\n frontera blueprint publish${at}${running ? `\n frontera action deploy ${apiName}${at}` : ''}`,
58
110
  }
59
111
  },
60
112
  }
@@ -1,16 +1,18 @@
1
- import { GovernedActionApi } from '../../api/governed-action-api'
1
+ import { GovernedActionApi, type ExternalWritePath } from '../../api/governed-action-api'
2
2
  import { CliError, UsageError } from '../../errors'
3
3
  import { flagString, type Command, type CommandContext } from '../types'
4
4
 
5
5
  /**
6
6
  * Approve a validated write path, and switch it on.
7
7
  *
8
- * Its own verb because it must run as a DIFFERENT principal: the service
9
- * refuses a reviewer who authored the Binding, the connector or the plan. An
10
- * organization key's principal is `orgkey:<org>:<key>`, so a second key is a
11
- * second reviewer — no second human required to arm a deployment in a
12
- * non-production organization, and a real one required wherever the roles are
13
- * held by people.
8
+ * Its own verb because only a person's session may review, and `action
9
+ * deploy` may have built the write path with an organization key. The person
10
+ * who built a write path that only edits Blueprint records may review it; one
11
+ * that calls another system needs a second person, and the service refuses
12
+ * its author. So does one that replaces a write path calling another system,
13
+ * and nobody's review turns that one on when an API key built it, so it asks
14
+ * before it reviews. `action deploy` run as a person does both and ends with
15
+ * the same two steps.
14
16
  *
15
17
  * Review and activate are one command because the bookkeeping between them is
16
18
  * not a decision: activation names the generation review left the revision's
@@ -35,15 +37,50 @@ const READ_REFUSED_HINT =
35
37
  * longer switch on. The message stays the service's; the hint says which step
36
38
  * refused and what did not happen.
37
39
  */
38
- async function requireReadableRevision(client: GovernedActionApi, bindingRevisionId: string): Promise<void> {
40
+ async function requireReadableRevision(client: GovernedActionApi, bindingRevisionId: string) {
39
41
  try {
40
- await client.getBindingRevision(bindingRevisionId)
42
+ return await client.getBindingRevision(bindingRevisionId)
41
43
  } catch (err) {
42
44
  if (!(err instanceof CliError) || err.code !== 'FORBIDDEN') throw err
43
45
  throw new CliError(err.message, { code: 'FORBIDDEN', hint: READ_REFUSED_HINT, cause: err })
44
46
  }
45
47
  }
46
48
 
49
+ /** What the Action does on a write path that calls another system, said of `subject`. */
50
+ export function externalWritePathSentence(subject: string, external: ExternalWritePath): string {
51
+ const where = external.state === 'active' ? 'runs on' : external.state === 'draining' ? 'is still finishing requests on' : 'last ran on'
52
+ return `${subject} ${where} a write path that calls another system (${external.planKind ?? 'a kind the platform cannot read'}), which a second person reviewed.`
53
+ }
54
+
55
+ /**
56
+ * Asked before a review that activation follows. While the Action runs,
57
+ * drains or last ran a write path that calls another system, activation turns
58
+ * a new one on only for a reviewer who built none of it, and never one an API
59
+ * key built, so a review it would refuse is not recorded.
60
+ */
61
+ async function requireReviewTurnsItOn(
62
+ client: GovernedActionApi,
63
+ revision: { id: string; actionDefinitionId: string },
64
+ validationReportId: string,
65
+ ): Promise<void> {
66
+ const external = await client.externalWritePath(revision.actionDefinitionId, revision.id)
67
+ if (!external?.reviewRefusal) return
68
+ const keyBuilt = external.reviewRefusal === 'key_built'
69
+ throw new CliError(
70
+ `${externalWritePathSentence('The Action', external)} `
71
+ + (keyBuilt
72
+ ? 'This one was built with an API key, so no review turns it on in its place, and nothing was reviewed.'
73
+ : 'You built this one, so a second person reviews it, and nothing was reviewed.'),
74
+ {
75
+ code: 'CONFLICT',
76
+ hint: keyBuilt
77
+ ? 'a person signed in as themselves runs `frontera action deploy` for this Action, which builds a new one as them, and a second person reviews it with the command it prints'
78
+ : `a second person who may review Actions turns it on with: frontera action review ${revision.id} --report ${validationReportId}`,
79
+ details: { reviewRefusal: external.reviewRefusal, replacedPlanKind: external.planKind, replacedState: external.state },
80
+ },
81
+ )
82
+ }
83
+
47
84
  /** The review has landed and the states activation names could not be read. */
48
85
  function notReadable(cause?: unknown): CliError {
49
86
  return new CliError('Reviewed, but the deployment state for this revision is not readable.', {
@@ -83,6 +120,43 @@ async function readActivation(client: GovernedActionApi, bindingRevisionId: stri
83
120
  }
84
121
  }
85
122
 
123
+ /**
124
+ * Review the revision at the exact validation it read. When it will be
125
+ * activated next, a reviewer who may not read it, or whose review activation
126
+ * would refuse, is refused first, with nothing reviewed.
127
+ */
128
+ export async function reviewRevision(client: GovernedActionApi, input: {
129
+ bindingRevisionId: string
130
+ validationReportId: string
131
+ activating: boolean
132
+ }): Promise<void> {
133
+ if (input.activating) {
134
+ const revision = await requireReadableRevision(client, input.bindingRevisionId)
135
+ await requireReviewTurnsItOn(client, revision, input.validationReportId)
136
+ }
137
+ // The generation is the service's, not ours: validation left the state at 2.
138
+ await client.reviewBindingRevision(input.bindingRevisionId, {
139
+ validationReportId: input.validationReportId,
140
+ expectedStateGeneration: 2,
141
+ })
142
+ }
143
+
144
+ /** Switch a reviewed revision on over whatever its Action is on now. */
145
+ export async function activateReviewed(
146
+ client: GovernedActionApi,
147
+ bindingRevisionId: string,
148
+ reason = 'Activated from the CLI after review.',
149
+ ): Promise<{ stateId: string }> {
150
+ const { target, current } = await readActivation(client, bindingRevisionId)
151
+ await client.activateDeployment(target.stateId, {
152
+ expectedGeneration: target.generation,
153
+ expectedCurrentStateId: current?.stateId ?? null,
154
+ expectedCurrentGeneration: current?.generation ?? null,
155
+ reason,
156
+ })
157
+ return { stateId: target.stateId }
158
+ }
159
+
86
160
  export const actionReview: Command = {
87
161
  meta: {
88
162
  noun: 'action',
@@ -113,13 +187,7 @@ export const actionReview: Command = {
113
187
  const client = api(ctx)
114
188
  const activate = ctx.flags['no-activate'] !== true
115
189
 
116
- if (activate) await requireReadableRevision(client, bindingRevisionId)
117
-
118
- // The generation is the service's, not ours: validation left the state at 2.
119
- await client.reviewBindingRevision(bindingRevisionId, {
120
- validationReportId,
121
- expectedStateGeneration: 2,
122
- })
190
+ await reviewRevision(client, { bindingRevisionId, validationReportId, activating: activate })
123
191
 
124
192
  if (!activate) {
125
193
  return {
@@ -128,17 +196,10 @@ export const actionReview: Command = {
128
196
  }
129
197
  }
130
198
 
131
- const { target, current } = await readActivation(client, bindingRevisionId)
132
-
133
- await client.activateDeployment(target.stateId, {
134
- expectedGeneration: target.generation,
135
- expectedCurrentStateId: current?.stateId ?? null,
136
- expectedCurrentGeneration: current?.generation ?? null,
137
- reason: flagString(ctx, 'reason') ?? 'Activated from the CLI after review.',
138
- })
199
+ const { stateId } = await activateReviewed(client, bindingRevisionId, flagString(ctx, 'reason'))
139
200
 
140
201
  return {
141
- data: { bindingRevisionId, stateId: target.stateId, reviewed: true, activated: true },
202
+ data: { bindingRevisionId, stateId, reviewed: true, activated: true },
142
203
  text: 'Reviewed and activated. The Action can now be invoked.',
143
204
  }
144
205
  },
@@ -2,6 +2,16 @@ import { readFileSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
 
4
4
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
+ import { GovernedActionApi } from '../../api/governed-action-api'
6
+ import {
7
+ blockerLines,
8
+ groupedChanges,
9
+ migrationInstructions,
10
+ newActionSteps,
11
+ changedActionSteps,
12
+ publishPlan,
13
+ } from '../../blueprint/publish-plan'
14
+ import { signInStep } from '../../device-login'
5
15
  import { CliError } from '../../errors'
6
16
  import type { Command, CommandContext } from '../types'
7
17
 
@@ -301,7 +311,7 @@ type ValidationReport = Awaited<ReturnType<BlueprintAuthoringApi['validate']>>
301
311
  * release to the validation that cleared it rather than to whatever the draft
302
312
  * holds later.
303
313
  */
304
- export function renderValidationReport(report: ValidationReport): string {
314
+ export function renderValidationReport(report: Partial<ValidationReport>): string {
305
315
  const id = report?.id ?? report?.reportId
306
316
  const objectTypes = report?.catalogProjection?.objectTypes?.length ?? 0
307
317
  const linkTypes = report?.catalogProjection?.linkTypes?.length ?? 0
@@ -327,7 +337,16 @@ export const blueprintPublish: Command = {
327
337
  verb: 'publish',
328
338
  args: [],
329
339
  flags: { label: 'string', notes: 'string', report: 'string', instruction: 'string' },
340
+ flagHelp: {
341
+ instruction: 'how existing data and environments take the changes that need a migration instruction; sent with each of them (a new Action’s own is written for you)',
342
+ report: 'the draft’s latest validation report to publish against; without it, publish validates the draft first',
343
+ },
330
344
  summary: 'Publish the shared draft as a release',
345
+ details: [
346
+ 'Validates the draft first and posts nothing that would be refused: each blocker is listed with the command that clears it.',
347
+ 'A new governed Action with no deployment decision is recorded as shipping switched off, as `frontera action prepare` does.',
348
+ 'A change that needs a migration instruction (other than a new Action\'s) is listed by kind and name; --instruction explains every one of them.',
349
+ ],
331
350
  examples: [
332
351
  'frontera blueprint publish --label v3 --notes "Adds Customer"',
333
352
  'frontera blueprint publish --label v4 --instruction "Backfilled"',
@@ -337,39 +356,106 @@ export const blueprintPublish: Command = {
337
356
  const client = api(ctx)
338
357
  const label = (ctx.flags.label as string) ?? 'cli-release'
339
358
  const notes = (ctx.flags.notes as string) ?? 'Published with the Frontera CLI.'
340
-
341
- // Validate first when no report was given: publishing against a stale report
342
- // would release something nobody checked.
343
- let validationReportId = ctx.flags.report as string | undefined
344
- if (!validationReportId) {
345
- const report = await client.validate()
346
- validationReportId = report?.id ?? report?.reportId
359
+ const instruction = (ctx.flags.instruction as string | undefined)?.trim() || undefined
360
+ const profile = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : undefined
361
+
362
+ // `--report` is the validation the person read; without it, the draft is validated now.
363
+ let report: ValidationReport
364
+ const reportId = ctx.flags.report as string | undefined
365
+ if (reportId) {
366
+ const latest = (await client.lifecycle() as { latestValidation?: ValidationReport | null }).latestValidation
367
+ if (latest?.id !== reportId) {
368
+ throw new CliError(`Report ${reportId} is not the draft's latest validation, so a release from it would not be what was checked.`, {
369
+ code: 'VALIDATION_STALE',
370
+ hint: 'drop --report (publish validates the draft itself), or run `frontera blueprint validate` and pass its report',
371
+ })
372
+ }
373
+ report = latest
374
+ } else {
375
+ report = await client.validate()
376
+ }
377
+ const signIn = await signInStep({ profile: ctx.profile, apiUrl: ctx.apiUrl })
378
+ let plan = publishPlan(report, signIn, profile)
379
+
380
+ // What `action prepare` records, for each Action that has no decision yet.
381
+ const prepared: string[] = []
382
+ if (plan.autoFixable.length) {
383
+ const actions = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
384
+ for (const name of plan.autoFixable) {
385
+ await actions.recordDeploymentDecision(name, `Published with the Frontera CLI ("${label}"): ships switched off until a person deploys it and turns it on.`)
386
+ prepared.push(name)
387
+ }
388
+ plan = publishPlan(await client.validate(), signIn, profile)
389
+ }
390
+ const preparedLines = prepared.map((name) => `Recorded that Action ${name} ships switched off: a person deploys it and turns it on after publish.`)
391
+
392
+ if (plan.blockers.length) {
393
+ throw new CliError([
394
+ ...preparedLines,
395
+ `The draft can't be published yet, so nothing was published. ${plan.blockers.length} blocker${plan.blockers.length === 1 ? '' : 's'}:`,
396
+ ...blockerLines(plan.blockers),
397
+ ].join('\n'), {
398
+ code: 'RELEASE_BLOCKED',
399
+ hint: 'clear each blocker above, then publish again',
400
+ details: { prepared, plan },
401
+ })
347
402
  }
348
- if (!validationReportId) {
349
- throw new CliError('Validation produced no report id; cannot publish.', {
350
- code: 'FAILURE',
351
- hint: 'frontera blueprint validate',
403
+
404
+ if (plan.instructionsNeeded.length && !instruction) {
405
+ const quote = (value: string) => (/^[\w.:-]+$/.test(value) ? value : JSON.stringify(value))
406
+ const rerun = ['frontera blueprint publish', `--label ${quote(label)}`, ...(ctx.flags.notes ? [`--notes ${quote(notes)}`] : []), '--instruction "<how existing data and environments take these changes>"', ...(profile ? [`--profile ${profile}`] : [])].join(' ')
407
+ const count = plan.instructionsNeeded.length
408
+ throw new CliError([
409
+ ...preparedLines,
410
+ `${count} change${count === 1 ? '' : 's'} in this release need${count === 1 ? 's' : ''} a migration instruction, so nothing was published:`,
411
+ ...groupedChanges(plan.instructionsNeeded).map((line) => ` ${line}`),
412
+ ].join('\n'), {
413
+ code: 'MIGRATION_INSTRUCTION_REQUIRED',
414
+ hint: `say how existing data and environments take these changes; the instruction is kept with each change in the release:\n ${rerun}`,
415
+ details: { prepared, plan },
352
416
  })
353
417
  }
354
418
 
419
+ // Refused by the service, and kept as a failed attempt, if posted.
420
+ if (plan.unchanged) {
421
+ return { data: { release: null, prepared, newActions: [] }, text: [...preparedLines, 'The draft has no unpublished changes, so nothing was published.'].join('\n') }
422
+ }
423
+
355
424
  const released = await client.publish({
356
- expectedRevision: await client.revision(),
357
- validationReportId,
425
+ expectedRevision: plan.draftRevision,
426
+ validationReportId: plan.reportId,
358
427
  releaseLabel: label,
359
428
  releaseNotes: notes,
360
- // A release that discards changes needs an instruction per discarded change.
361
- instruction: ctx.flags.instruction as string | undefined,
429
+ migrationInstructions: migrationInstructions(plan, instruction),
362
430
  })
363
431
  const lifecycle = released as { activeRelease?: { releaseLabel?: string }; alreadyPublished?: boolean } | null
364
- if (!lifecycle?.alreadyPublished) {
365
- return { data: released, text: `Published release "${label}".` }
432
+ if (lifecycle?.alreadyPublished) {
433
+ // A retry of a publish that already went through is answered with the release
434
+ // that is live, which carries the label it was published with, not this run's.
435
+ // One made after newer Draft edits is refused by the service as stale.
436
+ const activeReleaseLabel = lifecycle.activeRelease?.releaseLabel ?? label
437
+ return { data: { release: released, prepared, newActions: [] }, text: `Release "${activeReleaseLabel}" is already active; nothing was published.` }
438
+ }
439
+ const newActions = plan.newActions.map((action) => ({ ...action, nextSteps: newActionSteps(action, signIn, profile) }))
440
+ const changedActions = plan.changedSwitchedOff.map((action) => ({ apiName: action.apiName, nextSteps: changedActionSteps(action, signIn, profile) }))
441
+ const numbered = (steps: Array<{ step: string; command?: string }>) => steps.flatMap((step, index) => (step.command ? [` ${index + 1}. ${step.step}:`, ` ${step.command}`] : [` ${index + 1}. ${step.step}.`]))
442
+ return {
443
+ data: { release: released, prepared, newActions, changedActions },
444
+ text: [
445
+ ...preparedLines,
446
+ `Published release "${label}".`,
447
+ ...newActions.flatMap((action) => [
448
+ '',
449
+ `New Action ${action.apiName} is published switched off, with no write path. To turn it on:`,
450
+ ...numbered(action.nextSteps),
451
+ ]),
452
+ ...changedActions.flatMap((action) => [
453
+ '',
454
+ `Action ${action.apiName} changed and is published switched off; its old write path does not run the new version. To turn it on again:`,
455
+ ...numbered(action.nextSteps),
456
+ ]),
457
+ ].join('\n'),
366
458
  }
367
- // A retry of a publish that already went through is answered with the release
368
- // that is live, which carries the label it was published with, not this run's.
369
- // One made after newer Draft edits is refused by the service as stale.
370
- const activeReleaseLabel = lifecycle.activeRelease?.releaseLabel ?? label
371
- const nothingPublished = `Release "${activeReleaseLabel}" is already active; nothing was published.`
372
- return { data: released, text: nothingPublished }
373
459
  },
374
460
  }
375
461
 
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
 
4
4
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
- import { pickCurrentRevision } from '../../blueprint/dataset-revision'
5
+ import { columnMappingsFrom, datasetBindingCommand, pickCurrentRevision, type MappingEntry } from '../../blueprint/dataset-revision'
6
6
  import { readTree, writeTree } from '../../blueprint/tree'
7
7
  import { CliError } from '../../errors'
8
8
  import type { Command, CommandContext } from '../types'
@@ -31,7 +31,7 @@ function api(ctx: CommandContext): BlueprintAuthoringApi {
31
31
 
32
32
  interface MappingFile {
33
33
  dataset: string
34
- mapping: Record<string, string | { column: string; field?: string | null } | null>
34
+ mapping: Record<string, MappingEntry | null>
35
35
  }
36
36
 
37
37
  async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
@@ -58,7 +58,7 @@ async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
58
58
  hint: 'Publish a revision of the dataset, then bind.',
59
59
  })
60
60
  }
61
- return current
61
+ return { ...current, id: current.id }
62
62
  }
63
63
 
64
64
  /**
@@ -150,36 +150,7 @@ export const blueprintBind: Command = {
150
150
  })
151
151
  }
152
152
  const mapping = JSON.parse(readFileSync(resolve(ctx.cwd, filePath), 'utf8')) as MappingFile
153
- const idByApiName = new Map(properties.map((property) => [property.apiName, property.id]))
154
-
155
- const columnMappings: Array<{ propertyId: string; column: string; field?: string | null }> = []
156
- const missing: string[] = []
157
- for (const property of properties) {
158
- const name = property.apiName
159
- // A calculated field (a location from latitude and longitude) reads no column.
160
- if (!name || (property as { calculation?: unknown }).calculation) continue
161
- const entry = mapping.mapping?.[name]
162
- if (entry === undefined || entry === null) {
163
- missing.push(name)
164
- continue
165
- }
166
- const resolved = typeof entry === 'string' ? { column: entry } : entry
167
- const propertyId = idByApiName.get(name)
168
- if (!propertyId) {
169
- // `!` sent `propertyId: undefined` and let the service answer for it. The
170
- // property came from the same read as the mapping, so this means the draft
171
- // moved mid-command.
172
- throw new CliError(`Property "${name}" is no longer on "${apiName}".`, {
173
- code: 'CONFLICT',
174
- hint: 'Re-run `frontera blueprint bind … --plan` against the current draft.',
175
- })
176
- }
177
- columnMappings.push({
178
- propertyId,
179
- column: resolved.column,
180
- ...(resolved.field === undefined || resolved.field === null ? {} : { field: resolved.field }),
181
- })
182
- }
153
+ const { columnMappings, unmapped: missing } = columnMappingsFrom(properties, mapping.mapping ?? {})
183
154
  if (missing.length > 0) {
184
155
  // Refused here rather than at the service: the same refusal, one round trip
185
156
  // earlier, and it can name every unmapped property at once instead of the
@@ -193,20 +164,13 @@ export const blueprintBind: Command = {
193
164
  )
194
165
  }
195
166
 
196
- const result = await client.draftCommand({
197
- kind: 'update_source_binding',
198
- objectId: detail.id,
199
- binding: {
200
- kind: 'dataset',
201
- datasetRevisionId: revision.id,
202
- columnMappings,
203
- ...(ctx.flags['accept-contract-change'] === true ? { acceptContractChange: true } : {}),
204
- },
205
- }, await client.revision())
167
+ const result = await client.draftCommand(
168
+ datasetBindingCommand(detail.id, revision.id, columnMappings, ctx.flags['accept-contract-change'] === true),
169
+ await client.revision(),
170
+ )
206
171
 
207
- // The tree is told what the draft now says. `backing` is excluded from the diff
208
- // — a rebind is this command's act, not `apply`'s — so nothing else would ever
209
- // reconcile the file, and it would sit stale until the next `pull`.
172
+ // The tree is told what the draft now says, or the next `apply` would read the
173
+ // file's old Dataset as a rebind back to it.
210
174
  const treeRoot = resolve(ctx.cwd, (ctx.flags.dir as string) ?? 'blueprint')
211
175
  let treeUpdated = false
212
176
  if (existsSync(join(treeRoot, 'object-types'))) {