@frontera-sdk/cli 1.53.8 → 1.53.10
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/governed-action-api.ts +9 -3
- package/src/blueprint/action-rules.ts +175 -9
- package/src/blueprint/diff.ts +27 -8
- package/src/blueprint/ontology-edit-plan.ts +155 -4
- package/src/blueprint/org-name-check.ts +10 -8
- package/src/blueprint/projection.ts +13 -3
- package/src/blueprint/property-intents.ts +248 -0
- package/src/blueprint/publish-plan.ts +40 -4
- package/src/blueprint/render.ts +4 -0
- package/src/blueprint/tree.ts +22 -0
- package/src/commands/action/deploy.ts +31 -24
- package/src/commands/action/retire.ts +9 -1
- package/src/commands/action/submit.ts +21 -5
- package/src/commands/auth/login.ts +2 -2
- package/src/commands/blueprint/authoring.ts +22 -4
- package/src/commands/blueprint/declarative.ts +140 -32
- package/src/commands/knowledge/index-commands.ts +1 -1
- package/src/commands/local/check.ts +6 -4
- package/src/commands/local/index-commands.ts +8 -2
- package/src/commands/local/sync-request.ts +252 -66
- package/src/commands/local/sync.ts +29 -24
- package/src/device-login.ts +6 -1
- package/src/local/local-actions.ts +13 -1
- package/src/local/local-applied.ts +1 -1
- package/src/local/local-blueprint-schemas.ts +40 -6
- package/src/local/local-dev-references.ts +4 -3
- package/src/local/local-jobs.ts +28 -0
- package/src/local/local-portability.ts +1 -1
- package/src/local/local-sync-link.ts +62 -4
- package/src/local/local-sync-merge.ts +37 -1
- package/src/local/local-sync-plan.ts +44 -6
- package/src/local/local-sync-precheck.ts +2 -1
- package/src/local/local-sync-push.ts +22 -11
- package/src/local/local-sync-remote.ts +13 -8
- package/src/local/local-synthetic-data.ts +2 -2
- package/src/main.ts +7 -2
- package/src/vendor/sdk-sources.json +2 -2
|
@@ -188,9 +188,12 @@ function backingToFile(
|
|
|
188
188
|
? { column: entry.sourcePath, field: entry.sourceField }
|
|
189
189
|
: entry.sourcePath
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
// A set, which the service keeps sorted by property id: written in the type's own property order, so the
|
|
192
|
+
// file reads like the type and stays the same from one read to the next.
|
|
193
|
+
const editableIds = new Set(mapping.editableProperties ?? [])
|
|
194
|
+
const editable = object.properties
|
|
195
|
+
.filter((property) => editableIds.has(property.id))
|
|
196
|
+
.map((property) => property.apiName)
|
|
194
197
|
return {
|
|
195
198
|
dataset,
|
|
196
199
|
mapping: entries,
|
|
@@ -355,6 +358,13 @@ function actionToFile(action: Record<string, unknown>, names: ActionNames): Reco
|
|
|
355
358
|
if (isRecord(input.valueType)) named(input.valueType, 'objectTypeId', 'objectType', names.object)
|
|
356
359
|
}
|
|
357
360
|
|
|
361
|
+
for (const record of Array.isArray(file.records) ? file.records : []) {
|
|
362
|
+
if (!isRecord(record)) continue
|
|
363
|
+
named(record, 'objectTypeId', 'objectType', names.object)
|
|
364
|
+
named(record, 'keyFieldId', 'keyInput', inputNames)
|
|
365
|
+
named(record, 'versionFieldId', 'versionInput', inputNames)
|
|
366
|
+
}
|
|
367
|
+
|
|
358
368
|
for (const criterion of Array.isArray(file.submissionCriteria) ? file.submissionCriteria : []) {
|
|
359
369
|
if (!isRecord(criterion)) continue
|
|
360
370
|
delete criterion.id
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { parseRef } from '../local/local-sync-refs'
|
|
2
|
+
import type { AuthoredFile, DefinitionBundle } from './model'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a type file says about a property the draft holds under another name.
|
|
6
|
+
*
|
|
7
|
+
* A property the file stops naming and one it names for the first time look the
|
|
8
|
+
* same whether the person renamed a field or replaced it, and the two do opposite
|
|
9
|
+
* things to the values behind it. Two keys on the new property say which:
|
|
10
|
+
*
|
|
11
|
+
* renamedFrom: <old> the same property under a new name. Its id, its column,
|
|
12
|
+
* its edited values, its editability and every Action, link
|
|
13
|
+
* and metric that names it stay with it.
|
|
14
|
+
* replaces: <old> a new property. <old> is removed, and its values are not
|
|
15
|
+
* carried over.
|
|
16
|
+
*
|
|
17
|
+
* Read here and taken off the documents, so nothing after this compares or sends
|
|
18
|
+
* them. A key whose old name the draft no longer holds has already happened (the
|
|
19
|
+
* draft was renamed, or the name never reached it) and asks for nothing.
|
|
20
|
+
*/
|
|
21
|
+
export interface PropertyRename { objectApiName: string; from: string; to: string }
|
|
22
|
+
/** A saved object set's file, `object-sets/<ref>.yaml` under the Blueprint folder. */
|
|
23
|
+
export interface ObjectSetDocument { path: string; ref: string; document: Record<string, unknown> }
|
|
24
|
+
export interface PropertyReplacement { objectApiName: string; removed: string; added: string }
|
|
25
|
+
export interface PropertyRefusal { objectApiName: string; message: string; next: string }
|
|
26
|
+
|
|
27
|
+
export interface PropertyIntents {
|
|
28
|
+
/** The files without `renamedFrom` and `replaces`. */
|
|
29
|
+
files: AuthoredFile[]
|
|
30
|
+
/** The draft as it reads once every rename is made: names changed, ids kept. */
|
|
31
|
+
live: DefinitionBundle
|
|
32
|
+
renames: PropertyRename[]
|
|
33
|
+
replacements: PropertyReplacement[]
|
|
34
|
+
refusals: PropertyRefusal[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INTENT_KEYS = ['renamedFrom', 'replaces'] as const
|
|
38
|
+
|
|
39
|
+
type Doc = Record<string, unknown>
|
|
40
|
+
const isDoc = (value: unknown): value is Doc => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
41
|
+
const propertiesOf = (doc: Doc): Doc[] => (Array.isArray(doc.properties) ? doc.properties.filter(isDoc) : [])
|
|
42
|
+
|
|
43
|
+
export function renameCommand(objectApiName: string, from: string, to: string): string {
|
|
44
|
+
return `frontera blueprint rename property ${objectApiName} ${from} ${to}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readPropertyIntents(files: AuthoredFile[], draft: DefinitionBundle, objectSets: ObjectSetDocument[] = []): PropertyIntents {
|
|
48
|
+
const live = structuredClone(draft)
|
|
49
|
+
const renames: PropertyRename[] = []
|
|
50
|
+
const replacements: PropertyReplacement[] = []
|
|
51
|
+
const refusals: PropertyRefusal[] = []
|
|
52
|
+
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (file.kind !== 'object-type') continue
|
|
55
|
+
const object = live.objects.find((candidate) => candidate.apiName === file.apiName)
|
|
56
|
+
if (!object) continue
|
|
57
|
+
const declared = propertiesOf(file.document)
|
|
58
|
+
const declaredNames = new Set(declared.map((property) => String(property.apiName)))
|
|
59
|
+
const liveNames = new Set(object.properties.map((property) => property.apiName))
|
|
60
|
+
const refuse = (message: string, next: string) => refusals.push({ objectApiName: file.apiName, message: `${file.path}: ${message}`, next })
|
|
61
|
+
|
|
62
|
+
const renamedHere = new Map<string, string>()
|
|
63
|
+
const replaced = new Set<string>()
|
|
64
|
+
const replacing = new Set<string>()
|
|
65
|
+
for (const property of declared) {
|
|
66
|
+
const name = String(property.apiName)
|
|
67
|
+
const from = property.renamedFrom
|
|
68
|
+
if (typeof from === 'string' && from !== name && liveNames.has(from)) {
|
|
69
|
+
if (liveNames.has(name)) {
|
|
70
|
+
refuse(`${name} says renamedFrom: ${from}, and ${file.apiName} already has both ${from} and ${name}.`,
|
|
71
|
+
`rename ${from} once ${name} is gone (remove ${name} in an apply of its own), or take renamedFrom off ${name}`)
|
|
72
|
+
} else if (renamedHere.has(from)) {
|
|
73
|
+
refuse(`${renamedHere.get(from)} and ${name} both say renamedFrom: ${from}; one property has one new name.`,
|
|
74
|
+
`keep renamedFrom: ${from} on one of them`)
|
|
75
|
+
} else {
|
|
76
|
+
renamedHere.set(from, name)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const old = property.replaces
|
|
80
|
+
if (typeof old === 'string' && liveNames.has(old) && !declaredNames.has(old) && !liveNames.has(name)) {
|
|
81
|
+
replaced.add(old)
|
|
82
|
+
replacing.add(name)
|
|
83
|
+
replacements.push({ objectApiName: file.apiName, removed: old, added: name })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const [from, to] of renamedHere) {
|
|
87
|
+
renames.push({ objectApiName: file.apiName, from, to })
|
|
88
|
+
object.properties.find((property) => property.apiName === from)!.apiName = to
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const after = new Set(object.properties.map((property) => property.apiName))
|
|
92
|
+
// A calculated field is added from its latitude and longitude, never in place of a column.
|
|
93
|
+
const added = declared
|
|
94
|
+
.filter((property) => !after.has(String(property.apiName)) && !property.calculation && !replacing.has(String(property.apiName)))
|
|
95
|
+
.map((property) => String(property.apiName))
|
|
96
|
+
const removed = [...after].filter((name) => !declaredNames.has(name) && !replaced.has(name))
|
|
97
|
+
if (added.length && removed.length) {
|
|
98
|
+
const one = added.length === 1 && removed.length === 1
|
|
99
|
+
refuse(
|
|
100
|
+
`this edit removes ${removed.join(', ')} and adds ${added.join(', ')} at once, and a rename and a replacement look the same: `
|
|
101
|
+
+ 'a rename keeps the values, edits and references behind the field, a replacement does not carry them over.',
|
|
102
|
+
one
|
|
103
|
+
? `if ${removed[0]} is now ${added[0]}, run \`${renameCommand(file.apiName, removed[0]!, added[0]!)}\` `
|
|
104
|
+
+ `(or write renamedFrom: ${removed[0]} under ${added[0]}); if ${added[0]} replaces it, write replaces: ${removed[0]} under ${added[0]}, then apply again`
|
|
105
|
+
: 'under each new property write renamedFrom: <old name> (the same field, renamed) or replaces: <old name> (a new field; the old one\'s values are not carried over), then apply again',
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const rename of renames) {
|
|
111
|
+
// A new property that takes the old name back is what those references mean now.
|
|
112
|
+
const typeFile = files.find((file) => file.kind === 'object-type' && file.apiName === rename.objectApiName)!
|
|
113
|
+
if (propertiesOf(typeFile.document).some((property) => property.apiName === rename.from)) continue
|
|
114
|
+
const stale = [
|
|
115
|
+
...files.flatMap((file) => namesOf(file, rename.objectApiName, rename.from).map(() => file.path)),
|
|
116
|
+
...objectSets.flatMap((set) => objectSetNamesOf(set, files, objectSets, rename.objectApiName, rename.from).map(() => set.path)),
|
|
117
|
+
]
|
|
118
|
+
if (stale.length) {
|
|
119
|
+
refusals.push({
|
|
120
|
+
objectApiName: rename.objectApiName,
|
|
121
|
+
message: `${[...new Set(stale)].join(', ')} still name${stale.length === 1 ? 's' : ''} ${rename.objectApiName}.${rename.from}, which this apply renames to ${rename.to}.`,
|
|
122
|
+
next: `\`${renameCommand(rename.objectApiName, rename.from, rename.to)}\` changes every file that names it`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { files: files.map(withoutIntents), live, renames, replacements, refusals }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function withoutIntents(file: AuthoredFile): AuthoredFile {
|
|
131
|
+
if (file.kind !== 'object-type' || !propertiesOf(file.document).some((property) => INTENT_KEYS.some((key) => key in property))) return file
|
|
132
|
+
const document = structuredClone(file.document)
|
|
133
|
+
for (const property of propertiesOf(document)) for (const key of INTENT_KEYS) delete property[key]
|
|
134
|
+
return { ...file, document }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Each place a file names `<objectApiName>.<name>`, other than the property's own
|
|
139
|
+
* `apiName`, with how to name `to` there instead: a type's keys, its mapping, its
|
|
140
|
+
* editable list and its location fields; an Action's changes and the subject
|
|
141
|
+
* properties it observes; a link's ends; a metric's measures, filters, dimensions
|
|
142
|
+
* and time property.
|
|
143
|
+
*/
|
|
144
|
+
export function namesOf(file: AuthoredFile, objectApiName: string, name: string): Array<(to: string) => void> {
|
|
145
|
+
const places: Array<(to: string) => void> = []
|
|
146
|
+
const doc = file.document
|
|
147
|
+
const at = (holder: Doc | unknown[], key: string | number) => places.push((to) => { (holder as Record<string | number, unknown>)[key] = to })
|
|
148
|
+
|
|
149
|
+
if (file.kind === 'object-type' && file.apiName === objectApiName) {
|
|
150
|
+
for (const key of ['primaryKey', 'title']) if (doc[key] === name) at(doc, key)
|
|
151
|
+
const backing = isDoc(doc.backing) ? doc.backing : undefined
|
|
152
|
+
const mapping = isDoc(backing?.mapping) ? backing.mapping : undefined
|
|
153
|
+
if (mapping && Object.hasOwn(mapping, name)) {
|
|
154
|
+
places.push((to) => {
|
|
155
|
+
backing!.mapping = Object.fromEntries(Object.entries(mapping).map(([key, value]) => [key === name ? to : key, value]))
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(backing?.editable)) backing.editable.forEach((entry, index) => { if (entry === name) at(backing.editable as unknown[], index) })
|
|
159
|
+
for (const property of propertiesOf(doc)) {
|
|
160
|
+
const calculation = property.calculation
|
|
161
|
+
if (isDoc(calculation)) for (const key of ['latitude', 'longitude']) if (calculation[key] === name) at(calculation, key)
|
|
162
|
+
}
|
|
163
|
+
return places
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const subject = file.kind === 'action' && isDoc(doc.subject) ? doc.subject.objectType : undefined
|
|
167
|
+
const measured = file.kind === 'metric' && doc.objectType === objectApiName
|
|
168
|
+
const walk = (node: unknown, key?: string): void => {
|
|
169
|
+
if (Array.isArray(node)) {
|
|
170
|
+
node.forEach((entry, index) => {
|
|
171
|
+
if (measured && key === 'dimensions' && entry === name) at(node, index)
|
|
172
|
+
else walk(entry)
|
|
173
|
+
})
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (!isDoc(node)) return
|
|
177
|
+
if (node.property === name) {
|
|
178
|
+
const own = node.objectType === objectApiName
|
|
179
|
+
|| (node.objectType === undefined && ((subject === objectApiName && node.kind === 'observation_property') || measured))
|
|
180
|
+
if (own) at(node, 'property')
|
|
181
|
+
}
|
|
182
|
+
if (measured && node.timeseries === name) at(node, 'timeseries')
|
|
183
|
+
for (const [child, value] of Object.entries(node)) walk(value, child)
|
|
184
|
+
}
|
|
185
|
+
if (file.kind === 'action' || file.kind === 'link-type' || file.kind === 'metric') walk(doc)
|
|
186
|
+
return places
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Each place a saved object set names `<objectApiName>.<name>`: a condition's
|
|
191
|
+
* `property` in a filter over a set of that type. The type a filter reads is the
|
|
192
|
+
* type of the set it narrows, followed through links (by the project's link files)
|
|
193
|
+
* and references to other sets (by their files' `objectType`).
|
|
194
|
+
*/
|
|
195
|
+
export function objectSetNamesOf(
|
|
196
|
+
set: ObjectSetDocument,
|
|
197
|
+
files: AuthoredFile[],
|
|
198
|
+
sets: ObjectSetDocument[],
|
|
199
|
+
objectApiName: string,
|
|
200
|
+
name: string,
|
|
201
|
+
): Array<(to: string) => void> {
|
|
202
|
+
const places: Array<(to: string) => void> = []
|
|
203
|
+
const ends = new Map(files.filter((file) => file.kind === 'link-type').map((file) => {
|
|
204
|
+
const end = (key: string) => (isDoc(file.document[key]) ? (file.document[key] as Doc).objectType : undefined)
|
|
205
|
+
return [file.apiName, { from: end('from'), to: end('to') }]
|
|
206
|
+
}))
|
|
207
|
+
const typeOf = (expr: unknown): unknown => {
|
|
208
|
+
if (!isDoc(expr)) return undefined
|
|
209
|
+
switch (expr.type) {
|
|
210
|
+
case 'base':
|
|
211
|
+
case 'static':
|
|
212
|
+
return expr.objectType
|
|
213
|
+
case 'filter':
|
|
214
|
+
return typeOf(expr.objectSet)
|
|
215
|
+
case 'searchAround': {
|
|
216
|
+
const from = typeOf(expr.objectSet)
|
|
217
|
+
const link = ends.get(String(expr.link))
|
|
218
|
+
return from === undefined || !link ? undefined : link.from === from ? link.to : link.to === from ? link.from : undefined
|
|
219
|
+
}
|
|
220
|
+
case 'union':
|
|
221
|
+
case 'intersect':
|
|
222
|
+
case 'subtract':
|
|
223
|
+
return Array.isArray(expr.objectSets) ? typeOf(expr.objectSets[0]) : undefined
|
|
224
|
+
case 'reference': {
|
|
225
|
+
const ref = typeof expr.objectSetId === 'string' ? parseRef(expr.objectSetId) : null
|
|
226
|
+
return ref?.kind === 'objectSet'
|
|
227
|
+
? sets.find((other) => other.ref === ref.name || other.document.name === ref.name)?.document.objectType
|
|
228
|
+
: undefined
|
|
229
|
+
}
|
|
230
|
+
default:
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const conditions = (where: unknown): void => {
|
|
235
|
+
if (Array.isArray(where)) return where.forEach(conditions)
|
|
236
|
+
if (!isDoc(where)) return
|
|
237
|
+
if (where.property === name) places.push((to) => { where.property = to })
|
|
238
|
+
for (const key of ['and', 'or', 'not']) conditions(where[key])
|
|
239
|
+
}
|
|
240
|
+
const walk = (expr: unknown): void => {
|
|
241
|
+
if (!isDoc(expr)) return
|
|
242
|
+
if (expr.type === 'filter' && typeOf(expr.objectSet) === objectApiName) conditions(expr.where)
|
|
243
|
+
walk(expr.objectSet)
|
|
244
|
+
if (Array.isArray(expr.objectSets)) expr.objectSets.forEach(walk)
|
|
245
|
+
}
|
|
246
|
+
walk(set.document.definition)
|
|
247
|
+
return places
|
|
248
|
+
}
|
|
@@ -52,6 +52,8 @@ export interface ChangedAction {
|
|
|
52
52
|
apiName: string
|
|
53
53
|
/** As on `NewAction`: known when the release changes the Action's effect kind. */
|
|
54
54
|
editsBlueprint?: boolean
|
|
55
|
+
/** The object type whose change changed it, when its own definition did not change. */
|
|
56
|
+
objectType?: string
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export interface PublishPlan {
|
|
@@ -120,7 +122,22 @@ const NEXT_STEPS: Record<string, (name: string, signIn: string | null) => { why:
|
|
|
120
122
|
ACTION_BINDING_STATE_CONTRADICTORY: () => ({ why: 'Its deployment state does not add up.', commands: ['frontera action list'] }),
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
/** The object types this release changes other than by adding, by id: a property renamed or retyped changes the contract of an Action that uses it. */
|
|
126
|
+
function changedObjectTypes(report: ValidationReport): Map<string, string> {
|
|
127
|
+
return new Map((report.evolution?.changes ?? [])
|
|
128
|
+
.filter((change) => change.subject.kind === 'object' && change.classification !== 'additive')
|
|
129
|
+
.map((change) => [change.subject.id, change.subject.label]))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether `publishPlan` needs each Action's object type: a switched-off Action whose own definition this release leaves alone, beside a type it changes. */
|
|
133
|
+
export function needsActionSubjects(report: ValidationReport): boolean {
|
|
134
|
+
const own = new Set((report.evolution?.changes ?? []).filter((change) => change.subject.kind === 'action').map((change) => change.subject.id))
|
|
135
|
+
return changedObjectTypes(report).size > 0
|
|
136
|
+
&& (report.bindingReadiness?.actions ?? []).some((action) => action.reason === 'ACTION_DISABLED' && !own.has(action.actionDefinitionId))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param actionSubjects each Action's object type id, read from the draft when `needsActionSubjects` says so. */
|
|
140
|
+
export function publishPlan(report: ValidationReport, signIn: string | null, profile?: string, actionSubjects?: ReadonlyMap<string, string>): PublishPlan {
|
|
124
141
|
const changes = report.evolution?.changes ?? []
|
|
125
142
|
const actions = report.bindingReadiness?.actions ?? []
|
|
126
143
|
// Ids to names: a blocker names its subject only by an id in its path.
|
|
@@ -141,6 +158,7 @@ export function publishPlan(report: ValidationReport, signIn: string | null, pro
|
|
|
141
158
|
const newActionIds = new Set(changes.filter((change) => change.code === NEW_ACTION_ENVIRONMENT_CHANGE).map((change) => change.subject.id))
|
|
142
159
|
const changedActionIds = new Set(changes.filter((change) => change.subject.kind === 'action').map((change) => change.subject.id))
|
|
143
160
|
const editsBlueprint = editsBlueprintById(changes)
|
|
161
|
+
const changedTypes = changedObjectTypes(report)
|
|
144
162
|
return {
|
|
145
163
|
reportId: report.id,
|
|
146
164
|
draftRevision: report.draftRevision,
|
|
@@ -158,16 +176,34 @@ export function publishPlan(report: ValidationReport, signIn: string | null, pro
|
|
|
158
176
|
}
|
|
159
177
|
}),
|
|
160
178
|
changedSwitchedOff: actions
|
|
161
|
-
.filter((action) => action.reason === 'ACTION_DISABLED' &&
|
|
162
|
-
.
|
|
179
|
+
.filter((action) => action.reason === 'ACTION_DISABLED' && !newActionIds.has(action.actionDefinitionId))
|
|
180
|
+
.flatMap((action) => {
|
|
181
|
+
const objectType = changedActionIds.has(action.actionDefinitionId) ? undefined : changedTypes.get(actionSubjects?.get(action.actionDefinitionId) ?? '')
|
|
182
|
+
if (!changedActionIds.has(action.actionDefinitionId) && objectType === undefined) return []
|
|
163
183
|
const edits = editsBlueprint.get(action.actionDefinitionId)
|
|
164
|
-
return { apiName: action.actionApiName, ...(edits === undefined ? {} : { editsBlueprint: edits }) }
|
|
184
|
+
return [{ apiName: action.actionApiName, ...(edits === undefined ? {} : { editsBlueprint: edits }), ...(objectType ? { objectType } : {}) }]
|
|
165
185
|
}),
|
|
166
186
|
// Only a report that carries the list: one without it says nothing either way.
|
|
167
187
|
unchanged: Array.isArray(report.evolution?.changes) && changes.length === 0,
|
|
168
188
|
}
|
|
169
189
|
}
|
|
170
190
|
|
|
191
|
+
/**
|
|
192
|
+
* What a person runs after clearing one blocker, so a publish is not tried while
|
|
193
|
+
* another remains: the other blockers' commands, then the publish, written with
|
|
194
|
+
* an instruction to fill in when the release needs one. An Action with no
|
|
195
|
+
* deployment decision is left to the publish, which records it.
|
|
196
|
+
*/
|
|
197
|
+
export function stepsToPublish(plan: PublishPlan, profile?: string): string[] {
|
|
198
|
+
const blockers = plan.blockers.filter((blocker) => blocker.code !== 'ACTION_UNBOUND')
|
|
199
|
+
.flatMap((blocker) => blocker.next?.commands ?? [])
|
|
200
|
+
.filter((command) => !command.startsWith('frontera blueprint publish'))
|
|
201
|
+
const publish = plan.instructionsNeeded.length
|
|
202
|
+
? 'frontera blueprint publish --label cli-release --instruction "<how existing data and environments take these changes>"'
|
|
203
|
+
: 'frontera blueprint publish'
|
|
204
|
+
return [...new Set([...blockers, aimed(publish, profile)])]
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
/** One instruction per change the publish must explain: the person's text, and a new Action's own. */
|
|
172
208
|
export function migrationInstructions(plan: PublishPlan, instruction: string | undefined): Array<{ changeId: string; instruction: string }> {
|
|
173
209
|
return [
|
package/src/blueprint/render.ts
CHANGED
|
@@ -59,6 +59,10 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
|
|
|
59
59
|
: { marker: MARKER.update, kind: 'object-type', apiName: change.objectApiName, detail: move })
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
for (const refusal of plan.propertyRefusals ?? []) {
|
|
63
|
+
rows.push({ marker: '✕', kind: 'object-type', apiName: refusal.objectApiName, detail: `cannot apply: ${refusal.message} Next: ${refusal.next}` })
|
|
64
|
+
}
|
|
65
|
+
|
|
62
66
|
const withheld = withheldNote(plan)
|
|
63
67
|
|
|
64
68
|
if (rows.length === 0) {
|
package/src/blueprint/tree.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ArtifactKind,
|
|
11
11
|
type AuthoredFile,
|
|
12
12
|
} from './model'
|
|
13
|
+
import type { ObjectSetDocument } from './property-intents'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Reading and writing the `blueprint/` tree. No network, no resolution.
|
|
@@ -146,6 +147,27 @@ export function removeFromTree(root: string, paths: string[]): string[] {
|
|
|
146
147
|
return removed
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The saved object sets' files, `object-sets/` beside the model's folders. Not part
|
|
152
|
+
* of the model `apply` sends (`local dev` saves them itself), but they name its
|
|
153
|
+
* properties, so a property rename reads and rewrites them.
|
|
154
|
+
*/
|
|
155
|
+
export function readObjectSets(root: string): ObjectSetDocument[] {
|
|
156
|
+
const directory = join(root, 'object-sets')
|
|
157
|
+
if (!existsSync(directory)) return []
|
|
158
|
+
return readdirSync(directory).sort().flatMap((entry) => {
|
|
159
|
+
const extension = extname(entry)
|
|
160
|
+
if (!READABLE.has(extension) || entry.startsWith('.') || entry.startsWith('#')) return []
|
|
161
|
+
const path = `object-sets/${entry}`
|
|
162
|
+
// One that does not parse is skipped, as `local dev` skips it when it saves the sets.
|
|
163
|
+
try {
|
|
164
|
+
return [{ path, ref: entry.slice(0, -extension.length), document: parseDocument(path, readFileSync(join(directory, entry), 'utf8')) }]
|
|
165
|
+
} catch {
|
|
166
|
+
return []
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
149
171
|
export function writeTree(root: string, files: AuthoredFile[]): string[] {
|
|
150
172
|
const written: string[] = []
|
|
151
173
|
for (const file of files) {
|
|
@@ -4,7 +4,7 @@ import { GovernedActionApi } from '../../api/governed-action-api'
|
|
|
4
4
|
import { credentialStore } from '../../credential-store'
|
|
5
5
|
import { readDeviceSession } from '../../device-login'
|
|
6
6
|
import { CliError, UsageError } from '../../errors'
|
|
7
|
-
import { deriveOntologyEditPlan, PlanDerivationError } from '../../blueprint/ontology-edit-plan'
|
|
7
|
+
import { deriveOntologyEditPlan, PlanDerivationError, type ObjectTypeShape } from '../../blueprint/ontology-edit-plan'
|
|
8
8
|
import { validationFindings } from '../../local/local-actions'
|
|
9
9
|
import { selectedOrganization, setSelectedOrganization } from '../../organization'
|
|
10
10
|
import { canPrompt, promptConfirm } from '../../prompt'
|
|
@@ -73,15 +73,7 @@ export const actionDeploy: Command = {
|
|
|
73
73
|
const published = await client.publishedAction(apiName)
|
|
74
74
|
const definition = published.definition
|
|
75
75
|
|
|
76
|
-
const objectType = await client.
|
|
77
|
-
await resolveObjectTypeApiName(client, definition.subject.objectTypeId, apiName),
|
|
78
|
-
)
|
|
79
|
-
if (!objectType) {
|
|
80
|
-
throw new CliError(`Object type for "${apiName}" is not in the active release.`, {
|
|
81
|
-
code: 'NOT_FOUND',
|
|
82
|
-
hint: 'Publish a release that carries it, then deploy.',
|
|
83
|
-
})
|
|
84
|
-
}
|
|
76
|
+
const objectType = await activeObjectTypeOf(client, definition.subject.objectTypeId, `The object type "${apiName}" acts on`)
|
|
85
77
|
|
|
86
78
|
// An attachment Action (L6): the file arrives in --file-input, and the write fills --attach and/or --attach-item-id.
|
|
87
79
|
const attachProperty = flagString(ctx, 'attach')
|
|
@@ -98,9 +90,15 @@ export const actionDeploy: Command = {
|
|
|
98
90
|
? { uploadInput: fileInput, ...(attachProperty ? { property: attachProperty } : {}), ...(attachItemId ? { itemIdProperty: attachItemId } : {}), ...(mediaSet ? { mediaSetId: mediaSet } : {}) }
|
|
99
91
|
: undefined
|
|
100
92
|
|
|
93
|
+
// Each record the Action writes beside its subject, from the same active release.
|
|
94
|
+
const relatedTypes = []
|
|
95
|
+
for (const record of definition.records ?? []) {
|
|
96
|
+
relatedTypes.push(await activeObjectTypeOf(client, record.objectTypeId, `The object type of record "${record.key}" of "${apiName}"`))
|
|
97
|
+
}
|
|
98
|
+
|
|
101
99
|
let plan
|
|
102
100
|
try {
|
|
103
|
-
plan = deriveOntologyEditPlan(definition, objectType, attachment)
|
|
101
|
+
plan = deriveOntologyEditPlan(definition, objectType, attachment, relatedTypes)
|
|
104
102
|
} catch (err) {
|
|
105
103
|
if (err instanceof PlanDerivationError) {
|
|
106
104
|
throw new CliError(err.message, { code: 'VALIDATION_ERROR', hint: err.hint })
|
|
@@ -113,12 +111,21 @@ export const actionDeploy: Command = {
|
|
|
113
111
|
...(plan.mediaAttachment ? [`${[plan.mediaAttachment.propertyApiName, plan.mediaAttachment.itemIdProperty].filter(Boolean).join(' and ')} ← the file in ${plan.mediaAttachment.uploadParameter} (Media Set ${plan.mediaAttachment.mediaSetId})`] : []),
|
|
114
112
|
].join(', ')
|
|
115
113
|
|
|
114
|
+
// Each record the write path writes beside its subject, one line each.
|
|
115
|
+
const creates = (plan.related ?? []).map((step) => {
|
|
116
|
+
const sets = Object.entries(step.set).map(([property, parameter]) => `${property} ← ${parameter}`).join(', ')
|
|
117
|
+
if (step.op === 'create') return ` and creates ${step.record} (${step.objectTypeApiName}), filed under ${step.pkParameter}: ${sets}\n`
|
|
118
|
+
const checked = `${step.objectTypeApiName}), addressed by ${step.pkParameter}, compare-and-set on ${step.expectedVersionParameter}`
|
|
119
|
+
return step.op === 'delete' ? ` and deletes ${step.record} (${checked}\n` : ` and patches ${step.record} (${checked}: ${sets}\n`
|
|
120
|
+
}).join('')
|
|
121
|
+
|
|
116
122
|
if (flagBool(ctx, 'dry-run')) {
|
|
117
123
|
return {
|
|
118
124
|
data: { plan, dryRun: true },
|
|
119
125
|
text: `${plan.op} ${plan.objectTypeApiName}, addressed by ${plan.pkParameter}\n`
|
|
120
126
|
+ ` sets ${writes}\n`
|
|
121
127
|
+ (plan.expectedVersionParameter ? ` compare-and-set on ${plan.expectedVersionParameter}\n` : '')
|
|
128
|
+
+ creates
|
|
122
129
|
+ '\nNothing was written. Re-run without --dry-run to build it.',
|
|
123
130
|
}
|
|
124
131
|
}
|
|
@@ -181,7 +188,7 @@ export const actionDeploy: Command = {
|
|
|
181
188
|
stateId: binding.stateId,
|
|
182
189
|
validationReportId: validation.id,
|
|
183
190
|
}
|
|
184
|
-
const summary = ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}`
|
|
191
|
+
const summary = ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}${creates ? `\n${creates.trimEnd()}` : ''}`
|
|
185
192
|
const review = `frontera action review ${binding.id} --report ${validation.id}${at}`
|
|
186
193
|
const again = `frontera action deploy ${apiName} --yes${at}`
|
|
187
194
|
const secondPerson = `a second person who may review Actions turns it on with: ${review}`
|
|
@@ -262,22 +269,22 @@ export const actionDeploy: Command = {
|
|
|
262
269
|
}
|
|
263
270
|
|
|
264
271
|
/**
|
|
265
|
-
* The Action names
|
|
266
|
-
*
|
|
267
|
-
*
|
|
272
|
+
* The Action names object types by id; every read route addresses them by API
|
|
273
|
+
* name. One list read bridges them, rather than making the caller pass a name
|
|
274
|
+
* the Action already knows. `named` says which type a refusal is about.
|
|
268
275
|
*/
|
|
269
|
-
async function
|
|
276
|
+
async function activeObjectTypeOf(
|
|
270
277
|
client: GovernedActionApi,
|
|
271
278
|
objectTypeId: string,
|
|
272
|
-
|
|
273
|
-
): Promise<
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
if (!
|
|
277
|
-
throw new CliError(
|
|
279
|
+
named: string,
|
|
280
|
+
): Promise<ObjectTypeShape> {
|
|
281
|
+
const apiName = (await client.listActiveObjectTypes()).find((t) => t.id === objectTypeId)?.apiName
|
|
282
|
+
const objectType = apiName ? await client.activeObjectType(apiName) : null
|
|
283
|
+
if (!objectType) {
|
|
284
|
+
throw new CliError(`${named} is not in the active release.`, {
|
|
278
285
|
code: 'NOT_FOUND',
|
|
279
|
-
hint: '
|
|
286
|
+
hint: 'Publish a release that carries it, then deploy.',
|
|
280
287
|
})
|
|
281
288
|
}
|
|
282
|
-
return
|
|
289
|
+
return objectType
|
|
283
290
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
|
+
import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
|
|
3
4
|
import { GovernedActionApi, type ExternalWritePath } from '../../api/governed-action-api'
|
|
5
|
+
import { publishPlan, stepsToPublish } from '../../blueprint/publish-plan'
|
|
4
6
|
import { CliError, UsageError } from '../../errors'
|
|
5
7
|
import { canPrompt, promptConfirm } from '../../prompt'
|
|
6
8
|
import { flagBool, flagString, type Command } from '../types'
|
|
@@ -99,6 +101,12 @@ export const actionRetire: Command = {
|
|
|
99
101
|
? `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
102
|
+ `\`frontera action deploy ${apiName}${at}\` builds it and prints the command they run.\n\n`
|
|
101
103
|
: ''
|
|
104
|
+
// What publishing needs now, read as `blueprint publish` reads it: another Action still to retire
|
|
105
|
+
// comes before the publish, and the publish carries its instruction when the release needs one.
|
|
106
|
+
const profile = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : undefined
|
|
107
|
+
const toPublish = await new BlueprintAuthoringApi(ctx.apiUrl, ctx.token).validate()
|
|
108
|
+
.then((report) => stepsToPublish(publishPlan(report, null, profile), profile))
|
|
109
|
+
.catch(() => [`frontera blueprint publish${at}`])
|
|
102
110
|
const what = running
|
|
103
111
|
? `Switched off the running deployment of "${apiName}" and retired it. "${apiName}" does not run until it is deployed again.`
|
|
104
112
|
: current
|
|
@@ -106,7 +114,7 @@ export const actionRetire: Command = {
|
|
|
106
114
|
: `Retired the deployment of "${apiName}" that was still draining.`
|
|
107
115
|
return {
|
|
108
116
|
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
|
|
117
|
+
text: `${what}\n\n${secondPerson}Next:\n${[...toPublish, ...(running ? [`frontera action deploy ${apiName}${at}`] : [])].map((command) => ` ${command}`).join('\n')}`,
|
|
110
118
|
}
|
|
111
119
|
},
|
|
112
120
|
}
|
|
@@ -4,6 +4,7 @@ import { toEnvelope } from '../../exit'
|
|
|
4
4
|
import { parseInputFlag } from '../automation/run'
|
|
5
5
|
import { flagString, type Command } from '../types'
|
|
6
6
|
import { criteriaRefusal } from './criteria-refusal'
|
|
7
|
+
import { whyNot } from './list'
|
|
7
8
|
import { requestSummary } from './request-summary'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -23,7 +24,8 @@ import { requestSummary } from './request-summary'
|
|
|
23
24
|
/**
|
|
24
25
|
* `priority=2` is the number 2 and `title=Broken` the string — unless the
|
|
25
26
|
* Action declares the input a string, when the text is taken as written, so
|
|
26
|
-
* `ticketId=123` stays "123".
|
|
27
|
+
* `ticketId=123` stays "123". `assignee=null` is null for every input, which
|
|
28
|
+
* empties the property it sets; the text "null" goes through `--input`.
|
|
27
29
|
*/
|
|
28
30
|
function pair(raw: string, stringInputs: ReadonlySet<string>): [string, unknown] {
|
|
29
31
|
const eq = raw.indexOf('=')
|
|
@@ -32,6 +34,7 @@ function pair(raw: string, stringInputs: ReadonlySet<string>): [string, unknown]
|
|
|
32
34
|
}
|
|
33
35
|
const key = raw.slice(0, eq)
|
|
34
36
|
const text = raw.slice(eq + 1)
|
|
37
|
+
if (text === 'null') return [key, null]
|
|
35
38
|
if (stringInputs.has(key)) return [key, text]
|
|
36
39
|
try {
|
|
37
40
|
return [key, JSON.parse(text)]
|
|
@@ -48,7 +51,7 @@ export const actionSubmit: Command = {
|
|
|
48
51
|
scope: 'workspace' as const,
|
|
49
52
|
args: [
|
|
50
53
|
{ name: 'apiName', required: true, description: 'The Action to submit, as `action available` lists it' },
|
|
51
|
-
{ name: 'key=value...', required: false, description: 'Parameters; text for string inputs, otherwise read as JSON when it parses' },
|
|
54
|
+
{ name: 'key=value...', required: false, description: 'Parameters; text for string inputs, otherwise read as JSON when it parses. name=null is null for any input, which clears what it sets' },
|
|
52
55
|
],
|
|
53
56
|
flags: {
|
|
54
57
|
input: 'string',
|
|
@@ -60,12 +63,14 @@ export const actionSubmit: Command = {
|
|
|
60
63
|
},
|
|
61
64
|
summary: 'Submit a request to a deployed Action',
|
|
62
65
|
details: [
|
|
66
|
+
'`name=null` empties the property an optional input sets (no default); leaving the input out keeps it as it is. For the text "null", use `--input \'{"name":"null"}\'`.',
|
|
63
67
|
'On the local runtime, `--as <persona>` submits as a local persona (`--as submitter`), so a later step can be decided by another one. Refused anywhere else.',
|
|
64
68
|
],
|
|
65
69
|
examples: [
|
|
66
70
|
'frontera action submit createTicket title=Broken priority=2',
|
|
67
71
|
'frontera action submit escalateTicket --subject TKT-1 --subject-version 0',
|
|
68
72
|
'frontera action submit escalateTicket id=TKT-1 expectedVersion=0',
|
|
73
|
+
'frontera action submit updateTicket id=TKT-1 expectedVersion=3 owner=null',
|
|
69
74
|
'frontera action submit createTicket --input @params.json',
|
|
70
75
|
'frontera action submit createTicket --idempotency-key retry-2026-09-26-01',
|
|
71
76
|
],
|
|
@@ -87,7 +92,14 @@ export const actionSubmit: Command = {
|
|
|
87
92
|
// refused here, by name, instead of by the service without one.
|
|
88
93
|
const action = (await client.discover()).find((a) => a.apiName === apiName)
|
|
89
94
|
if (!action) {
|
|
90
|
-
|
|
95
|
+
// Published but not running (switched off after a change to it or its object type, never
|
|
96
|
+
// deployed): said as `action list` says it, with the command that turns it on.
|
|
97
|
+
const listed = await client.listDeployableActions().catch(() => null)
|
|
98
|
+
const entry = Array.isArray(listed) ? listed.find((e) => e.definition?.apiName === apiName) : undefined
|
|
99
|
+
const why = entry
|
|
100
|
+
? whyNot(apiName, { published: true, deploymentStatus: entry.deploymentStatus, reason: entry.reason ?? null, available: entry.availability === 'available' && entry.executable !== false })
|
|
101
|
+
: ''
|
|
102
|
+
throw new CliError(`You cannot submit "${apiName}" in this workspace${why ? `. ${apiName}: ${why}` : '.'}`, {
|
|
91
103
|
code: 'NOT_FOUND',
|
|
92
104
|
hint: 'frontera action available — to list the Actions you can submit',
|
|
93
105
|
})
|
|
@@ -189,10 +201,14 @@ function transient(err: unknown): boolean {
|
|
|
189
201
|
return typeof status === 'number' && (status >= 500 || status === 429)
|
|
190
202
|
}
|
|
191
203
|
|
|
192
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Inputs the Action declares as strings, whose text must not be read as JSON:
|
|
206
|
+
* `type: 'string'`, or `['string', 'null']` for an optional input that also
|
|
207
|
+
* takes null (an older service says only `'string'`).
|
|
208
|
+
*/
|
|
193
209
|
function stringInputs(action: ActionDiscovery): ReadonlySet<string> {
|
|
194
210
|
const properties = action.inputSchema?.properties?.input?.properties ?? {}
|
|
195
211
|
return new Set(Object.entries(properties)
|
|
196
|
-
.filter(([, schema]) => (schema as { type?: unknown } | null)?.type
|
|
212
|
+
.filter(([, schema]) => ([] as unknown[]).concat((schema as { type?: unknown } | null)?.type).includes('string'))
|
|
197
213
|
.map(([name]) => name))
|
|
198
214
|
}
|
|
@@ -39,7 +39,7 @@ export const authLogin: Command = {
|
|
|
39
39
|
flagHelp: { 'no-open': 'print the approval address without opening a browser' },
|
|
40
40
|
summary: 'Sign in as yourself in the browser, for the commands that act as a person',
|
|
41
41
|
details: [
|
|
42
|
-
'Prints a code and opens the platform’s approval page; approve the code there while signed in. The CLI then keeps a session for the selected profile, beside its key, and uses it
|
|
42
|
+
'Prints a code and opens the platform’s approval page; approve the code there while signed in. The CLI then keeps a session for the selected profile, beside its key, and uses it for commands that act as a person (they refuse API keys), for organization commands when the profile holds a workspace key, and for a project’s change requests when the profile is the one `frontera local sync connect` names. Every other command keeps using the profile’s key. The session lasts 12 hours; `frontera auth logout` ends it sooner.',
|
|
43
43
|
'Approve only a code you started yourself, in your own terminal, just now.',
|
|
44
44
|
],
|
|
45
45
|
examples: ['frontera auth login --browser', 'frontera auth login --browser --profile acme --no-open'],
|
|
@@ -61,7 +61,7 @@ export const authLogin: Command = {
|
|
|
61
61
|
await saveDeviceSession(store, profile, { ...session, ...(email ? { email } : {}) })
|
|
62
62
|
return {
|
|
63
63
|
data: { profile, signedIn: true, email, expiresAt: session.expiresAt, reused: false },
|
|
64
|
-
text: `Signed in for ${profile}${email ? ` as ${email}` : ''} until ${session.expiresAt}. Commands that act as a person use this session; every other command keeps using the profile's key.`,
|
|
64
|
+
text: `Signed in for ${profile}${email ? ` as ${email}` : ''} until ${session.expiresAt}. Commands that act as a person use this session, as do organization commands on a profile that holds a workspace key and a project's change requests on its \`local sync\` profile; every other command keeps using the profile's key.`,
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
}
|