@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
|
@@ -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
|
+
}
|
package/src/blueprint/render.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
105
|
-
const binding = await
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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}
|
|
49
|
+
return `no write path${code} — \`frontera action deploy ${apiName}\``
|
|
50
50
|
case 'disabled':
|
|
51
|
-
return `ships switched off${code} — \`frontera action deploy ${apiName}
|
|
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
|
|
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 "…"
|
|
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
|
},
|