@frontera-sdk/cli 0.1.0 → 1.43.6

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.
Files changed (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. package/src/commands/blueprint/reserved.ts +0 -40
@@ -0,0 +1,1052 @@
1
+ import { existsSync, mkdirSync, readdirSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
+ import {
6
+ linkTypeCreateBody,
7
+ linkTypeStructure,
8
+ objectTypeCreateBody,
9
+ metricCreateBody,
10
+ metricPatchBody,
11
+ readBacking,
12
+ resolveSharedProperty,
13
+ sharedPropertyCreateBody,
14
+ sharedPropertyPatchBody,
15
+ } from '../../blueprint/compile'
16
+ import { pickCurrentRevision } from '../../blueprint/dataset-revision'
17
+ import { diff, isEmpty, satisfies, type ModelOperation, type Plan } from '../../blueprint/diff'
18
+ import {
19
+ API_NAME_RULE,
20
+ ARTIFACT_KINDS,
21
+ KIND_DIRECTORY,
22
+ describeApiNameRule,
23
+ indexBundle,
24
+ type ArtifactKind,
25
+ type AuthoredFile,
26
+ type DefinitionBundle,
27
+ } from '../../blueprint/model'
28
+ import { toFiles } from '../../blueprint/projection'
29
+ import { renderDrift, renderPlan } from '../../blueprint/render'
30
+ import { scaffold } from '../../blueprint/scaffold'
31
+ import { pathFor, readTree, removeFromTree, writeTree } from '../../blueprint/tree'
32
+ import { CliError } from '../../errors'
33
+ import type { Command, CommandContext } from '../types'
34
+
35
+ /**
36
+ * Declarative Blueprint authoring: the Blueprint as files, applied to a draft.
37
+ *
38
+ * The imperative verbs beside this one (`create`, `update`, `delete`) stay, and stay
39
+ * supported, for exploring an organization interactively. This is the path for
40
+ * DEPLOYING one — re-runnable, reviewable as a diff, and the same tree on every
41
+ * environment because nothing environment-shaped is in it.
42
+ */
43
+
44
+ function api(ctx: CommandContext): BlueprintAuthoringApi {
45
+ return new BlueprintAuthoringApi(ctx.apiUrl, ctx.token)
46
+ }
47
+
48
+ /** Any uuid at all. The tree is portable only when it contains none. */
49
+ const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
50
+
51
+ function root(ctx: CommandContext): string {
52
+ return resolve(ctx.cwd, (ctx.flags.dir as string) ?? 'blueprint')
53
+ }
54
+
55
+ /**
56
+ * Datasets, or nothing if this credential cannot read them.
57
+ *
58
+ * Reading datasets needs `dataset: ['read']`, which a key minted only to author the
59
+ * MODEL will not carry — and authoring the model does not need it. So a refusal here
60
+ * is a fact about the credential, not a failure of the command: `pull` still writes
61
+ * every artifact and simply omits `backing`, and `plan` still diffs. Only `bind`
62
+ * genuinely requires the capability, and it says so by name when it is missing.
63
+ *
64
+ * Letting the 403 escape made `pull` unusable for a model-only key, which is the
65
+ * common case for a key scoped to exactly what it needs.
66
+ */
67
+ async function readableDatasets(
68
+ client: BlueprintAuthoringApi,
69
+ ): Promise<Array<{ id?: string; name?: string; currentRevisionId?: string }> | null> {
70
+ try {
71
+ return await client.listDatasets()
72
+ } catch (err) {
73
+ if (err instanceof CliError && (err.code === 'FORBIDDEN' || err.code === 'UNAUTHORIZED')) return null
74
+ throw err
75
+ }
76
+ }
77
+
78
+ /** Dataset name → id, read once per command that needs it. */
79
+ async function datasetsByName(client: BlueprintAuthoringApi): Promise<Map<string, string>> {
80
+ const datasets = await readableDatasets(client) ?? []
81
+ const byName = new Map<string, string>()
82
+ for (const dataset of datasets) {
83
+ if (dataset.name && dataset.id) byName.set(dataset.name, dataset.id)
84
+ }
85
+ return byName
86
+ }
87
+
88
+ /**
89
+ * Dataset revision id → dataset name, for `pull`.
90
+ *
91
+ * One request per dataset rather than per bound object type: a hundred types sharing
92
+ * one dataset is one lookup.
93
+ */
94
+ async function datasetNamesByRevision(
95
+ client: BlueprintAuthoringApi,
96
+ ): Promise<Map<string, string>> {
97
+ const names = new Map<string, string>()
98
+ for (const dataset of await readableDatasets(client) ?? []) {
99
+ if (!dataset.id || !dataset.name) continue
100
+ for (const revision of await client.datasetRevisions(dataset.id)) {
101
+ if (revision.id) names.set(revision.id, dataset.name)
102
+ }
103
+ }
104
+ return names
105
+ }
106
+
107
+ async function resolveRevision(
108
+ client: BlueprintAuthoringApi,
109
+ name: string,
110
+ ): Promise<{ revisionId: string; schemaDigest: string }> {
111
+ const datasets = await readableDatasets(client) ?? []
112
+ const dataset = datasets.find((candidate) => candidate.name === name)
113
+ const datasetId = dataset?.id
114
+ if (!datasetId) {
115
+ throw new CliError(`No dataset named "${name}" in this organization.`, {
116
+ code: 'NOT_FOUND',
117
+ // The 403 shape is worth naming: a key minted without `dataset:read` sees an
118
+ // empty list rather than a refusal, which reads as "the dataset is missing".
119
+ hint: `frontera blueprint plan lists what is reachable. If nothing is, the key may not carry dataset:read.`,
120
+ })
121
+ }
122
+ const revisions = await client.datasetRevisions(datasetId)
123
+ // The dataset names its current revision; the sorted list is only the fallback for
124
+ // a payload that does not carry it. Shared with `bind` — see `dataset-revision.ts`.
125
+ const current = pickCurrentRevision(dataset, revisions)
126
+ if (!current?.id || !current.schemaDigest) {
127
+ throw new CliError(`Dataset "${name}" has no published revision to bind to.`, {
128
+ code: 'FAILURE',
129
+ hint: 'Publish a revision of the dataset first.',
130
+ })
131
+ }
132
+ return { revisionId: current.id, schemaDigest: current.schemaDigest }
133
+ }
134
+
135
+ export const blueprintPull: Command = {
136
+ meta: {
137
+ noun: 'blueprint',
138
+ verb: 'pull',
139
+ args: [],
140
+ flags: { dir: 'string', force: 'boolean' },
141
+ summary: 'Write the shared draft to a file tree you can commit',
142
+ examples: ['frontera blueprint pull', 'frontera blueprint pull --dir ./blueprint'],
143
+ },
144
+ async run(ctx) {
145
+ const client = api(ctx)
146
+ const { definition } = await client.draft()
147
+ const directory = root(ctx)
148
+
149
+ if (!ctx.flags.force && existsSync(directory) && readTree(directory).length > 0) {
150
+ throw new CliError(`${directory} already holds a Blueprint tree.`, {
151
+ code: 'USAGE',
152
+ // A round trip makes overwriting easy and silent, which is exactly when a
153
+ // confirmation earns its place.
154
+ hint: 'Commit or move what is there, then re-run with --force to overwrite it.',
155
+ })
156
+ }
157
+
158
+ const names = await datasetNamesByRevision(client)
159
+ const model = toFiles(definition, names)
160
+ // The root is created even when the draft is empty. An empty Blueprint is a
161
+ // legitimate state — a freshly adopted organization, or one rolled back to its
162
+ // baseline — and leaving no directory behind made the next `plan` report "no
163
+ // Blueprint tree" for what is really "no artifacts yet".
164
+ mkdirSync(directory, { recursive: true })
165
+ // Anything the tree holds that the draft no longer has is deleted, not left
166
+ // behind: `pull` writes what the draft IS, and a leftover file would be read back
167
+ // by the next `plan` as an artifact somebody wants created.
168
+ const keep = new Set(model.files.map((file) => file.path))
169
+ // `--force` exists to overwrite a tree that is wrong, so reading it must not be
170
+ // able to refuse the overwrite. A filename/apiName mismatch throws from
171
+ // `readTree`, which made the broken tree the one case `--force` could not fix.
172
+ const stale = existingPaths(directory)
173
+ .filter((path) => !keep.has(path))
174
+ const dropped = removeFromTree(directory, stale)
175
+ const written = writeTree(directory, model.files)
176
+ // A written file may still carry identifiers the projection cannot turn into
177
+ // names — validation ids, lifecycle state ids. Such a tree round-trips against the
178
+ // SAME draft and is not portable to another deployment, and saying so here is the
179
+ // difference between a documented limit and a surprise at the second deployment.
180
+ const unportable = model.files
181
+ .filter((file) => UUID_PATTERN.test(JSON.stringify(file.document)))
182
+ .map((file) => file.path)
183
+ const bound = definition.objects.filter((object) =>
184
+ (object.governance.sourceMappings[0] as { datasetRevisionId?: string } | undefined)?.datasetRevisionId,
185
+ ).length
186
+ // Said plainly rather than left to be discovered: a tree pulled without dataset
187
+ // access is complete as a MODEL and cannot recreate the bindings elsewhere.
188
+ const omitted = bound > 0 && names.size === 0
189
+ ? ' Dataset names were not readable with this credential, so `backing` is omitted —'
190
+ + ' this tree describes the model, not what it reads.'
191
+ : ''
192
+ return {
193
+ data: {
194
+ directory,
195
+ files: written,
196
+ removed: dropped,
197
+ backingsOmitted: omitted !== '',
198
+ unportable,
199
+ },
200
+ text: `Wrote ${written.length} artifact${written.length === 1 ? '' : 's'} to ${directory}.`
201
+ + (dropped.length ? ` Removed ${dropped.length} no longer on the draft.` : '')
202
+ + omitted
203
+ + (unportable.length
204
+ ? `\n${unportable.length} carr${unportable.length === 1 ? 'ies' : 'y'} identifiers that name `
205
+ + `nothing in another deployment (${unportable.join(', ')}) — this tree round-trips here, `
206
+ + 'but is not portable as it stands.'
207
+ : ''),
208
+ }
209
+ },
210
+ }
211
+
212
+ /** Paths already in the tree, tolerating files this CLI would refuse to read. */
213
+ function existingPaths(directory: string): string[] {
214
+ if (!existsSync(directory)) return []
215
+ try {
216
+ return readTree(directory).map((file) => file.path)
217
+ } catch {
218
+ // A tree that cannot be parsed still has files, and `--force` is how they get
219
+ // replaced. Enumerated directly rather than through the reader that rejected it.
220
+ return ARTIFACT_KINDS.flatMap((kind) => {
221
+ const sub = join(directory, KIND_DIRECTORY[kind])
222
+ if (!existsSync(sub)) return []
223
+ return readdirSync(sub)
224
+ .filter((entry) => /\.(ya?ml|json)$/.test(entry))
225
+ .map((entry) => `${KIND_DIRECTORY[kind]}/${entry}`)
226
+ })
227
+ }
228
+ }
229
+
230
+ async function computePlan(ctx: CommandContext): Promise<{
231
+ plan: Plan
232
+ files: AuthoredFile[]
233
+ /** The draft the plan was computed against — `apply` indexes it for id resolution. */
234
+ definition: DefinitionBundle
235
+ drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>
236
+ }> {
237
+ const client = api(ctx)
238
+ const directory = root(ctx)
239
+ if (!existsSync(directory)) {
240
+ throw new CliError(`No Blueprint tree at ${directory}.`, {
241
+ code: 'USAGE',
242
+ hint: 'frontera blueprint pull, or frontera blueprint new object-type <ApiName>',
243
+ })
244
+ }
245
+ const files = readTree(directory)
246
+ const { definition, revision } = await client.draft()
247
+ const plan = diff(files, definition, revision)
248
+
249
+ // Per DISTINCT dataset, which is what the design promised and what the previous
250
+ // loop did not do: it called `datasetRevisions` once per BOUND TYPE, after
251
+ // `datasetNamesByRevision` had already read every dataset's revisions. A hundred
252
+ // types on one dataset cost a hundred redundant round trips.
253
+ const drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }> = []
254
+ if (plan.drift.length > 0) {
255
+ // One pass over the datasets, not two: the revisions are read once and both the
256
+ // revision→name map and the current-revision map are built from the same reads.
257
+ const names = new Map<string, string>()
258
+ const currentByDataset = new Map<string, { id: string; schemaDigest?: string }>()
259
+ for (const dataset of await readableDatasets(client) ?? []) {
260
+ if (!dataset.id || !dataset.name) continue
261
+ const revisions = await client.datasetRevisions(dataset.id)
262
+ for (const revision of revisions) {
263
+ if (revision.id) names.set(revision.id, dataset.name)
264
+ }
265
+ const current = pickCurrentRevision(dataset, revisions)
266
+ if (current?.id) currentByDataset.set(dataset.name, { id: current.id, schemaDigest: current.schemaDigest })
267
+ }
268
+ for (const pin of plan.drift) {
269
+ const datasetName = names.get(pin.datasetRevisionId)
270
+ if (!datasetName) continue
271
+ const current = currentByDataset.get(datasetName)
272
+ if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
273
+ drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
274
+ }
275
+ }
276
+ return { plan, files, definition, drift }
277
+ }
278
+
279
+ export const blueprintPlan: Command = {
280
+ meta: {
281
+ noun: 'blueprint',
282
+ verb: 'plan',
283
+ args: [],
284
+ flags: { dir: 'string', prune: 'boolean' },
285
+ summary: 'Show what applying the file tree would change, and write nothing',
286
+ examples: ['frontera blueprint plan', 'frontera blueprint plan --json'],
287
+ },
288
+ async run(ctx) {
289
+ const { plan, drift } = await computePlan(ctx)
290
+ const body = [renderPlan(plan, { prune: ctx.flags.prune === true }), renderDrift(drift)]
291
+ .filter(Boolean)
292
+ .join('\n')
293
+ return { data: { ...plan, drift }, text: body }
294
+ },
295
+ }
296
+
297
+ /**
298
+ * Apply one operation, on the route that carries its guard.
299
+ *
300
+ * Creations go to the per-artifact POST rather than to a whole-bundle write: the
301
+ * create route mints every id, derives each property's base type from the pinned
302
+ * column, and writes the `governance.sourceMappings` shell that a later `bind`
303
+ * requires. An object type inserted any other way can never afterwards be bound.
304
+ */
305
+ async function applyOperation(
306
+ client: BlueprintAuthoringApi,
307
+ operation: ModelOperation,
308
+ file: AuthoredFile,
309
+ revision: number,
310
+ sharedPropertyIds: Map<string, string>,
311
+ ): Promise<number> {
312
+ const resolveDataset = (name: string) => resolveRevision(client, name)
313
+
314
+ if (operation.kind === 'shared-field') {
315
+ if (operation.operation === 'create') {
316
+ const created = await client.createSharedProperty(
317
+ sharedPropertyCreateBody(file, revision),
318
+ revision,
319
+ )
320
+ // The minted id, threaded into the property writes that follow in THIS run —
321
+ // the same way `expectedRevision` is threaded. Without it a tree that creates a
322
+ // shared field and its implementer together can only work on a second apply.
323
+ const id = created?.sharedProperty?.id
324
+ if (!id) {
325
+ throw new CliError(
326
+ `Creating shared field "${operation.apiName}" returned no id, so nothing can reference it.`,
327
+ {
328
+ code: 'FAILURE',
329
+ hint: 'The service is expected to answer with the created row. Re-run `frontera blueprint plan`.',
330
+ },
331
+ )
332
+ }
333
+ sharedPropertyIds.set(operation.apiName, id)
334
+ return nextRevision(created, revision)
335
+ }
336
+ assertApplicable('shared-field', file, operation.liveDocument)
337
+ const updated = await client.updateSharedProperty(
338
+ operation.apiName, sharedPropertyPatchBody(file, revision), revision,
339
+ )
340
+ return nextRevision(updated, revision)
341
+ }
342
+
343
+ if (operation.kind === 'object-type') {
344
+ if (operation.operation === 'create') {
345
+ const created = await client.createObjectType(
346
+ await objectTypeCreateBody(file, revision, resolveDataset, sharedPropertyIds),
347
+ revision,
348
+ )
349
+ return nextRevision(created, revision)
350
+ }
351
+ // Refused BEFORE the first write, not after some of them. Everything this
352
+ // executor can express is enumerated below; a difference outside that set has no
353
+ // route to travel, and reporting it as applied is the defect this whole review
354
+ // round keeps finding at a new level. Naming the field beats a silent no-op.
355
+ assertApplicable('object-type', file, operation.liveDocument)
356
+
357
+ // Sent only when the metadata actually differs. The service refuses a command
358
+ // that would not change the draft — "Blueprint Draft command does not change the
359
+ // Shared Draft" — so an unconditional PUT turned a pure property change into a
360
+ // failed run that had applied nothing.
361
+ let current = revision
362
+ const patch = objectTypeMetadataChanges(file, operation.liveDocument)
363
+ if (Object.keys(patch).length > 0) {
364
+ const updated = await client.updateObjectType(
365
+ operation.apiName, { ...patch, expectedRevision: revision }, revision,
366
+ )
367
+ current = nextRevision(updated, revision)
368
+ }
369
+ current = await applyObjectKeys(client, operation.apiName, file, operation.liveDocument, current)
370
+ return applyPropertyPlan(
371
+ client, operation.apiName, file, operation.liveDocument, current, sharedPropertyIds,
372
+ )
373
+ }
374
+
375
+ if (operation.kind === 'link-type') {
376
+ if (operation.operation !== 'create') assertApplicable('link-type', file, operation.liveDocument)
377
+ const body = operation.operation === 'create'
378
+ ? await client.createLinkType(linkTypeCreateBody(file, revision), revision)
379
+ : await client.updateLinkType(operation.apiName, linkTypeStructure(file, revision), revision)
380
+ return nextRevision(body, revision)
381
+ }
382
+
383
+ if (operation.operation !== 'create') assertApplicable('metric', file, operation.liveDocument)
384
+ const result = operation.operation === 'create'
385
+ ? await client.createMetric(metricCreateBody(file, revision), revision)
386
+ : await client.updateMetric(operation.apiName, metricPatchBody(file, revision), revision)
387
+ return nextRevision(result, revision)
388
+ }
389
+
390
+ /**
391
+ * The revision a write produced, so the next write can declare it.
392
+ *
393
+ * `apply` must never re-read the revision mid-run. Re-reading was the whole defect:
394
+ * every write then declared "expectedRevision = whatever it is right now", so a
395
+ * colleague editing the shared draft between two of our writes could never produce a
396
+ * 409 — the one guard the shared draft depends on, and the same shared-ness that makes
397
+ * `--prune` opt-in. Threading it forward means a concurrent edit makes OUR next
398
+ * expectation stale, which is exactly when the service should refuse us.
399
+ *
400
+ * The `+ 1` fallback is for a route that does not echo the revision; it stays strict,
401
+ * because guessing a revision the server did not confirm still gets refused if the
402
+ * draft moved.
403
+ */
404
+ function nextRevision(response: unknown, previous: number): number {
405
+ const echoed = (response as { draftRevision?: unknown } | null)?.draftRevision
406
+ return typeof echoed === 'number' ? echoed : previous + 1
407
+ }
408
+
409
+ /**
410
+ * What each kind's update routes can actually express, named as the FILE names them.
411
+ *
412
+ * Per kind rather than for object types alone, because the defect is not about object
413
+ * types: an update reported as applied and not applied is the same lie whichever route
414
+ * dropped it, and every kind has fields outside what its route takes.
415
+ *
416
+ * OBJECT TYPE — `metadata` travels the PUT, the keys travel `set_object_keys`,
417
+ * `properties` travels the field commands. `governance`, `validations` and `lifecycle`
418
+ * have no command at all.
419
+ *
420
+ * LINK TYPE — `PUT /link-types/:apiName` takes the whole structure, so nearly
421
+ * everything a file states is expressible. `status` is not: the bundle carries it and
422
+ * the route has no field for it.
423
+ *
424
+ * METRIC — `PUT /metrics/:apiName` takes `displayName`, `description`, `definition`
425
+ * and `formatConfig`. `objectType` is deliberately absent: the subject is fixed at
426
+ * creation, the patch body has no channel for it, and editing it in a file was planned,
427
+ * counted as applied, and re-planned identically on every run afterwards.
428
+ *
429
+ * SHARED FIELD — its patch route takes every authored field, including `valueType`,
430
+ * which is a breaking change the release gate reports rather than something the
431
+ * route refuses.
432
+ */
433
+ const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
434
+ 'shared-field': new Set([
435
+ 'apiName', 'displayName', 'description', 'valueType', 'propertyType', 'formatConfig',
436
+ 'status', 'visibility',
437
+ ]),
438
+ 'object-type': new Set([
439
+ 'apiName', 'displayName', 'pluralDisplayName', 'description', 'icon', 'color',
440
+ 'status', 'visibility', 'groups', 'primaryKey', 'title', 'properties', 'backing',
441
+ ]),
442
+ 'link-type': new Set(['apiName', 'cardinality', 'description', 'from', 'to']),
443
+ metric: new Set(['apiName', 'displayName', 'description', 'definition', 'formatConfig']),
444
+ }
445
+
446
+ export function assertApplicable(
447
+ kind: ArtifactKind,
448
+ file: AuthoredFile,
449
+ live: Record<string, unknown> | undefined,
450
+ ): void {
451
+ if (!live) return
452
+ const applicable = APPLICABLE_FIELDS[kind]
453
+ // The SAME predicate the plan used to decide this artifact changed. An equality
454
+ // comparison here would disagree with it about key order and about the fields a file
455
+ // omits, and refuse the run naming a field the plan never called changed.
456
+ const unmappable = Object.keys(file.document).filter((field) =>
457
+ !applicable.has(field) && !satisfies(file.document[field], live[field], field))
458
+ if (unmappable.length === 0) return
459
+ throw new CliError(
460
+ `${file.path}: ${unmappable.join(', ')} ${unmappable.length === 1 ? 'differs' : 'differ'} from the draft, `
461
+ + 'and no authoring route can apply that.',
462
+ {
463
+ code: 'USAGE',
464
+ hint: kind === 'metric' && unmappable.includes('objectType')
465
+ ? 'A metric measures one object type for its lifetime. To move it, delete the file and '
466
+ + '`apply --prune`, then write it again against the new object type.'
467
+ : 'Revert those fields to match the draft, or change them in the Console. '
468
+ + 'Applying would report a change that did not happen.',
469
+ },
470
+ )
471
+ }
472
+
473
+ /**
474
+ * `primaryKey` and `title` — the two references a file states by property apiName.
475
+ *
476
+ * Structural, so they travel `set_object_keys` rather than the metadata PUT. Without
477
+ * this, editing either was planned, counted as applied, and never written.
478
+ */
479
+ async function applyObjectKeys(
480
+ client: BlueprintAuthoringApi,
481
+ apiName: string,
482
+ file: AuthoredFile,
483
+ live: Record<string, unknown> | undefined,
484
+ revision: number,
485
+ ): Promise<number> {
486
+ const wantedKey = file.document.primaryKey
487
+ const wantedTitle = file.document.title
488
+ const keyChanged = wantedKey !== undefined && wantedKey !== live?.primaryKey
489
+ const titleChanged = wantedTitle !== undefined && wantedTitle !== live?.title
490
+ if (!keyChanged && !titleChanged) return revision
491
+
492
+ const detail = await client.getObjectTypeDetail(apiName)
493
+ if (!detail?.id) return revision
494
+ const idByApiName = new Map(
495
+ detail.properties
496
+ .filter((property) => property.apiName && property.id)
497
+ .map((property) => [String(property.apiName), String(property.id)]),
498
+ )
499
+ const resolve = (name: unknown, field: string) => {
500
+ const id = idByApiName.get(String(name))
501
+ if (!id) {
502
+ throw new CliError(`${file.path}: ${field} names "${name}", which is not one of its properties.`, {
503
+ code: 'USAGE',
504
+ hint: 'Both keys name a property by apiName.',
505
+ })
506
+ }
507
+ return id
508
+ }
509
+ // Both ids are required by the command, so the unchanged one is restated from the
510
+ // file rather than omitted — `set_object_keys` sets the pair, it does not patch one.
511
+ const result = await client.draftCommand({
512
+ kind: 'set_object_keys',
513
+ objectId: detail.id,
514
+ primaryKeyPropertyId: resolve(wantedKey ?? live?.primaryKey, 'primaryKey'),
515
+ titlePropertyId: resolve(wantedTitle ?? live?.title, 'title'),
516
+ }, revision)
517
+ return result.revision
518
+ }
519
+
520
+ /** Object-level metadata the file states and the draft does not already agree with. */
521
+ function objectTypeMetadataChanges(
522
+ file: AuthoredFile,
523
+ live: Record<string, unknown> | undefined,
524
+ ): Record<string, unknown> {
525
+ const document = file.document
526
+ const patch: Record<string, unknown> = {}
527
+ const pairs: Array<[string, string]> = [
528
+ ['displayName', 'displayName'],
529
+ ['pluralDisplayName', 'pluralName'],
530
+ ['description', 'description'],
531
+ ['icon', 'icon'],
532
+ ['color', 'color'],
533
+ ['status', 'status'],
534
+ ['visibility', 'visibility'],
535
+ ]
536
+ for (const [fileField, wireField] of pairs) {
537
+ const value = document[fileField]
538
+ if (value !== undefined && value !== live?.[fileField]) patch[wireField] = value
539
+ }
540
+ const groups = document.groups
541
+ if (Array.isArray(groups) && JSON.stringify(groups) !== JSON.stringify(live?.groups ?? [])) {
542
+ patch.groups = groups
543
+ }
544
+ return patch
545
+ }
546
+
547
+ /**
548
+ * Reconcile one object type's properties.
549
+ *
550
+ * `PUT /object-types/:apiName` takes METADATA only and ignores `properties`, so every
551
+ * field change has to travel a draft command. Additions were wired first and the rest
552
+ * were left with a comment claiming they were "refused by name" — they were not; they
553
+ * were dropped, counted as applied, and re-reported by the next `plan` forever. A
554
+ * planning tool that says it did something it did not is the one failure worth
555
+ * refusing outright, so anything this cannot map now aborts BEFORE the first write.
556
+ *
557
+ * Mapped: add, remove, and a change to any field the file can state. Unmapped: a
558
+ * property rename, which is indistinguishable from a remove plus an add for the same
559
+ * reason an object-type rename is (`rename` exists for that, and there is no
560
+ * per-property equivalent).
561
+ */
562
+ interface PropertyPlan {
563
+ adds: Array<Record<string, unknown>>
564
+ removes: Array<{ id: string; apiName: string }>
565
+ schemaChanges: Array<{ id: string; apiName: string; patch: Record<string, unknown> }>
566
+ metadataChanges: Array<{ id: string; apiName: string; patch: Record<string, unknown> }>
567
+ }
568
+
569
+ /** Fields a file states that live on `update_field_schema`. */
570
+ const SCHEMA_FIELDS = ['valueType', 'propertyType', 'required', 'unique'] as const
571
+ /** …and on `update_field_metadata`. */
572
+ const METADATA_FIELDS = ['displayName', 'description', 'status', 'visibility'] as const
573
+
574
+ /**
575
+ * @param live the projected live artifact — the same shape the FILE is written in.
576
+ *
577
+ * Not the route payload. The route answers properties in the catalog's own vocabulary,
578
+ * where the bundle's `valueType` does not exist; comparing against it made every
579
+ * declared `valueType` read as a change and produced a patch per property that changed
580
+ * nothing. The service refuses a command that changes nothing, so a pure removal
581
+ * failed with "does not change the Shared Draft" and applied none of it.
582
+ *
583
+ * @param idByApiName the route payload's ids, which the projection deliberately omits.
584
+ */
585
+ function planProperties(
586
+ file: AuthoredFile,
587
+ live: Array<Record<string, unknown>>,
588
+ backing: ReturnType<typeof readBacking>,
589
+ idByApiName: Map<string, string>,
590
+ ): PropertyPlan {
591
+ const declared = Array.isArray(file.document.properties)
592
+ ? (file.document.properties as Array<Record<string, unknown>>)
593
+ : []
594
+ const liveByName = new Map(live.map((property) => [String(property.apiName), property]))
595
+ const declaredNames = new Set(declared.map((property) => String(property.apiName)))
596
+
597
+ const plan: PropertyPlan = { adds: [], removes: [], schemaChanges: [], metadataChanges: [] }
598
+
599
+ for (const property of declared) {
600
+ const name = String(property.apiName)
601
+ const counterpart = liveByName.get(name)
602
+ if (!counterpart) {
603
+ plan.adds.push(property)
604
+ continue
605
+ }
606
+ const schemaPatch: Record<string, unknown> = {}
607
+ for (const field of SCHEMA_FIELDS) {
608
+ if (property[field] !== undefined && property[field] !== counterpart[field]) {
609
+ schemaPatch[field] = property[field]
610
+ }
611
+ }
612
+ // The column a property reads lives in `backing.mapping`, not beside the property.
613
+ const mapped = backing?.mapping?.[name]
614
+ const column = mapped === undefined ? undefined : typeof mapped === 'string' ? mapped : mapped.column
615
+ if (column !== undefined && column !== counterpart.column) schemaPatch.sourceColumn = column
616
+
617
+ const metadataPatch: Record<string, unknown> = {}
618
+ for (const field of METADATA_FIELDS) {
619
+ if (property[field] !== undefined && property[field] !== counterpart[field]) {
620
+ metadataPatch[field] = property[field]
621
+ }
622
+ }
623
+ // Carried by NAME through the plan and resolved to an id at write time: the
624
+ // shared field may not exist yet when the plan is computed. Attaching to a
625
+ // shared field is `update_field_metadata`'s job, so it travels that patch.
626
+ //
627
+ // An authored `null` means detached, and a live field with no reference is
628
+ // ALREADY that — so the two are compared as the same state. Treating them as
629
+ // different made a file that says `sharedField: null` re-plan a detach on
630
+ // every run and then fail it, because the applied command changes nothing and
631
+ // the service refuses a command that does not change the Shared Draft.
632
+ if (property.sharedField !== undefined) {
633
+ const wanted = property.sharedField === null ? null : property.sharedField
634
+ const current = counterpart.sharedField ?? null
635
+ if (wanted !== current) metadataPatch.sharedField = wanted
636
+ }
637
+ const id = idByApiName.get(name)
638
+ // A declared property with no live id is either brand new (handled above) or the
639
+ // draft moved; skipping it quietly is how a change goes missing.
640
+ if (!id) continue
641
+ if (Object.keys(schemaPatch).length > 0) plan.schemaChanges.push({ id, apiName: name, patch: schemaPatch })
642
+ if (Object.keys(metadataPatch).length > 0) {
643
+ plan.metadataChanges.push({ id, apiName: name, patch: metadataPatch })
644
+ }
645
+ }
646
+
647
+ for (const property of live) {
648
+ const name = String(property.apiName)
649
+ const id = idByApiName.get(name)
650
+ if (!declaredNames.has(name) && id) plan.removes.push({ id, apiName: name })
651
+ }
652
+
653
+ return plan
654
+ }
655
+
656
+ async function applyPropertyPlan(
657
+ client: BlueprintAuthoringApi,
658
+ apiName: string,
659
+ file: AuthoredFile,
660
+ liveDocument: Record<string, unknown> | undefined,
661
+ revision: number,
662
+ sharedPropertyIds: Map<string, string>,
663
+ ): Promise<number> {
664
+ const detail = await client.getObjectTypeDetail(apiName)
665
+ if (!detail?.id) {
666
+ // Silently returning here reported the operation as applied. The plan said this
667
+ // type exists; if it does not, the plan is stale and saying so is the answer.
668
+ throw new CliError(`"${apiName}" is no longer on the draft.`, {
669
+ code: 'CONFLICT',
670
+ hint: 'frontera blueprint plan — the draft moved under this run.',
671
+ })
672
+ }
673
+ const backing = readBacking(file)
674
+ const idByApiName = new Map(
675
+ detail.properties
676
+ .filter((property) => property.apiName && property.id)
677
+ .map((property) => [String(property.apiName), String(property.id)]),
678
+ )
679
+ const liveProperties = Array.isArray(liveDocument?.properties)
680
+ ? (liveDocument.properties as Array<Record<string, unknown>>)
681
+ : []
682
+ const plan = planProperties(file, liveProperties, backing, idByApiName)
683
+
684
+ // Every add needs a column, and the service offers no name-match fallback. Checked
685
+ // for ALL of them before the first write, so a partial run cannot start.
686
+ for (const property of plan.adds) {
687
+ const name = String(property.apiName)
688
+ if (backing?.mapping?.[name] === undefined) {
689
+ throw new CliError(
690
+ `${file.path}: new property "${name}" has no column in \`backing.mapping\`.`,
691
+ {
692
+ code: 'USAGE',
693
+ hint: 'A property reads a column, and there is no name-match fallback. Add it to backing.mapping.',
694
+ },
695
+ )
696
+ }
697
+ }
698
+
699
+ // A property that leaves AND one that arrives in the same edit is the shape a
700
+ // rename takes, and it is indistinguishable from a genuine replacement — the two
701
+ // have opposite consequences for the data behind the field. The earlier guard only
702
+ // caught it when `backing.mapping` lacked the new name; update the mapping too and
703
+ // it silently ran remove_field + add_field, which is the data drop the refusal was
704
+ // supposed to prevent.
705
+ if (plan.adds.length > 0 && plan.removes.length > 0) {
706
+ throw new CliError(
707
+ `${file.path}: this edit removes ${plan.removes.map((entry) => entry.apiName).join(', ')} and adds `
708
+ + `${plan.adds.map((entry) => String(entry.apiName)).join(', ')} at once.`,
709
+ {
710
+ code: 'USAGE',
711
+ hint: 'If that is a rename, there is no per-property rename command — do the removal and the '
712
+ + 'addition in separate applies, so the data consequence is a decision rather than a side effect.',
713
+ },
714
+ )
715
+ }
716
+
717
+ let current = revision
718
+ for (const property of plan.adds) {
719
+ const name = String(property.apiName)
720
+ const mapped = backing!.mapping[name]!
721
+ const column = typeof mapped === 'string' ? mapped : mapped.column
722
+ const sourceField = typeof mapped === 'string' ? undefined : mapped.field
723
+ // Resolved here rather than at plan time: a shared field created earlier in
724
+ // this same run only entered the map when its create answered.
725
+ const sharedPropertyId = resolveSharedProperty(
726
+ file, name, property.sharedField, sharedPropertyIds,
727
+ )
728
+ const result = await client.draftCommand({
729
+ kind: 'add_field',
730
+ objectId: detail.id,
731
+ field: {
732
+ // The one identifier the CLI generates, and it never reaches a file: the
733
+ // command addresses a field that does not exist yet, so there is nothing on
734
+ // the draft to resolve a name against. It lives for one request.
735
+ id: crypto.randomUUID(),
736
+ apiName: name,
737
+ displayName: String(property.displayName ?? name),
738
+ propertyType: (property.propertyType as string | undefined) ?? 'attribute',
739
+ valueType: String(property.valueType ?? 'string'),
740
+ sourceColumn: column,
741
+ ...(sourceField === undefined || sourceField === null ? {} : { sourceField }),
742
+ required: Boolean(property.required ?? false),
743
+ unique: Boolean(property.unique ?? false),
744
+ status: 'active',
745
+ visibility: 'normal',
746
+ // A field being ADDED has no reference to detach, so an authored `null`
747
+ // is simply "no shared field" — `add_field`'s uuid is not nullable, and a
748
+ // detach command on a field that is being created makes no sense anyway.
749
+ ...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
750
+ },
751
+ }, current)
752
+ current = result.revision
753
+ }
754
+ for (const change of plan.schemaChanges) {
755
+ const result = await client.draftCommand({
756
+ kind: 'update_field_schema', objectId: detail.id, fieldId: change.id, patch: change.patch,
757
+ }, current)
758
+ current = result.revision
759
+ }
760
+ for (const change of plan.metadataChanges) {
761
+ // `sharedField` is the file's name for it and `sharedPropertyId` the wire's.
762
+ // `null` survives the resolution and travels as `null`: that is the detach
763
+ // signal on both sides, and dropping it left this patch empty.
764
+ const { sharedField, ...patch } = change.patch
765
+ const sharedPropertyId = resolveSharedProperty(
766
+ file, change.apiName, sharedField, sharedPropertyIds,
767
+ )
768
+ const result = await client.draftCommand({
769
+ kind: 'update_field_metadata',
770
+ objectId: detail.id,
771
+ fieldId: change.id,
772
+ patch: {
773
+ ...patch,
774
+ ...(sharedPropertyId === undefined ? {} : { sharedPropertyId }),
775
+ },
776
+ }, current)
777
+ current = result.revision
778
+ }
779
+ for (const removal of plan.removes) {
780
+ const result = await client.draftCommand({
781
+ kind: 'remove_field', objectId: detail.id, fieldId: removal.id,
782
+ }, current)
783
+ current = result.revision
784
+ }
785
+ return current
786
+ }
787
+
788
+ async function pruneOperation(
789
+ client: BlueprintAuthoringApi,
790
+ operation: ModelOperation,
791
+ revision: number,
792
+ ): Promise<number> {
793
+ if (operation.kind === 'object-type') {
794
+ const objectType = await client.getObjectType(operation.apiName)
795
+ if (!objectType?.id) return revision
796
+ // Structural: preview, then apply the exact preview. Migration blockers the
797
+ // preview reports abort the run with them printed — never bypassed.
798
+ const removed = await client.removeObject(objectType.id, revision)
799
+ return removed.revision
800
+ }
801
+ // Deleting a shared field DEGRADES its implementers rather than being refused:
802
+ // each keeps the description and format it was inheriting. Deleting a label must
803
+ // not delete the mappings underneath it.
804
+ const result = operation.kind === 'link-type'
805
+ ? await client.deleteLinkType(operation.apiName, revision)
806
+ : operation.kind === 'shared-field'
807
+ ? await client.deleteSharedProperty(operation.apiName, revision)
808
+ : await client.deleteMetric(operation.apiName, revision)
809
+ return nextRevision(result, revision)
810
+ }
811
+
812
+ export const blueprintApply: Command = {
813
+ meta: {
814
+ noun: 'blueprint',
815
+ verb: 'apply',
816
+ args: [],
817
+ flags: { dir: 'string', prune: 'boolean', yes: 'boolean' },
818
+ aliases: { y: 'yes' },
819
+ summary: 'Reconcile the shared draft with the file tree',
820
+ examples: [
821
+ 'frontera blueprint apply --yes',
822
+ 'frontera blueprint apply --prune --yes',
823
+ ],
824
+ },
825
+ async run(ctx) {
826
+ const client = api(ctx)
827
+ const prune = ctx.flags.prune === true
828
+ const { plan, files, definition, drift } = await computePlan(ctx)
829
+
830
+ if (isEmpty(plan)) {
831
+ return { data: { ...plan, applied: [] }, text: renderPlan(plan) }
832
+ }
833
+
834
+ // Drift is REPORTED, and blocks only the artifacts it is about. It used to throw
835
+ // before the plan was even shown, so a colleague's type reading a moved dataset
836
+ // refused an unrelated creation and hid what the run would have done. A rebind is
837
+ // `bind`'s act either way — the plan says so on the line.
838
+ const driftingNames = new Set(drift.map((entry) => entry.objectApiName))
839
+ const blocked = plan.operations.filter((operation) =>
840
+ operation.kind === 'object-type' && driftingNames.has(operation.apiName))
841
+ if (blocked.length > 0) {
842
+ throw new CliError(
843
+ `${blocked.map((operation) => operation.apiName).join(', ')} read a dataset that now publishes a `
844
+ + 'different column contract. Re-bind before applying those.',
845
+ { code: 'CONFLICT', hint: renderDrift(drift) || 'frontera blueprint plan' },
846
+ )
847
+ }
848
+
849
+ if (ctx.flags.yes !== true) {
850
+ throw new CliError(
851
+ `This would apply ${plan.operations.length} change${plan.operations.length === 1 ? '' : 's'}.\n`
852
+ + `${renderPlan(plan, { prune })}\n\nRe-run with --yes to apply.`,
853
+ { code: 'USAGE', hint: 'frontera blueprint apply --yes' },
854
+ )
855
+ }
856
+
857
+ const byKey = new Map(files.map((file) => [`${file.kind}:${file.apiName}`, file]))
858
+ const applied: string[] = []
859
+ try {
860
+ // Creations before updates, and object types before the kinds that reference
861
+ // them: a link names both of its endpoints, and a metric names the type it
862
+ // measures. `ARTIFACT_KINDS` fixes that order.
863
+ const ordered = [...plan.operations].sort(byKindThenCreateFirst)
864
+ // Seeded from the revision the PLAN was computed against, then advanced by what
865
+ // each write returns. Never re-read — see `nextRevision`.
866
+ let revision = plan.revision
867
+ // Shared field apiName → id: what the draft already had, plus what this run
868
+ // creates. A file references a shared field by name and the wire takes an
869
+ // id, and one created in this run has no id until its create answers.
870
+ const sharedPropertyIds = indexBundle(definition).sharedPropertyIdByApiName
871
+ for (const operation of ordered) {
872
+ const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
873
+ revision = await applyOperation(client, operation, file, revision, sharedPropertyIds)
874
+ applied.push(`${operation.operation} ${operation.kind} ${operation.apiName}`)
875
+ }
876
+ if (prune) {
877
+ for (const operation of plan.prunes) {
878
+ revision = await pruneOperation(client, operation, revision)
879
+ applied.push(`delete ${operation.kind} ${operation.apiName}`)
880
+ }
881
+ }
882
+ } catch (err) {
883
+ // What landed is as important as what failed: the draft is shared, and a
884
+ // partial run that does not say where it stopped cannot be resumed safely.
885
+ const done = applied.length
886
+ ? `Applied before failing:\n${applied.map((line) => ` ${line}`).join('\n')}\n`
887
+ : 'Nothing was applied.\n'
888
+ throw new CliError(`${done}${(err as Error).message}`, {
889
+ code: err instanceof CliError ? err.code : 'FAILURE',
890
+ hint: 'frontera blueprint plan — re-plan against the current draft, then re-apply.',
891
+ })
892
+ }
893
+
894
+ const revision = await client.revision()
895
+ return {
896
+ data: { applied, revision },
897
+ text: `${renderPlan(plan, { prune })}\n\nApplied ${applied.length} change`
898
+ + `${applied.length === 1 ? '' : 's'}. Draft is at revision ${revision}.`,
899
+ }
900
+ },
901
+ }
902
+
903
+ function byKindThenCreateFirst(left: ModelOperation, right: ModelOperation): number {
904
+ const kindOrder = ARTIFACT_KINDS.indexOf(left.kind) - ARTIFACT_KINDS.indexOf(right.kind)
905
+ if (kindOrder !== 0) return kindOrder
906
+ const rank = (operation: ModelOperation) => (operation.operation === 'create' ? 0 : 1)
907
+ return rank(left) - rank(right)
908
+ }
909
+
910
+ export const blueprintNew: Command = {
911
+ meta: {
912
+ noun: 'blueprint',
913
+ verb: 'new',
914
+ args: [
915
+ { name: 'kind', required: true, description: `One of: ${ARTIFACT_KINDS.join(', ')}` },
916
+ { name: 'apiName', required: true, description: 'The artifact’s API name' },
917
+ ],
918
+ flags: { dir: 'string' },
919
+ offline: true,
920
+ summary: 'Scaffold an artifact file that validates as written',
921
+ examples: [
922
+ 'frontera blueprint new object-type Customer',
923
+ 'frontera blueprint new link-type customerHoldsPolicy',
924
+ 'frontera blueprint new shared-field accountCode',
925
+ ],
926
+ },
927
+ async run(ctx) {
928
+ const kind = ctx.positional[0] as ArtifactKind
929
+ if (!ARTIFACT_KINDS.includes(kind)) {
930
+ throw new CliError(
931
+ `Unknown kind "${ctx.positional[0] ?? ''}". Expected one of: ${ARTIFACT_KINDS.join(', ')}.`,
932
+ { code: 'USAGE', hint: 'frontera blueprint new --help' },
933
+ )
934
+ }
935
+ const apiName = ctx.positional[1]
936
+ if (!apiName) {
937
+ throw new CliError('An apiName is required.', {
938
+ code: 'USAGE',
939
+ hint: `frontera blueprint new ${kind} <apiName>`,
940
+ })
941
+ }
942
+ if (!API_NAME_RULE[kind].test(apiName)) {
943
+ throw new CliError(`"${apiName}" is not a valid ${kind} apiName.`, {
944
+ code: 'USAGE',
945
+ hint: `${describeApiNameRule(kind)} — and it becomes the file name, so nothing else fits.`,
946
+ })
947
+ }
948
+ const directory = root(ctx)
949
+ const relative = pathFor(kind, apiName)
950
+ if (existsSync(resolve(directory, relative))) {
951
+ throw new CliError(`${relative} already exists.`, {
952
+ code: 'USAGE',
953
+ hint: 'Edit it, or choose another apiName.',
954
+ })
955
+ }
956
+ const file: AuthoredFile = {
957
+ kind,
958
+ apiName,
959
+ path: relative,
960
+ document: scaffold(kind, apiName),
961
+ }
962
+ writeTree(directory, [file])
963
+ return {
964
+ data: { path: relative },
965
+ text: `Wrote ${relative}. Edit it, then \`frontera blueprint plan\`.`,
966
+ }
967
+ },
968
+ }
969
+
970
+ export const blueprintRename: Command = {
971
+ meta: {
972
+ noun: 'blueprint',
973
+ verb: 'rename',
974
+ args: [
975
+ { name: 'kind', required: true, description: 'object-type' },
976
+ { name: 'from', required: true, description: 'Current apiName' },
977
+ { name: 'to', required: true, description: 'New apiName' },
978
+ ],
979
+ flags: { dir: 'string' },
980
+ summary: 'Rename an object type, and its file, as one act',
981
+ examples: ['frontera blueprint rename object-type Client Customer'],
982
+ },
983
+ async run(ctx) {
984
+ const [kind, from, to] = ctx.positional
985
+ if (kind === 'object-type' && to && !API_NAME_RULE['object-type'].test(to)) {
986
+ throw new CliError(`"${to}" is not a valid object-type apiName.`, {
987
+ code: 'USAGE',
988
+ hint: `${describeApiNameRule('object-type')} — and it becomes the file name.`,
989
+ })
990
+ }
991
+ if (kind !== 'object-type') {
992
+ throw new CliError('Only object types can be renamed in place.', {
993
+ code: 'USAGE',
994
+ // Being specific about the alternative rather than just refusing: for the
995
+ // other kinds a delete-and-create is genuinely equivalent, because nothing
996
+ // hangs off their identity the way data hangs off an object type's.
997
+ hint: 'For a link type, metric or action, rename the file and apply with --prune. '
998
+ + 'A shared field renamed that way is DELETED and recreated, which detaches every '
999
+ + 'field implementing it — rename it in the Console instead.',
1000
+ })
1001
+ }
1002
+ if (!from || !to) {
1003
+ throw new CliError('Both the current and the new apiName are required.', {
1004
+ code: 'USAGE',
1005
+ hint: 'frontera blueprint rename object-type <from> <to>',
1006
+ })
1007
+ }
1008
+ const client = api(ctx)
1009
+ const objectType = await client.getObjectType(from)
1010
+ if (!objectType?.id) {
1011
+ throw new CliError(`No object type "${from}" on the draft.`, {
1012
+ code: 'NOT_FOUND',
1013
+ hint: 'frontera blueprint catalog',
1014
+ })
1015
+ }
1016
+ await client.draftCommand(
1017
+ { kind: 'rename_object_api', objectId: objectType.id, apiName: to },
1018
+ await client.revision(),
1019
+ )
1020
+
1021
+ // The file moves with it. Leaving the tree behind would make the next `plan`
1022
+ // report a create plus a prune — the exact pair this command exists to avoid.
1023
+ const directory = root(ctx)
1024
+ const files = existsSync(directory) ? readTree(directory) : []
1025
+ const existing = files.find((file) => file.kind === 'object-type' && file.apiName === from)
1026
+ if (existing) {
1027
+ writeTree(directory, [{
1028
+ kind: 'object-type',
1029
+ apiName: to,
1030
+ path: pathFor('object-type', to),
1031
+ document: { ...existing.document, apiName: to },
1032
+ }])
1033
+ // The old file is DELETED, not left for the reader to tidy. Leaving it produced
1034
+ // exactly the create-plus-prune pair this command exists to avoid, which made
1035
+ // the instruction to delete it a step you had to obey for the rename to hold.
1036
+ removeFromTree(directory, [existing.path])
1037
+ }
1038
+ return {
1039
+ data: { from, to, movedFile: Boolean(existing) },
1040
+ text: `Renamed object type "${from}" to "${to}".`
1041
+ + (existing ? ` Moved ${existing.path} to ${pathFor('object-type', to)}.` : ''),
1042
+ }
1043
+ },
1044
+ }
1045
+
1046
+ export const blueprintDeclarativeCommands: Command[] = [
1047
+ blueprintPull,
1048
+ blueprintPlan,
1049
+ blueprintApply,
1050
+ blueprintNew,
1051
+ blueprintRename,
1052
+ ]