@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.
- package/package.json +4 -4
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +2 -3
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +28 -5
- package/src/commands/local/sync.ts +19 -3
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-person-steps.ts +92 -8
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/main.ts +1 -0
- package/src/vendor/kit-assets.json +6 -6
|
@@ -1,21 +1,29 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
+
import { canPrompt, promptConfirm } from '../../prompt'
|
|
6
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
* Retire an Action's deployment
|
|
9
|
+
* Retire an Action's deployment, so a changed Action can publish (Forge
|
|
10
|
+
* improvement plan X11).
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 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
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
* person with
|
|
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: '
|
|
35
|
+
summary: 'Switch off and retire an Action’s deployment, so a changed Action can publish',
|
|
28
36
|
details: [
|
|
29
|
-
'For an Action
|
|
30
|
-
'
|
|
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 "
|
|
34
|
-
'frontera action
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
57
|
-
text:
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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:
|
|
357
|
-
validationReportId,
|
|
425
|
+
expectedRevision: plan.draftRevision,
|
|
426
|
+
validationReportId: plan.reportId,
|
|
358
427
|
releaseLabel: label,
|
|
359
428
|
releaseNotes: notes,
|
|
360
|
-
|
|
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 (
|
|
365
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
208
|
-
//
|
|
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'))) {
|