@frontera-sdk/cli 1.53.6 → 1.53.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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
|
@@ -23,15 +23,19 @@ import {
|
|
|
23
23
|
} from '../../blueprint/compile'
|
|
24
24
|
import { metricRuleProblems } from '../../blueprint/metric-rules'
|
|
25
25
|
import { actionRuleProblems, type ActionRuleProblems, type InheritedConflict } from '../../blueprint/action-rules'
|
|
26
|
-
import { pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
26
|
+
import { datasetBindingCommand, pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
27
27
|
import { nameCheckWords, orgNameCheck } from '../../blueprint/org-name-check'
|
|
28
28
|
import {
|
|
29
29
|
assertPrunesLeaveNoDanglingReference,
|
|
30
30
|
diff,
|
|
31
|
+
driftOf,
|
|
32
|
+
editableChanges,
|
|
31
33
|
isEmpty,
|
|
32
34
|
satisfies,
|
|
35
|
+
type DatasetIndex,
|
|
33
36
|
type ModelOperation,
|
|
34
37
|
type Plan,
|
|
38
|
+
type RebindChange,
|
|
35
39
|
} from '../../blueprint/diff'
|
|
36
40
|
import {
|
|
37
41
|
API_NAME_RULE,
|
|
@@ -135,6 +139,66 @@ async function datasetNamesByRevision(
|
|
|
135
139
|
return names
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
/**
|
|
143
|
+
* What `plan` needs from the Datasets: the name each pinned revision belongs to, and the
|
|
144
|
+
* current revision of a Dataset a file names. The Dataset list names every current
|
|
145
|
+
* revision, so revisions are read only for a pin that is not current (it may have
|
|
146
|
+
* drifted) and for a Dataset a file names that its type does not read now (it may move).
|
|
147
|
+
* When every type reads its Dataset's current revision, nothing beyond the list is read.
|
|
148
|
+
* Undefined when this credential cannot read Datasets, so nothing is planned from a guess.
|
|
149
|
+
*/
|
|
150
|
+
async function readDatasetIndex(
|
|
151
|
+
client: BlueprintAuthoringApi,
|
|
152
|
+
files: AuthoredFile[],
|
|
153
|
+
live: DefinitionBundle,
|
|
154
|
+
): Promise<DatasetIndex | undefined> {
|
|
155
|
+
const pinOf = (apiName: string) => (live.objects.find((object) => object.apiName === apiName)
|
|
156
|
+
?.governance.sourceMappings[0] as { datasetRevisionId?: string } | undefined)?.datasetRevisionId
|
|
157
|
+
const named = files.flatMap((file) => {
|
|
158
|
+
const dataset = file.kind === 'object-type' ? (file.document.backing as { dataset?: unknown } | undefined)?.dataset : undefined
|
|
159
|
+
return typeof dataset === 'string' && dataset && live.objects.some((object) => object.apiName === file.apiName)
|
|
160
|
+
? [{ dataset, pin: pinOf(file.apiName) }]
|
|
161
|
+
: []
|
|
162
|
+
})
|
|
163
|
+
const pins = new Set([...named.flatMap((entry) => entry.pin ?? []), ...driftOf(live).map((pin) => pin.datasetRevisionId)])
|
|
164
|
+
if (named.length === 0 && pins.size === 0) return undefined
|
|
165
|
+
const readable = await readableDatasets(client)
|
|
166
|
+
if (!readable) return undefined
|
|
167
|
+
|
|
168
|
+
const byName = new Map(readable.flatMap((dataset) => (dataset.id && dataset.name ? [[dataset.name, { ...dataset, id: dataset.id }] as const] : [])))
|
|
169
|
+
const index: DatasetIndex = { nameByRevisionId: new Map(), current: new Map() }
|
|
170
|
+
for (const [name, dataset] of byName) {
|
|
171
|
+
if (dataset.currentRevisionId) index.nameByRevisionId.set(dataset.currentRevisionId, name)
|
|
172
|
+
}
|
|
173
|
+
const read = new Set<string>()
|
|
174
|
+
const readRevisions = async (name: string, dataset: { id: string; currentRevisionId?: string }) => {
|
|
175
|
+
if (read.has(name)) return
|
|
176
|
+
read.add(name)
|
|
177
|
+
const revisions = await client.datasetRevisions(dataset.id)
|
|
178
|
+
for (const revision of revisions) {
|
|
179
|
+
if (revision.id) index.nameByRevisionId.set(revision.id, name)
|
|
180
|
+
}
|
|
181
|
+
const current = pickCurrentRevision(dataset, revisions)
|
|
182
|
+
if (!current?.id) return
|
|
183
|
+
index.current.set(name, {
|
|
184
|
+
id: current.id,
|
|
185
|
+
...(current.schemaDigest ? { schemaDigest: current.schemaDigest } : {}),
|
|
186
|
+
...(current.columns ? { columns: current.columns.flatMap((column) => (column.name ? [column.name] : [])) } : {}),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
for (const { dataset, pin } of named) {
|
|
190
|
+
const target = byName.get(dataset)
|
|
191
|
+
if (target && (!pin || target.currentRevisionId !== pin)) await readRevisions(dataset, target)
|
|
192
|
+
}
|
|
193
|
+
for (const pin of pins) {
|
|
194
|
+
for (const [name, dataset] of byName) {
|
|
195
|
+
if (index.nameByRevisionId.has(pin)) break
|
|
196
|
+
await readRevisions(name, dataset)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return index
|
|
200
|
+
}
|
|
201
|
+
|
|
138
202
|
async function resolveRevision(
|
|
139
203
|
client: BlueprintAuthoringApi,
|
|
140
204
|
name: string,
|
|
@@ -308,34 +372,18 @@ async function computePlan(ctx: CommandContext): Promise<{
|
|
|
308
372
|
})
|
|
309
373
|
}
|
|
310
374
|
const { definition, revision } = await client.draft()
|
|
311
|
-
const
|
|
375
|
+
const datasets = await readDatasetIndex(client, files, definition)
|
|
376
|
+
const plan = withheldPrunes(diff(files, definition, revision, datasets), directory)
|
|
312
377
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
// `datasetNamesByRevision` had already read every dataset's revisions. A hundred
|
|
316
|
-
// types on one dataset cost a hundred redundant round trips.
|
|
378
|
+
// A type moving to another Dataset is no longer held to the old one's contract.
|
|
379
|
+
const rebound = new Set(plan.rebind.map((change) => change.objectApiName))
|
|
317
380
|
const drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }> = []
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (!dataset.id || !dataset.name) continue
|
|
325
|
-
const revisions = await client.datasetRevisions(dataset.id)
|
|
326
|
-
for (const revision of revisions) {
|
|
327
|
-
if (revision.id) names.set(revision.id, dataset.name)
|
|
328
|
-
}
|
|
329
|
-
const current = pickCurrentRevision(dataset, revisions)
|
|
330
|
-
if (current?.id) currentByDataset.set(dataset.name, { id: current.id, schemaDigest: current.schemaDigest })
|
|
331
|
-
}
|
|
332
|
-
for (const pin of plan.drift) {
|
|
333
|
-
const datasetName = names.get(pin.datasetRevisionId)
|
|
334
|
-
if (!datasetName) continue
|
|
335
|
-
const current = currentByDataset.get(datasetName)
|
|
336
|
-
if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
|
|
337
|
-
drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
|
|
338
|
-
}
|
|
381
|
+
for (const pin of plan.drift) {
|
|
382
|
+
const datasetName = datasets?.nameByRevisionId.get(pin.datasetRevisionId)
|
|
383
|
+
if (!datasetName || rebound.has(pin.objectApiName)) continue
|
|
384
|
+
const current = datasets?.current.get(datasetName)
|
|
385
|
+
if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
|
|
386
|
+
drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
|
|
339
387
|
}
|
|
340
388
|
const inherits = inheritance(ctx)
|
|
341
389
|
const actionProblems = actionRuleProblems(files, inherits.conflict)
|
|
@@ -1341,8 +1389,8 @@ export const blueprintApply: Command = {
|
|
|
1341
1389
|
|
|
1342
1390
|
// Drift is REPORTED, and blocks only the artifacts it is about. It used to throw
|
|
1343
1391
|
// before the plan was even shown, so a colleague's type reading a moved dataset
|
|
1344
|
-
// refused an unrelated creation and hid what the run would have done.
|
|
1345
|
-
// `bind`'s act
|
|
1392
|
+
// refused an unrelated creation and hid what the run would have done. Re-binding to
|
|
1393
|
+
// the same Dataset's new revision is `bind`'s act; the plan says so on the line.
|
|
1346
1394
|
const driftingNames = new Set(drift.map((entry) => entry.objectApiName))
|
|
1347
1395
|
const blocked = plan.operations.filter((operation) =>
|
|
1348
1396
|
operation.kind === 'object-type' && driftingNames.has(operation.apiName))
|
|
@@ -1354,6 +1402,16 @@ export const blueprintApply: Command = {
|
|
|
1354
1402
|
)
|
|
1355
1403
|
}
|
|
1356
1404
|
|
|
1405
|
+
// A rebind that cannot be sent stops the run before any write, with what to run instead.
|
|
1406
|
+
const refused = plan.rebind.filter((change): change is Extract<RebindChange, { refused: string }> => 'refused' in change)
|
|
1407
|
+
if (refused.length > 0) {
|
|
1408
|
+
throw new CliError(
|
|
1409
|
+
refused.map((change) => `${change.objectApiName} cannot move to Dataset "${change.to}": ${change.refused}.`).join('\n')
|
|
1410
|
+
+ '\nNothing was applied.',
|
|
1411
|
+
{ code: 'CONFLICT', hint: refused.map((change) => change.next).join('\n') },
|
|
1412
|
+
)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1357
1415
|
// BOTH orderings resolved before the confirmation, not inside the write loop.
|
|
1358
1416
|
// Each refuses a mutual `compensationAction` pair, and a refusal that arrives
|
|
1359
1417
|
// after the creates have committed leaves the draft half-applied — which is what
|
|
@@ -1367,9 +1425,10 @@ export const blueprintApply: Command = {
|
|
|
1367
1425
|
const ordered = orderForApply(plan.operations)
|
|
1368
1426
|
const orderedPrunes = prune ? orderForPrune(plan.prunes, liveCompensations(definition)) : []
|
|
1369
1427
|
|
|
1428
|
+
const changeCount = plan.operations.length + plan.editable.length + plan.rebind.length
|
|
1370
1429
|
if (ctx.flags.yes !== true) {
|
|
1371
1430
|
throw new CliError(
|
|
1372
|
-
`This would apply ${
|
|
1431
|
+
`This would apply ${changeCount} change${changeCount === 1 ? '' : 's'}.\n`
|
|
1373
1432
|
+ `${renderPlan(plan, { prune })}\n\nRe-run with --yes to apply.`,
|
|
1374
1433
|
{ code: 'USAGE', hint: 'frontera blueprint apply --yes' },
|
|
1375
1434
|
)
|
|
@@ -1398,6 +1457,23 @@ export const blueprintApply: Command = {
|
|
|
1398
1457
|
}),
|
|
1399
1458
|
),
|
|
1400
1459
|
}
|
|
1460
|
+
// First, so a property this run adds can read a column only the new Dataset publishes.
|
|
1461
|
+
// The file naming the Dataset is the decision `--accept-contract-change` stands for in `bind`.
|
|
1462
|
+
for (const change of plan.rebind) {
|
|
1463
|
+
if ('refused' in change) continue
|
|
1464
|
+
current = `update object-type ${change.objectApiName} dataset: ${change.from ?? '(none)'} → ${change.to}`
|
|
1465
|
+
try {
|
|
1466
|
+
revision = (await client.draftCommand(datasetBindingCommand(change.objectId, change.datasetRevisionId, change.columnMappings, true), revision)).revision
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
if (!(err instanceof CliError)) throw err
|
|
1469
|
+
throw new CliError(`backing.dataset (${change.to}) was refused: ${err.message}`, {
|
|
1470
|
+
code: err.code,
|
|
1471
|
+
hint: `frontera blueprint bind ${change.objectApiName} --dataset ${change.to} --plan ./map.json`,
|
|
1472
|
+
cause: err,
|
|
1473
|
+
})
|
|
1474
|
+
}
|
|
1475
|
+
applied.push(current)
|
|
1476
|
+
}
|
|
1401
1477
|
for (const operation of ordered) {
|
|
1402
1478
|
const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
|
|
1403
1479
|
current = `${operation.operation} ${operation.kind} ${operation.apiName}`
|
|
@@ -1411,6 +1487,24 @@ export const blueprintApply: Command = {
|
|
|
1411
1487
|
applied.push(current)
|
|
1412
1488
|
}
|
|
1413
1489
|
}
|
|
1490
|
+
// Last, against the draft as these writes left it: a type created above has its
|
|
1491
|
+
// id, and a property added above can be named.
|
|
1492
|
+
if (plan.editable.length) {
|
|
1493
|
+
const after = await client.draft()
|
|
1494
|
+
revision = after.revision
|
|
1495
|
+
for (const change of editableChanges(files, after.definition)) {
|
|
1496
|
+
current = `update object-type ${change.objectApiName}`
|
|
1497
|
+
const objectId = after.definition.objects.find((object) => object.apiName === change.objectApiName)?.id
|
|
1498
|
+
if (!objectId) throw new CliError(`No object type "${change.objectApiName}" on the draft to declare editable fields on.`, { code: 'NOT_FOUND', hint: 'frontera blueprint plan — re-plan against the current draft, then re-apply.' })
|
|
1499
|
+
try {
|
|
1500
|
+
revision = (await client.draftCommand({ kind: 'set_editable_properties', objectId, editableProperties: change.to }, revision)).revision
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
if (!(err instanceof CliError)) throw err
|
|
1503
|
+
throw new CliError(`backing.editable (${change.to.join(', ') || 'none'}) was refused: ${err.message}`, { code: err.code, ...(err.hint ? { hint: err.hint } : {}), cause: err })
|
|
1504
|
+
}
|
|
1505
|
+
applied.push(`${current} editable: ${change.to.join(', ') || '(none)'}`)
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1414
1508
|
current = null
|
|
1415
1509
|
} catch (err) {
|
|
1416
1510
|
// What landed is as important as what failed: the draft is shared, and a
|
|
@@ -5,11 +5,9 @@ import type { Command, CommandContext } from '../types'
|
|
|
5
5
|
/**
|
|
6
6
|
* Declare which properties a governed Action may write.
|
|
7
7
|
*
|
|
8
|
-
* The `set_editable_properties` draft command
|
|
9
|
-
*
|
|
10
|
-
* from
|
|
11
|
-
* So the one declaration standing between a published ontology and an Action
|
|
12
|
-
* that can change it was reachable by raw HTTP alone.
|
|
8
|
+
* The `set_editable_properties` draft command, for one object type at a time.
|
|
9
|
+
* `apply` sends the same command for every file whose `backing.editable`
|
|
10
|
+
* differs from the draft; this verb reads or changes the set without a file.
|
|
13
11
|
*
|
|
14
12
|
* It is its own verb rather than a flag on `bind` because narrowing the set is
|
|
15
13
|
* a governed act with its own consequence — an edit already stored against a
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
type SyncProfiles,
|
|
30
30
|
type SyncTarget,
|
|
31
31
|
} from '../../local/local-dev-sync'
|
|
32
|
-
import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout,
|
|
32
|
+
import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, embeddingsDiffer, localDirectory, mediaCatalogDatasetReason, pointMediaTypesAt, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild, type ResolvedBlueprint } from '../../local/local-dev-references'
|
|
33
33
|
import { applyObjectSets, namesObjectSet, objectSetCalls } from '../../local/local-object-sets'
|
|
34
34
|
import { applyTheme, themeCalls } from '../../local/local-themes'
|
|
35
35
|
import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
|
|
@@ -421,8 +421,6 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
421
421
|
if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
|
|
422
422
|
}
|
|
423
423
|
let authoredAgain: string[] = []
|
|
424
|
-
// Object types whose editable fields were declared for their Actions below.
|
|
425
|
-
const editableDone = new Set<string>()
|
|
426
424
|
if (actionCalls && blueprint) {
|
|
427
425
|
const names = blueprint.actions.map((a) => a.apiName)
|
|
428
426
|
// A decision pins the Action's contract, its object type's fields included, and a first
|
|
@@ -431,14 +429,6 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
431
429
|
const again = await runCli(project, command, opts.auth)
|
|
432
430
|
if (!again.ok) return again
|
|
433
431
|
}
|
|
434
|
-
// What each Action may write (`backing.editable`): `blueprint apply` leaves backings to `bind`.
|
|
435
|
-
const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
|
|
436
|
-
const subjects = new Set(blueprint.actions.flatMap((a) => (a.fit.subject ? [a.fit.subject] : [])))
|
|
437
|
-
for (const [type, props] of editableSets(appliedDir, subjects)) {
|
|
438
|
-
editableDone.add(type)
|
|
439
|
-
const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
|
|
440
|
-
if (!set.ok && !/no change|already|unchanged/i.test(set.output)) notes = [notes, ` warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}`].filter(Boolean).join('\n')
|
|
441
|
-
}
|
|
442
432
|
// An Action whose contract moved after its decision here blocks the publish, and a decision
|
|
443
433
|
// cannot follow a new contract. On this runtime it is authored again under a new id: out of
|
|
444
434
|
// the draft (`--prune` over a copy without it), then back from the files.
|
|
@@ -462,16 +452,6 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
462
452
|
const refused = await prepareActions(actionCalls, names, new Set(authoredAgain))
|
|
463
453
|
if (refused.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...refused].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
|
|
464
454
|
}
|
|
465
|
-
// Every other type's `backing.editable` too, before the publish: an editable type's rows carry
|
|
466
|
-
// their record version, which an Action on it (one to come, or an App's) submits.
|
|
467
|
-
if (blueprint && opts.publish) {
|
|
468
|
-
const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
|
|
469
|
-
for (const [type, props] of editableSets(appliedDir, null)) {
|
|
470
|
-
if (editableDone.has(type)) continue
|
|
471
|
-
const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
|
|
472
|
-
if (!set.ok && !/no change|already|unchanged/i.test(set.output)) result = { ...result, output: `${result.output}\n warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}` }
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
455
|
const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(blueprintRoot) : []
|
|
476
456
|
// The workspace's grants beyond read (`blueprint/workspace-grants.yaml`); a file that is wrong fails the Blueprint, said by line.
|
|
477
457
|
const workspaceGrants = target.kind === 'blueprint' ? readWorkspaceGrants(project.root) : null
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { UsageError } from '../../errors'
|
|
2
2
|
import { FRONTERA_READ_ONLY_COMMANDS } from '../../forge/permission-rules'
|
|
3
|
-
import { addPersonStep, clearSettledSteps, CREDENTIAL_WORDS, CREDENTIALS, deliveryGroups, readPersonSteps, runStepChecks, settlePersonStep, type PersonStep } from '../../local/local-person-steps'
|
|
3
|
+
import { addPersonStep, clearSettledSteps, CREDENTIAL_WORDS, CREDENTIALS, currentTarget, deliveryGroups, readPersonSteps, runStepChecks, settlePersonStep, staleProblem, type PersonStep, type StepTarget } from '../../local/local-person-steps'
|
|
4
4
|
import { runSelf } from '../../mcp/mcp-cli'
|
|
5
5
|
import { localProject } from '../../local/local-project'
|
|
6
6
|
import { flagBool, flagString, type Command } from '../types'
|
|
@@ -15,11 +15,15 @@ import { flagBool, flagString, type Command } from '../types'
|
|
|
15
15
|
|
|
16
16
|
const ACTIONS = ['list', 'add', 'done', 'dismiss', 'reopen', 'check', 'clear'] as const
|
|
17
17
|
|
|
18
|
-
function stepLines(steps: PersonStep[]): string[] {
|
|
19
|
-
return steps.flatMap((s, n) =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
function stepLines(steps: PersonStep[], current: StepTarget | null): string[] {
|
|
19
|
+
return steps.flatMap((s, n) => {
|
|
20
|
+
const stale = staleProblem(s, current)
|
|
21
|
+
return [
|
|
22
|
+
` ${n + 1}. ${s.command ?? `${s.changes} (in the product)`}${s.check ? ' · checks itself' : ''}`,
|
|
23
|
+
` ${s.why} Changes: ${s.changes} Needs ${CREDENTIAL_WORDS[s.credential] ?? s.credential}. (${s.id})`,
|
|
24
|
+
...(stale ? [` ! Don't run it: ${stale}.`] : []),
|
|
25
|
+
]
|
|
26
|
+
})
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export const localSteps: Command = {
|
|
@@ -97,7 +101,7 @@ export const localSteps: Command = {
|
|
|
97
101
|
const groups = deliveryGroups(steps)
|
|
98
102
|
const settled = steps.filter((s) => s.status !== 'pending')
|
|
99
103
|
const lines = groups.length
|
|
100
|
-
? groups.flatMap((g) => [g.delivery ? `${g.delivery}:` : 'Other steps:', ...stepLines(g.steps), ''])
|
|
104
|
+
? groups.flatMap((g) => [g.delivery ? `${g.delivery}:` : 'Other steps:', ...stepLines(g.steps, currentTarget(project.root)), ''])
|
|
101
105
|
: ['No steps wait on the person.', '']
|
|
102
106
|
if (flagBool(ctx, 'all') && settled.length) lines.push('Settled:', ...settled.map((s) => ` ${s.status === 'done' ? '✓' : '–'} ${s.command ?? s.changes} (${s.id})`))
|
|
103
107
|
return { data: { pending: groups, ...(flagBool(ctx, 'all') ? { settled } : {}) }, text: lines.join('\n').trimEnd() }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { signInStep } from '../../device-login'
|
|
4
5
|
import { CliError, UsageError } from '../../errors'
|
|
5
6
|
import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
|
|
6
7
|
import { assetFilename } from '../skill/bundle-commands'
|
|
@@ -12,7 +13,7 @@ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
|
|
|
12
13
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
13
14
|
import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
14
15
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
15
|
-
import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
|
|
16
|
+
import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetOf, type SyncLink } from '../../local/local-sync-link'
|
|
16
17
|
import { itemTitle } from '../../local/local-sync-names'
|
|
17
18
|
import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
18
19
|
import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
checksOf,
|
|
29
30
|
currentRequest,
|
|
30
31
|
describeOutcome,
|
|
32
|
+
failedName,
|
|
31
33
|
functionEntryOf,
|
|
32
34
|
isOpen,
|
|
33
35
|
itemLine,
|
|
@@ -470,7 +472,9 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
470
472
|
const not = items.filter((i) => !i.proposable)
|
|
471
473
|
const name = link.workspaceName ?? link.workspace
|
|
472
474
|
const lines = [
|
|
473
|
-
ok.length
|
|
475
|
+
ok.length
|
|
476
|
+
? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:`
|
|
477
|
+
: not.length ? `Nothing to propose to ${name}: ${plural(not.length, 'change')} here cannot go yet, each for the reason below.` : `Nothing to propose to ${name}.`,
|
|
474
478
|
// Each item's notes under it.
|
|
475
479
|
...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
|
|
476
480
|
]
|
|
@@ -627,6 +631,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
627
631
|
const going = chosen.filter((i) => i.proposable && !leftSamples.includes(i))
|
|
628
632
|
const releaseAsks = parseReleases(flagString(ctx, 'release'))
|
|
629
633
|
if (!going.length && !releaseAsks.length) {
|
|
634
|
+
// Changes the checks refused are changes still: say why each can't go, not that nothing changed.
|
|
635
|
+
const held = chosen.filter((i) => !i.proposable && !i.sample)
|
|
636
|
+
if (held.length) {
|
|
637
|
+
throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
|
|
638
|
+
code: 'SYNC_PUSH_BLOCKED',
|
|
639
|
+
hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`frontera local sync push --preview\``,
|
|
640
|
+
details: { blocked: held.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })) },
|
|
641
|
+
})
|
|
642
|
+
}
|
|
630
643
|
throw new CliError(`nothing to propose: nothing ${mcp ? 'a change request carries ' : ''}changed here since the last pull`, {
|
|
631
644
|
code: 'SYNC_NOTHING_TO_PUSH',
|
|
632
645
|
hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
|
|
@@ -971,7 +984,7 @@ async function apply(ctx: CommandContext) {
|
|
|
971
984
|
throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
|
|
972
985
|
code: 'SYNC_APPLY_FAILED',
|
|
973
986
|
hint: /migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' : 'once what failed is fixed, apply again (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)',
|
|
974
|
-
details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title:
|
|
987
|
+
details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: failedName(outcome, allTitles), message: outcome.failure.message } : null },
|
|
975
988
|
})
|
|
976
989
|
}
|
|
977
990
|
// The workspace now has the applied versions: fetch, and move each applied item's base to them.
|
|
@@ -994,7 +1007,13 @@ async function apply(ctx: CommandContext) {
|
|
|
994
1007
|
// The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
|
|
995
1008
|
recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
|
|
996
1009
|
// What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
|
|
997
|
-
const
|
|
1010
|
+
const after = afterApplyOf(answer.outcome)
|
|
1011
|
+
// The steps change the workspace the request went to, so they name it: the folder is bound to its local runtime.
|
|
1012
|
+
const { target, targetError } = after.length
|
|
1013
|
+
? await registerTargetProfile(project, link).then((name) => ({ target: name, targetError: null }), (err: Error) => ({ target: null, targetError: err.message }))
|
|
1014
|
+
: { target: null, targetError: null }
|
|
1015
|
+
const pending = recordAfterApply(project.root, answer.request.title, after, target ? { profile: target, target: targetOf(link) } : null)
|
|
1016
|
+
const signIn = target && pending.length ? await signInStep({ profile: target, apiUrl: link.platform, named: true }) : null
|
|
998
1017
|
return {
|
|
999
1018
|
data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
|
|
1000
1019
|
text: [
|
|
@@ -1003,7 +1022,11 @@ async function apply(ctx: CommandContext) {
|
|
|
1003
1022
|
'',
|
|
1004
1023
|
fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
|
|
1005
1024
|
...(pending.length
|
|
1006
|
-
? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
|
|
1025
|
+
? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
|
|
1026
|
+
target
|
|
1027
|
+
? `They run against ${new URL(link.platform).host} with \`--profile ${target}\` (this project's keys), not the local runtime${signIn ? `; a step a signed-in person takes needs \`${signIn}\` first` : ''}.`
|
|
1028
|
+
: `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${targetError ? ` (${targetError})` : ''}. Add \`--profile <a profile for that platform>\` when you run them.`,
|
|
1029
|
+
'Forge lists them on the project’s home; `frontera local steps` here.']
|
|
1007
1030
|
: []),
|
|
1008
1031
|
].join('\n'),
|
|
1009
1032
|
}
|
|
@@ -11,7 +11,7 @@ import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/l
|
|
|
11
11
|
import { analyticsNeeded, connectionsNeeded, documentationNeeded, standInPath, writePlatformOnly, writeStandIns } from '../../local/local-stand-ins'
|
|
12
12
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
13
13
|
import { normalizeOrigin, syncClient, type SyncClient } from '../../local/local-sync-client'
|
|
14
|
-
import { getSyncKey, projectFile, readLink, removeSyncKey, setSyncKey, writeLink, type SyncLink } from '../../local/local-sync-link'
|
|
14
|
+
import { getSyncKey, projectFile, projectKeyFingerprints, readLink, registerTargetProfile, removeSyncKey, removeTargetProfile, setSyncKey, targetProfileName, writeLink, type SyncLink } from '../../local/local-sync-link'
|
|
15
15
|
import { describeRepair, repairBases, type BaseRepair } from '../../local/local-sync-base'
|
|
16
16
|
import { conflictsId, mergeItemFiles, type Choice, type Conflict, type Side } from '../../local/local-sync-merge'
|
|
17
17
|
import {
|
|
@@ -434,11 +434,13 @@ const connect = async (ctx: CommandContext) => {
|
|
|
434
434
|
}
|
|
435
435
|
const blueprintFlag = flagString(ctx, 'blueprint')
|
|
436
436
|
if (blueprintFlag && blueprintFlag !== 'workspace' && blueprintFlag !== 'organization') throw new UsageError('--blueprint is workspace or organization', '`--blueprint organization` reads the whole model and needs an organization key')
|
|
437
|
+
// The stored organization key belongs to the previous link's platform and organization: kept only while both stay.
|
|
438
|
+
const keepsOrganizationKey = !keys.organization && previous?.platform === platform && previous.organization === me.orgId
|
|
437
439
|
const link: SyncLink = {
|
|
438
440
|
platform,
|
|
439
441
|
workspace: me.workspaceId!,
|
|
440
442
|
workspaceName: identity?.workspace.name ?? previous?.workspaceName,
|
|
441
|
-
organization: keys.organization ? me.orgId! : previous
|
|
443
|
+
organization: keys.organization ? me.orgId! : keepsOrganizationKey ? previous!.organization : undefined,
|
|
442
444
|
organizationName: identity?.organization.name ?? previous?.organizationName,
|
|
443
445
|
blueprint: (blueprintFlag as SyncLink['blueprint'] | undefined) ?? (previous?.blueprint === 'organization' && keys.organization ? 'organization' : 'workspace'),
|
|
444
446
|
// The models mapped for this workspace stay; another workspace has its own catalog.
|
|
@@ -446,14 +448,26 @@ const connect = async (ctx: CommandContext) => {
|
|
|
446
448
|
}
|
|
447
449
|
// A folder that is not a project yet gets the project file the link lives in.
|
|
448
450
|
if (!existsSync(projectFile(project.root))) writeFileSync(projectFile(project.root), `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${basename(project.root)}\n`)
|
|
451
|
+
// The previous target's profile goes with the keys it was made from.
|
|
452
|
+
if (previous && targetProfileName(project, previous) !== targetProfileName(project, link)) await removeTargetProfile(project, previous)
|
|
453
|
+
// Read before the keys are replaced, so a profile holding the key a rotation replaces is still the project's.
|
|
454
|
+
const previousKeys = projectKeyFingerprints(project)
|
|
449
455
|
setSyncKey(project, 'workspace', keys.workspace)
|
|
450
456
|
if (keys.organization) setSyncKey(project, 'organization', keys.organization)
|
|
457
|
+
else if (!keepsOrganizationKey) removeSyncKey(project, 'organization')
|
|
451
458
|
writeLink(project.root, link)
|
|
459
|
+
// The workspace as a CLI profile: a change request's steps for a person name it (`--profile`).
|
|
460
|
+
const { profile, profileError } = await registerTargetProfile(project, link, previousKeys).then(
|
|
461
|
+
(name) => ({ profile: name, profileError: null }),
|
|
462
|
+
(err: Error) => ({ profile: null, profileError: err.message }),
|
|
463
|
+
)
|
|
452
464
|
return {
|
|
453
|
-
data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) } },
|
|
465
|
+
data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) }, profile },
|
|
454
466
|
text: [
|
|
455
467
|
`Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(platform).host}.`,
|
|
456
468
|
'Keys are in your keychain; the link is in frontera.yaml.',
|
|
469
|
+
...(profile ? [`A change request's steps for a person reach this platform with \`--profile ${profile}\`, not the local runtime.`] : []),
|
|
470
|
+
...(profileError ? [`No CLI profile was recorded for this platform (${profileError}); a change request's steps for a person name none until one is.`] : []),
|
|
457
471
|
'See what you can pull: `frontera local sync pull --preview`.',
|
|
458
472
|
].join('\n'),
|
|
459
473
|
}
|
|
@@ -947,6 +961,8 @@ const resolve = async (ctx: CommandContext) => {
|
|
|
947
961
|
const disconnect = async (ctx: CommandContext) => {
|
|
948
962
|
const project = localProject(ctx.cwd)
|
|
949
963
|
const link = readLink(project.root)
|
|
964
|
+
// First, while the keys that show the profile is the project's are still stored.
|
|
965
|
+
if (link) await removeTargetProfile(project, link)
|
|
950
966
|
removeSyncKey(project, 'workspace')
|
|
951
967
|
removeSyncKey(project, 'organization')
|
|
952
968
|
const forget = flagBool(ctx, 'forget-link')
|
package/src/commands/types.ts
CHANGED
|
@@ -106,6 +106,8 @@ export interface CommandContext {
|
|
|
106
106
|
cwd: string
|
|
107
107
|
apiUrl: string
|
|
108
108
|
token: string
|
|
109
|
+
/** The profile the credential came from, when it came from one. */
|
|
110
|
+
profile?: string
|
|
109
111
|
positional: string[]
|
|
110
112
|
flags: Record<string, string | boolean>
|
|
111
113
|
/** Every value of a string flag given more than once (`flags` holds the last). */
|
package/src/device-login.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { credentialStore, type CredentialStore } from './credential-store'
|
|
2
2
|
import { CliError } from './errors'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -128,6 +128,24 @@ export async function readDeviceSession(store: CredentialStore, profile: string,
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** How long a stored session must still last for a printed sign-in step to be left out: the steps after it take a few minutes. */
|
|
132
|
+
const SIGN_IN_STEP_MARGIN_MS = 5 * 60_000
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The sign-in step a list of next steps starts with: `frontera auth login --browser`, with
|
|
136
|
+
* `--profile` when the steps name one. Null when that profile already holds a browser
|
|
137
|
+
* session for this platform that lasts past the next few minutes, so a person who signed
|
|
138
|
+
* in is not asked again at every printed step.
|
|
139
|
+
*/
|
|
140
|
+
export async function signInStep(
|
|
141
|
+
at: { profile?: string; apiUrl: string; named?: boolean },
|
|
142
|
+
store: CredentialStore = credentialStore(),
|
|
143
|
+
now = Date.now(),
|
|
144
|
+
): Promise<string | null> {
|
|
145
|
+
if (at.profile && (await readDeviceSession(store, at.profile, at.apiUrl, now + SIGN_IN_STEP_MARGIN_MS))) return null
|
|
146
|
+
return `frontera auth login --browser${at.named && at.profile ? ` --profile ${at.profile}` : ''}`
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
/** Revoke the session on the platform (best effort) and forget it here. */
|
|
132
150
|
export async function forgetDeviceSession(store: CredentialStore, profile: string, fetchFn: Fetch = fetch): Promise<boolean> {
|
|
133
151
|
const raw = await store.get(sessionSlot(profile)).catch(() => null)
|
package/src/exit.ts
CHANGED
|
@@ -74,6 +74,8 @@ const USAGE_CODES = new Set([
|
|
|
74
74
|
// An Action that isn't there to submit, or a submit without the key it needs.
|
|
75
75
|
'ACTION_NOT_FOUND',
|
|
76
76
|
'ACTION_IDEMPOTENCY_KEY_REQUIRED',
|
|
77
|
+
// `blueprint publish` without the --instruction its changes need: fixed in the command.
|
|
78
|
+
'MIGRATION_INSTRUCTION_REQUIRED',
|
|
77
79
|
])
|
|
78
80
|
/**
|
|
79
81
|
* Someone changed it first: re-read, then decide or submit again. Each of
|
|
@@ -115,6 +117,9 @@ const CONFLICT_CODES = new Set([
|
|
|
115
117
|
'AUTOMATION_PUBLISH_UNCERTAIN',
|
|
116
118
|
// The dataset's rows are written somewhere else now.
|
|
117
119
|
'DATASET_AUTHORITY_MOVED',
|
|
120
|
+
// A release the draft's state refuses (an Action without a deployment decision, a
|
|
121
|
+
// missing column): the same publish is refused until that is cleared.
|
|
122
|
+
'RELEASE_BLOCKED',
|
|
118
123
|
])
|
|
119
124
|
/**
|
|
120
125
|
* Refusals the service sends under another code with a `reason` that is a
|
|
@@ -21,8 +21,8 @@ import type { LocalCredentials } from './local-state'
|
|
|
21
21
|
* after the publish an Action that writes only the Blueprint's records gets its write
|
|
22
22
|
* path (an ontology edit derived from the Action), built and validated
|
|
23
23
|
* by the organization key, then reviewed and activated by the owner's
|
|
24
|
-
* session (
|
|
25
|
-
* may then invoke it
|
|
24
|
+
* session (an ontology edit, so its builder may review it); the owner
|
|
25
|
+
* role may then invoke it
|
|
26
26
|
*
|
|
27
27
|
* An Action whose effect would reach another system (a transition, a command)
|
|
28
28
|
* keeps its switched-off decision: it is published, workflows that name it
|
|
@@ -170,8 +170,9 @@ export async function unpublishedActions(call: Call, apiNames: string[]): Promis
|
|
|
170
170
|
* Actions whose contract moved after their decision was taken here (an edit
|
|
171
171
|
* to the Action, or to a field of its object type), which blocks the publish:
|
|
172
172
|
* a decision cannot follow a new contract. The draft's validation names them.
|
|
173
|
-
* On the platform
|
|
174
|
-
* the local runtime `local dev`
|
|
173
|
+
* On the platform a person retires the old deployment (`action retire`), then
|
|
174
|
+
* publishes and deploys again; on the local runtime `local dev` authors the
|
|
175
|
+
* Action again under a new id itself (see `dev.ts`).
|
|
175
176
|
*/
|
|
176
177
|
export async function staleActions(call: Call, apiNames: string[]): Promise<{ stale: string[]; consumed: string[] }> {
|
|
177
178
|
const validated = await call('org', 'POST', '/v1/blueprint/lifecycle/validate', {})
|
|
@@ -295,7 +296,7 @@ async function deployOne(call: Call, published: Deployable, attachment?: LocalAc
|
|
|
295
296
|
if (findings.length) return { reason: `its write path did not validate (${findings.length} failed check${findings.length === 1 ? '' : 's'})`, findings }
|
|
296
297
|
return `its write path did not validate: ${validation.status >= 300 ? message(validation) : failedChecks(report)}`
|
|
297
298
|
}
|
|
298
|
-
//
|
|
299
|
+
// Review and activation take a person's session; the key only builds.
|
|
299
300
|
const reviewed = await call('session', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/review`, { validationReportId: report.id, expectedStateGeneration: 2 })
|
|
300
301
|
if (reviewed.status >= 300) return `its review was refused: ${message(reviewed)}`
|
|
301
302
|
const revision = data<{ stateId?: string; stateGeneration?: number; actionDefinitionId?: string }>(await call('session', 'GET', `${ADMIN}/binding-revisions/${bindingRevisionId}`))
|
|
@@ -113,7 +113,7 @@ const OBJECT_TYPE: JsonSchema = {
|
|
|
113
113
|
type: 'object',
|
|
114
114
|
description: 'Where the records come from: the dataset by NAME (its revision is resolved when applied), and one column per property. There is no name-match fallback.',
|
|
115
115
|
properties: {
|
|
116
|
-
dataset: text('The dataset’s apiName (datasets/<apiName>.json).'),
|
|
116
|
+
dataset: text('The dataset’s apiName (datasets/<apiName>.json). Naming another dataset moves the type to it on `frontera blueprint apply` and in a change request’s Apply; each property keeps its column. A change request creates a new type bound to it, in the same release.'),
|
|
117
117
|
mapping: {
|
|
118
118
|
type: 'object',
|
|
119
119
|
description: 'property apiName → its column, or { column, field } for a field inside a column.',
|
|
@@ -124,7 +124,7 @@ const OBJECT_TYPE: JsonSchema = {
|
|
|
124
124
|
],
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
|
-
editable: { type: 'array', items: { type: 'string' }, description: 'The properties a governed Action may write.' },
|
|
127
|
+
editable: { type: 'array', items: { type: 'string' }, description: 'The properties a governed Action may write. `frontera blueprint apply` and a change request’s Apply declare this list, for a type they create too; leave the key out to keep what the draft holds, or write [] for none.' },
|
|
128
128
|
},
|
|
129
129
|
required: ['dataset', 'mapping'],
|
|
130
130
|
},
|
|
@@ -735,29 +735,6 @@ export function pointMediaTypesAt(project: LocalProject, blueprint: ResolvedBlue
|
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
-
/**
|
|
739
|
-
* The properties each object type lets a governed Action write
|
|
740
|
-
* (`backing.editable`), for the types those Actions act on. `blueprint apply`
|
|
741
|
-
* leaves backings to `bind`, so `local dev` declares them itself
|
|
742
|
-
* (`blueprint editable`) before an Action's write path is built.
|
|
743
|
-
*/
|
|
744
|
-
export function editableSets(dir: string, subjects: ReadonlySet<string> | null): Map<string, string[]> {
|
|
745
|
-
const out = new Map<string, string[]>()
|
|
746
|
-
const at = join(dir, 'object-types')
|
|
747
|
-
if (!existsSync(at)) return out
|
|
748
|
-
for (const name of readdirSync(at)) {
|
|
749
|
-
if (!/\.ya?ml$/.test(name) || name.startsWith('.')) continue
|
|
750
|
-
try {
|
|
751
|
-
const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as { apiName?: unknown; backing?: { editable?: unknown } } | null
|
|
752
|
-
const editable = doc?.backing?.editable
|
|
753
|
-
if (typeof doc?.apiName === 'string' && (!subjects || subjects.has(doc.apiName)) && Array.isArray(editable) && editable.length) out.set(doc.apiName, editable.map(String).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)))
|
|
754
|
-
} catch {
|
|
755
|
-
// A file that does not parse is refused by the apply itself.
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
return out
|
|
759
|
-
}
|
|
760
|
-
|
|
761
738
|
/**
|
|
762
739
|
* A copy of a Blueprint folder without some governed Actions, under
|
|
763
740
|
* `.frontera/local/resolved/blueprint-without`: applied with `--prune`, it
|