@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.
- package/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- 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
|
+
]
|