@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.
@@ -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 plan = withheldPrunes(diff(files, definition, revision), directory)
375
+ const datasets = await readDatasetIndex(client, files, definition)
376
+ const plan = withheldPrunes(diff(files, definition, revision, datasets), directory)
312
377
 
313
- // Per DISTINCT dataset, which is what the design promised and what the previous
314
- // loop did not do: it called `datasetRevisions` once per BOUND TYPE, after
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
- if (plan.drift.length > 0) {
319
- // One pass over the datasets, not two: the revisions are read once and both the
320
- // revision→name map and the current-revision map are built from the same reads.
321
- const names = new Map<string, string>()
322
- const currentByDataset = new Map<string, { id: string; schemaDigest?: string }>()
323
- for (const dataset of await readableDatasets(client) ?? []) {
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. A rebind is
1345
- // `bind`'s act either way — the plan says so on the line.
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 ${plan.operations.length} change${plan.operations.length === 1 ? '' : 's'}.\n`
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 has existed since the edit
9
- * overlay landed, and until now nothing called it: `apply` excludes backings
10
- * from its diff (`bind` owns them) and `bind` sends only the column mapping.
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, editableSets, embeddingsDiffer, localDirectory, mediaCatalogDatasetReason, pointMediaTypesAt, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild, type ResolvedBlueprint } from '../../local/local-dev-references'
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
- ` ${n + 1}. ${s.command ?? `${s.changes} (in the product)`}${s.check ? ' · checks itself' : ''}`,
21
- ` ${s.why} Changes: ${s.changes} Needs ${CREDENTIAL_WORDS[s.credential] ?? s.credential}. (${s.id})`,
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 ? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:` : `Nothing to propose to ${name}.`,
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: allTitles[outcome.failure.ref] ?? outcome.failure.ref, message: outcome.failure.message } : null },
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 pending = recordAfterApply(project.root, answer.request.title, afterApplyOf(answer.outcome))
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)' : ''}`), 'Forge lists them on the project’s home; `frontera local steps` here.']
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?.organization,
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')
@@ -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). */
@@ -1,4 +1,4 @@
1
- import type { CredentialStore } from './credential-store'
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 (the service refuses a reviewer who built it); the owner role
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 the cure is authoring the Action again under a new id; on
174
- * the local runtime `local dev` does that itself (see `dev.ts`).
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
- // A different principal from the key that built it: the service insists.
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