@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,271 @@
1
+ import { CliError, UsageError } from '../../errors'
2
+ import {
3
+ AutomationApi,
4
+ type AutomationRunStep,
5
+ type AutomationRunSummary,
6
+ type RegistryStatus,
7
+ } from '../../api/automation-api'
8
+ import { flagBool, type Command, type CommandContext } from '../types'
9
+
10
+ /**
11
+ * How long to wait for a queued run to reach a terminal state.
12
+ *
13
+ * Shorter than the runner's own 10m ceiling on purpose. A CLI that blocks for
14
+ * ten minutes reads as hung, and the run is not lost when this gives up — it is
15
+ * still recorded, and the timeout message says where to look. Bounding the WAIT
16
+ * is not the same as bounding the RUN.
17
+ */
18
+ const WAIT_TIMEOUT_MS = 120_000
19
+ const POLL_INTERVAL_MS = 2_000
20
+
21
+ /**
22
+ * How long to wait for a runner to register the current generation.
23
+ *
24
+ * A separate, shorter budget than the run wait: this one is bounded by a poll
25
+ * interval and a PUT, not by how long an automation takes.
26
+ */
27
+ const CATCHUP_TIMEOUT_MS = 45_000
28
+
29
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled'])
30
+
31
+ /**
32
+ * Can the broker invoke what the service has deployed?
33
+ *
34
+ * The whole reason this check exists: an invoke event matches functions at
35
+ * INGEST, so one sent before the runner has registered the new generation
36
+ * matches nothing and is dropped. The run never appears and no amount of waiting
37
+ * recovers it, because the event is gone. `deploy` then `run` is the most
38
+ * natural pair of commands an author types, and it hit this every time.
39
+ *
40
+ * A null `registeredGeneration` means no runner has ever registered — a
41
+ * different failure, and one worth naming differently.
42
+ */
43
+ export function isRunnerCaughtUp(status: RegistryStatus): boolean {
44
+ return status.registeredGeneration !== null
45
+ && status.registeredGeneration >= status.generation
46
+ }
47
+
48
+ /** Has this run stopped, or might it still change? */
49
+ export function isTerminal(status: string): boolean {
50
+ return TERMINAL.has(status)
51
+ }
52
+
53
+ /**
54
+ * Is `latest` the run this invoke started, or one that already existed?
55
+ *
56
+ * By ID and not by count. Counting rows would call a concurrently-firing cron's
57
+ * run "mine" and report ITS outcome as the outcome of this command — and on an
58
+ * automation whose cron is every five minutes that is not a rare race. A null
59
+ * baseline means the automation had never run, so any row is new.
60
+ */
61
+ export function isNewRun(priorId: string | null, latest: { id: string } | undefined): boolean {
62
+ return latest !== undefined && latest.id !== priorId
63
+ }
64
+
65
+ function requireSlug(ctx: CommandContext): string {
66
+ const slug = ctx.positional[0]
67
+ if (!slug) {
68
+ throw new UsageError(
69
+ 'missing <slug>',
70
+ 'run `frontera automation list` — then pass the slug of the one you mean',
71
+ )
72
+ }
73
+ return slug
74
+ }
75
+
76
+ /** ms as something short enough to sit at the end of a trail line. */
77
+ function duration(ms: number | null): string {
78
+ if (ms === null) return ''
79
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`
80
+ }
81
+
82
+ /**
83
+ * A run's steps, nested under the step each `ctx` call ran inside.
84
+ *
85
+ * Rows arrive in `seq` order — the order they were written — which interleaves
86
+ * two steps' children once a run has more than one. Walking the parent map is
87
+ * what keeps each step with its own calls.
88
+ *
89
+ * Exported for its own test: the ordering is the part that is easy to get
90
+ * subtly wrong and impossible to notice by eye.
91
+ */
92
+ export function describeTrail(steps: AutomationRunStep[]): string[] {
93
+ const byParent = new Map<string | null, AutomationRunStep[]>()
94
+ const known = new Set(steps.map((s) => s.id))
95
+ for (const step of steps) {
96
+ // A parent outside this list is treated as top level rather than dropped —
97
+ // losing a row from the trail is worse than losing its indent.
98
+ const parent = step.parentStepId && known.has(step.parentStepId) ? step.parentStepId : null
99
+ byParent.set(parent, [...(byParent.get(parent) ?? []), step])
100
+ }
101
+
102
+ const lines: string[] = []
103
+ const walk = (parent: string | null, depth: number): void => {
104
+ for (const step of byParent.get(parent) ?? []) {
105
+ const indent = ' '.repeat(depth + 1)
106
+ const kind = step.stepName !== null ? 'step' : step.kind
107
+ const failed = step.status === 'error' ? ' ✗' : ''
108
+ const took = duration(step.durationMs)
109
+ lines.push(`${indent}${step.label} ${kind}${failed}${took ? ` ${took}` : ''}`)
110
+ walk(step.id, depth + 1)
111
+ }
112
+ }
113
+ walk(null, 0)
114
+ return lines
115
+ }
116
+
117
+ /** One line describing a finished run, its trail, and its output. */
118
+ export function describeRun(run: AutomationRunSummary, steps: AutomationRunStep[] = []): string {
119
+ const lines = [`${run.status} — v${run.version}, ${run.ctxCalls} ctx call(s)`]
120
+ if (run.errorMessage) lines.push(` ${run.errorMessage}`)
121
+ // The trail is why an author ran this by hand: which steps executed, in what
122
+ // order, and where the time went. Without it the CLI reported an outcome and
123
+ // sent them to the Console to find out what happened.
124
+ lines.push(...describeTrail(steps))
125
+ if (run.result !== null && run.result !== undefined) {
126
+ lines.push(` returned ${JSON.stringify(run.result)}`)
127
+ }
128
+ return lines.join('\n')
129
+ }
130
+
131
+ /**
132
+ * Start a run and, by default, wait for it.
133
+ *
134
+ * Waiting is the default because the reason to run an automation by hand is to
135
+ * find out what it does. A verb that printed "queued" and exited would leave the
136
+ * author to discover the answer some other way — and until now there was no
137
+ * other way from the CLI at all.
138
+ *
139
+ * The run is identified by taking the newest run id BEFORE the invoke and
140
+ * waiting for a different one to appear. Comparing ids rather than counting
141
+ * rows, because a cron firing concurrently also adds a row; and reading the
142
+ * baseline first, because a run that finishes before the first poll would
143
+ * otherwise look like it never started.
144
+ */
145
+ export const automationRun: Command = {
146
+ meta: {
147
+ noun: 'automation',
148
+ verb: 'run',
149
+ args: [{ name: 'slug', required: true, description: 'automation slug' }],
150
+ flags: { 'no-wait': 'boolean' },
151
+ summary: 'Run the live version now and report what it did',
152
+ examples: [
153
+ 'frontera automation run daily-digest',
154
+ 'frontera automation run daily-digest --no-wait',
155
+ ],
156
+ },
157
+
158
+ async run(ctx) {
159
+ const slug = requireSlug(ctx)
160
+ const client = new AutomationApi(ctx.apiUrl, ctx.token)
161
+
162
+ // Wait for the runner BEFORE invoking, not after. An event sent early is
163
+ // dropped rather than queued, so there is nothing to wait for afterwards.
164
+ const catchupDeadline = Date.now() + CATCHUP_TIMEOUT_MS
165
+ let status = await client.registryStatus()
166
+ while (!isRunnerCaughtUp(status) && Date.now() < catchupDeadline) {
167
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
168
+ status = await client.registryStatus()
169
+ }
170
+ if (!isRunnerCaughtUp(status)) {
171
+ throw new CliError(
172
+ status.registeredGeneration === null
173
+ ? 'no automation runner has ever registered with the broker, so nothing '
174
+ + 'can execute this automation'
175
+ : `the runner is still on registry generation ${status.registeredGeneration} `
176
+ + `and the service is on ${status.generation} — a version deployed since `
177
+ + 'then is not invocable yet',
178
+ {
179
+ code: 'REQUEST_FAILED',
180
+ // Refusing BEFORE sending is the point: an invoke event sent now would
181
+ // match no function and vanish, and the author would be told their run
182
+ // never appeared without being told why.
183
+ hint: 'wait for the runner to pick up the deploy, then run this again',
184
+ },
185
+ )
186
+ }
187
+
188
+ const before = await client.runs(slug, 1)
189
+ const priorId = before[0]?.id ?? null
190
+
191
+ const queued = await client.run(slug)
192
+
193
+ if (flagBool(ctx, 'no-wait')) {
194
+ return {
195
+ data: queued,
196
+ text: `Queued a run of ${slug} v${queued.version}.\n`
197
+ + ` frontera automation runs ${slug} # to see how it went`,
198
+ }
199
+ }
200
+
201
+ const deadline = Date.now() + WAIT_TIMEOUT_MS
202
+ let seen: AutomationRunSummary | undefined
203
+ while (Date.now() < deadline) {
204
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
205
+ const [latest] = await client.runs(slug, 1)
206
+ if (isNewRun(priorId, latest) && latest) {
207
+ seen = latest
208
+ if (isTerminal(latest.status)) {
209
+ // Best-effort: a run that finished is the answer, and failing to read
210
+ // its trail must not turn a successful run into a failed command.
211
+ const steps = await client.runSteps(latest.id).catch(() => [])
212
+ return { data: { ...latest, steps }, text: `${slug}: ${describeRun(latest, steps)}` }
213
+ }
214
+ }
215
+ }
216
+
217
+ // NOT an error about the automation — an error about the wait. The
218
+ // distinction matters: the run may yet succeed, and telling an author their
219
+ // automation failed when it is merely slow would send them to debug working
220
+ // code.
221
+ throw new CliError(
222
+ seen
223
+ ? `the run started but was still ${seen.status} after ${WAIT_TIMEOUT_MS / 1000}s`
224
+ : `no run appeared within ${WAIT_TIMEOUT_MS / 1000}s — the invoke was accepted, `
225
+ + 'so either no runner is polling or it has not picked the event up',
226
+ {
227
+ code: 'REQUEST_FAILED',
228
+ hint: `frontera automation runs ${slug}`,
229
+ },
230
+ )
231
+ },
232
+ }
233
+
234
+ /** Recent runs, newest first — the pull-visibility half of "did it work". */
235
+ export const automationRuns: Command = {
236
+ meta: {
237
+ noun: 'automation',
238
+ verb: 'runs',
239
+ args: [{ name: 'slug', required: true, description: 'automation slug' }],
240
+ flags: {},
241
+ summary: 'List recent runs, newest first, with what each one returned',
242
+ examples: ['frontera automation runs daily-digest'],
243
+ },
244
+
245
+ async run(ctx) {
246
+ const slug = requireSlug(ctx)
247
+ const rows = await new AutomationApi(ctx.apiUrl, ctx.token).runs(slug)
248
+
249
+ if (rows.length === 0) {
250
+ return {
251
+ data: rows,
252
+ text: `No runs recorded for ${slug}.\n`
253
+ + ` frontera automation run ${slug} # to start one`,
254
+ }
255
+ }
256
+
257
+ return {
258
+ data: rows,
259
+ text: rows
260
+ .map((r) => {
261
+ // The MESSAGE, not a truncation of it: a budget refusal or a missing
262
+ // grant names its own remedy, and cutting it at 40 columns to fit a
263
+ // table is how the remedy gets lost. One block per run instead.
264
+ const head = `${r.startedAt} ${r.status} v${r.version}`
265
+ + ` ${r.triggerSource} ${r.ctxCalls} ctx call(s)`
266
+ return [head, ...describeRun(r).split('\n').slice(1)].join('\n')
267
+ })
268
+ .join('\n'),
269
+ }
270
+ },
271
+ }
@@ -0,0 +1,410 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
+ import { CliError } from '../../errors'
6
+ import type { Command, CommandContext } from '../types'
7
+
8
+ /**
9
+ * Blueprint authoring from the CLI.
10
+ *
11
+ * These verbs were reserved in the command table until the organization-level shared
12
+ * draft had a credential that could reach it. It does now: an organization API key
13
+ * (`sk-org-`) satisfies an organization-scoped permission check, which a workspace key
14
+ * is refused by design because Blueprint's draft is shared across every workspace in
15
+ * the organization.
16
+ *
17
+ * Every mutation reads the current draft revision first and sends it back as
18
+ * `expectedRevision`. The draft is shared, so a blind write is a lost update; the
19
+ * service answers 409 when it has moved and the command says so rather than retrying
20
+ * over someone else's change.
21
+ */
22
+
23
+ function api(ctx: CommandContext): BlueprintAuthoringApi {
24
+ return new BlueprintAuthoringApi(ctx.apiUrl, ctx.token)
25
+ }
26
+
27
+ /** Object sets are addressed by id; a person types the name they can see. */
28
+ async function objectSetId(client: BlueprintAuthoringApi, name: string): Promise<string> {
29
+ const sets = await client.listObjectSets()
30
+ const matches = sets.filter((set) => set.name === name || set.id === name)
31
+ const only = matches.length === 1 ? matches[0] : undefined
32
+ if (only?.id) return only.id
33
+ if (matches.length > 1) {
34
+ throw new CliError(`"${name}" names ${matches.length} object sets.`, {
35
+ code: 'USAGE',
36
+ hint: `Use the id: ${matches.map((match) => match.id).join(', ')}`,
37
+ })
38
+ }
39
+ throw new CliError(`No object set named "${name}".`, {
40
+ code: 'NOT_FOUND',
41
+ hint: 'frontera blueprint catalog',
42
+ })
43
+ }
44
+
45
+ /** Read a JSON document from `--file`, or from the positional path. */
46
+ function readDocument(ctx: CommandContext, positionalIndex = 0): Record<string, unknown> {
47
+ const path = (ctx.flags.file as string) ?? ctx.positional[positionalIndex]
48
+ if (!path) {
49
+ throw new CliError('A JSON document is required: pass --file <path>.', {
50
+ code: 'USAGE',
51
+ hint: 'frontera blueprint create object-type --file ./object-type.json',
52
+ })
53
+ }
54
+ const full = resolve(ctx.cwd, path)
55
+ let raw: string
56
+ try {
57
+ raw = readFileSync(full, 'utf8')
58
+ } catch {
59
+ throw new CliError(`Cannot read ${full}`, {
60
+ code: 'USAGE',
61
+ hint: 'Check the path, or pass --file with a path relative to the current directory.',
62
+ })
63
+ }
64
+ try {
65
+ return JSON.parse(raw) as Record<string, unknown>
66
+ } catch (err) {
67
+ throw new CliError(`${full} is not valid JSON: ${(err as Error).message}`, {
68
+ code: 'USAGE',
69
+ hint: 'Validate the document, e.g. `jq . < ' + full + '`.',
70
+ })
71
+ }
72
+ }
73
+
74
+ /**
75
+ * `object-set` is here rather than in the file tree on purpose: a static set names
76
+ * individual data rows and a set may carry a workspaceId, neither of which survives a
77
+ * move between deployments. It is curation, not Blueprint.
78
+ */
79
+ const KINDS = ['object-type', 'link-type', 'metric', 'action', 'object-set'] as const
80
+ type Kind = (typeof KINDS)[number]
81
+
82
+ function readKind(ctx: CommandContext): Kind {
83
+ const kind = ctx.positional[0] as Kind
84
+ if (!KINDS.includes(kind)) {
85
+ throw new CliError(
86
+ `Unknown kind "${ctx.positional[0] ?? ''}". Expected one of: ${KINDS.join(', ')}.`,
87
+ { code: 'USAGE', hint: 'frontera blueprint create --help' },
88
+ )
89
+ }
90
+ return kind
91
+ }
92
+
93
+ export const blueprintAdopt: Command = {
94
+ meta: {
95
+ noun: 'blueprint',
96
+ verb: 'adopt',
97
+ args: [],
98
+ flags: {},
99
+ summary: 'Create this organization’s shared Blueprint draft',
100
+ examples: ['frontera blueprint adopt'],
101
+ },
102
+ async run(ctx) {
103
+ const result = await api(ctx).adopt()
104
+ return { data: result, text: 'Shared Blueprint draft adopted.' }
105
+ },
106
+ }
107
+
108
+ export const blueprintCreate: Command = {
109
+ meta: {
110
+ noun: 'blueprint',
111
+ verb: 'create',
112
+ args: [
113
+ { name: 'kind', required: true, description: `One of: ${KINDS.join(', ')}` },
114
+ ],
115
+ flags: { file: 'string' },
116
+ aliases: { f: 'file' },
117
+ summary: 'Create an object type, link type or metric on the shared draft',
118
+ examples: [
119
+ 'frontera blueprint create object-type --file customer.json',
120
+ 'frontera blueprint create metric --file revenue.json',
121
+ ],
122
+ },
123
+ async run(ctx) {
124
+ const kind = readKind(ctx)
125
+ const client = api(ctx)
126
+ const document = readDocument(ctx, 1)
127
+ const revision = await client.revision()
128
+
129
+ const created =
130
+ kind === 'object-type' ? await client.createObjectType(document, revision)
131
+ : kind === 'link-type' ? await client.createLinkType(document, revision)
132
+ : kind === 'action' ? await client.createAction(document, revision)
133
+ // Object sets are not draft artifacts, so they carry no expectedRevision.
134
+ : kind === 'object-set' ? await client.createObjectSet(document)
135
+ : await client.createMetric(document, revision)
136
+
137
+ return { data: created, text: `Created ${kind} "${document.apiName}" on draft revision ${revision}.` }
138
+ },
139
+ }
140
+
141
+ export const blueprintUpdate: Command = {
142
+ meta: {
143
+ noun: 'blueprint',
144
+ verb: 'update',
145
+ args: [
146
+ { name: 'kind', required: true, description: `One of: ${KINDS.join(', ')}` },
147
+ { name: 'apiName', required: true, description: 'The artifact’s API name' },
148
+ ],
149
+ flags: { file: 'string' },
150
+ aliases: { f: 'file' },
151
+ summary: 'Update an artifact on the shared draft',
152
+ examples: ['frontera blueprint update object-type Customer --file customer.json'],
153
+ },
154
+ async run(ctx) {
155
+ const kind = readKind(ctx)
156
+ const apiName = ctx.positional[1]
157
+ if (!apiName) {
158
+ throw new CliError('An apiName is required.', {
159
+ code: 'USAGE',
160
+ hint: 'frontera blueprint update object-type <apiName> --file ./patch.json',
161
+ })
162
+ }
163
+ const client = api(ctx)
164
+ const document = readDocument(ctx, 2)
165
+ const revision = await client.revision()
166
+
167
+ const updated =
168
+ kind === 'object-type' ? await client.updateObjectType(apiName, document, revision)
169
+ : kind === 'link-type' ? await client.updateLinkType(apiName, document, revision)
170
+ : kind === 'action' ? await client.updateAction(apiName, document, revision)
171
+ : kind === 'object-set' ? await client.updateObjectSet(await objectSetId(client, apiName), document)
172
+ : await client.updateMetric(apiName, document, revision)
173
+
174
+ return { data: updated, text: `Updated ${kind} "${apiName}" on draft revision ${revision}.` }
175
+ },
176
+ }
177
+
178
+ export const blueprintDelete: Command = {
179
+ meta: {
180
+ noun: 'blueprint',
181
+ verb: 'delete',
182
+ args: [
183
+ { name: 'kind', required: true, description: `One of: ${KINDS.join(', ')}` },
184
+ { name: 'apiName', required: true, description: 'The artifact’s API name' },
185
+ ],
186
+ flags: {},
187
+ summary: 'Remove an artifact from the shared draft',
188
+ examples: ['frontera blueprint delete metric revenuePerCustomer'],
189
+ },
190
+ async run(ctx) {
191
+ const kind = readKind(ctx)
192
+ const apiName = ctx.positional[1]
193
+ if (!apiName) {
194
+ throw new CliError('An apiName is required.', {
195
+ code: 'USAGE',
196
+ hint: 'frontera blueprint delete metric <apiName>',
197
+ })
198
+ }
199
+ const client = api(ctx)
200
+
201
+ if (kind === 'object-type') {
202
+ // Removing an object is structural: preview, then apply the exact preview. The
203
+ // service refuses an apply whose digest does not match what it showed.
204
+ const objectType = await client.getObjectType(apiName)
205
+ if (!objectType?.id) {
206
+ throw new CliError(`No object type "${apiName}" on the draft.`, {
207
+ code: 'NOT_FOUND',
208
+ hint: 'frontera blueprint list',
209
+ })
210
+ }
211
+ const result = await client.removeObject(objectType.id, await client.revision())
212
+ return { data: result, text: `Removed object type "${apiName}" (previewed, then applied).` }
213
+ }
214
+
215
+ if (kind === 'object-set') {
216
+ const removed = await client.deleteObjectSet(await objectSetId(client, apiName))
217
+ return { data: removed, text: `Removed object set "${apiName}".` }
218
+ }
219
+
220
+ const revision = await client.revision()
221
+ const removed = kind === 'link-type'
222
+ ? await client.deleteLinkType(apiName, revision)
223
+ : kind === 'action'
224
+ ? await client.deleteAction(apiName, revision)
225
+ : await client.deleteMetric(apiName, revision)
226
+ return { data: removed, text: `Removed ${kind} "${apiName}" on draft revision ${revision}.` }
227
+ },
228
+ }
229
+
230
+ export const blueprintValidate: Command = {
231
+ meta: {
232
+ noun: 'blueprint',
233
+ verb: 'validate',
234
+ args: [],
235
+ flags: {},
236
+ summary: 'Validate the shared draft and produce a report',
237
+ examples: ['frontera blueprint validate'],
238
+ },
239
+ async run(ctx) {
240
+ const report = await api(ctx).validate()
241
+ const id = report?.id ?? report?.reportId
242
+ return {
243
+ data: report,
244
+ // The id matters: `publish` takes it, which is what ties a release to the
245
+ // validation that cleared it rather than to whatever the draft holds later.
246
+ text: `Draft validated. Report ${id} — pass it to \`frontera blueprint publish\`.`,
247
+ }
248
+ },
249
+ }
250
+
251
+ export const blueprintPublish: Command = {
252
+ meta: {
253
+ noun: 'blueprint',
254
+ verb: 'publish',
255
+ args: [],
256
+ flags: { label: 'string', notes: 'string', report: 'string', instruction: 'string' },
257
+ summary: 'Publish the shared draft as a release',
258
+ examples: [
259
+ 'frontera blueprint publish --label v3 --notes "Adds Customer"',
260
+ 'frontera blueprint publish --label v4 --instruction "Backfilled"',
261
+ ],
262
+ },
263
+ async run(ctx) {
264
+ const client = api(ctx)
265
+ const label = (ctx.flags.label as string) ?? 'cli-release'
266
+ const notes = (ctx.flags.notes as string) ?? 'Published with the Frontera CLI.'
267
+
268
+ // Validate first when no report was given: publishing against a stale report
269
+ // would release something nobody checked.
270
+ let validationReportId = ctx.flags.report as string | undefined
271
+ if (!validationReportId) {
272
+ const report = await client.validate()
273
+ validationReportId = report?.id ?? report?.reportId
274
+ }
275
+ if (!validationReportId) {
276
+ throw new CliError('Validation produced no report id; cannot publish.', {
277
+ code: 'FAILURE',
278
+ hint: 'frontera blueprint validate',
279
+ })
280
+ }
281
+
282
+ const released = await client.publish({
283
+ expectedRevision: await client.revision(),
284
+ validationReportId,
285
+ releaseLabel: label,
286
+ releaseNotes: notes,
287
+ // A release that discards changes needs an instruction per discarded change.
288
+ instruction: ctx.flags.instruction as string | undefined,
289
+ })
290
+ return { data: released, text: `Published release "${label}".` }
291
+ },
292
+ }
293
+
294
+ export const blueprintRollback: Command = {
295
+ meta: {
296
+ noun: 'blueprint',
297
+ verb: 'rollback',
298
+ args: [
299
+ { name: 'releaseId', required: true, description: 'The release to roll back to' },
300
+ ],
301
+ flags: { label: 'string', notes: 'string', instruction: 'string' },
302
+ summary: 'Roll the organization back to an earlier release',
303
+ examples: ['frontera blueprint rollback <releaseId> --label revert'],
304
+ },
305
+ async run(ctx) {
306
+ const releaseId = ctx.positional[0]
307
+ if (!releaseId) {
308
+ throw new CliError('A releaseId is required.', {
309
+ code: 'USAGE',
310
+ hint: 'frontera blueprint status',
311
+ })
312
+ }
313
+ const result = await api(ctx).rollback(releaseId, {
314
+ releaseLabel: (ctx.flags.label as string) ?? 'cli-rollback',
315
+ releaseNotes: (ctx.flags.notes as string) ?? 'Rolled back with the Frontera CLI.',
316
+ instruction: ctx.flags.instruction as string | undefined,
317
+ })
318
+ return { data: result, text: `Rolled back to release ${releaseId}.` }
319
+ },
320
+ }
321
+
322
+ export const blueprintStatus: Command = {
323
+ meta: {
324
+ noun: 'blueprint',
325
+ verb: 'status',
326
+ args: [],
327
+ flags: {},
328
+ summary: 'Show the draft revision and the active release',
329
+ examples: ['frontera blueprint status'],
330
+ },
331
+ async run(ctx) {
332
+ const lifecycle = await api(ctx).lifecycleOrNull()
333
+ if (!lifecycle) {
334
+ // Not an error: every organization starts here.
335
+ return {
336
+ data: { adopted: false },
337
+ text: 'This organization has no Blueprint yet.\n Run `frontera blueprint adopt` to create the shared draft.',
338
+ }
339
+ }
340
+ const revision = lifecycle?.draft?.revision
341
+ const active = lifecycle?.activeRelease
342
+ return {
343
+ data: lifecycle,
344
+ text: [
345
+ `Draft revision: ${revision ?? '(no draft)'}`,
346
+ active?.id
347
+ ? `Active release: ${active.releaseLabel ?? `#${active.releaseNumber ?? '?'}`} (${active.id})`
348
+ : 'Active release: (none)',
349
+ ].join('\n'),
350
+ }
351
+ },
352
+ }
353
+
354
+ export const blueprintCatalog: Command = {
355
+ meta: {
356
+ noun: 'blueprint',
357
+ verb: 'catalog',
358
+ args: [],
359
+ flags: {},
360
+ summary: 'List what is on the shared draft: object types, links and metrics',
361
+ examples: ['frontera blueprint catalog', 'frontera blueprint catalog --json'],
362
+ },
363
+ async run(ctx) {
364
+ // `blueprint list` reads the WORKSPACE's granted slice, which an organization key
365
+ // has no grant for — it authors the org's draft rather than reading one workspace's
366
+ // view of it. This is the equivalent read for that credential.
367
+ const client = api(ctx)
368
+ const [objectTypes, linkTypes, metrics] = await Promise.all([
369
+ client.listObjectTypes(),
370
+ client.listLinkTypes(),
371
+ client.listMetrics(),
372
+ ])
373
+
374
+ // Links and metrics reference object types by ID, not by API name. Rendering the
375
+ // raw field would print nothing useful, so resolve through the list already
376
+ // fetched rather than showing a blank column.
377
+ const nameById = new Map<string, string>()
378
+ for (const o of objectTypes) {
379
+ if (o.id && o.apiName) nameById.set(o.id, o.apiName)
380
+ }
381
+ const resolve = (id?: string) => (id ? (nameById.get(id) ?? id) : '?')
382
+
383
+ const lines = [
384
+ `Object types (${objectTypes.length})`,
385
+ ...objectTypes.map((o) => ` ${o.apiName ?? '?'}${o.displayName ? ` — ${o.displayName}` : ''}`),
386
+ '',
387
+ `Link types (${linkTypes.length})`,
388
+ ...linkTypes.map(
389
+ (l) => ` ${l.apiName ?? '?'} — ${resolve(l.fromObjectTypeId)} → ${resolve(l.toObjectTypeId)}`,
390
+ ),
391
+ '',
392
+ `Metrics (${metrics.length})`,
393
+ ...metrics.map((m) => ` ${m.apiName ?? '?'} — on ${resolve(m.objectTypeId)}`),
394
+ ]
395
+
396
+ return { data: { objectTypes, linkTypes, metrics }, text: lines.join('\n') }
397
+ },
398
+ }
399
+
400
+ export const blueprintAuthoringCommands: Command[] = [
401
+ blueprintCatalog,
402
+ blueprintAdopt,
403
+ blueprintStatus,
404
+ blueprintCreate,
405
+ blueprintUpdate,
406
+ blueprintDelete,
407
+ blueprintValidate,
408
+ blueprintPublish,
409
+ blueprintRollback,
410
+ ]