@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,164 @@
1
+ import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
2
+ import { CliError } from '../../errors'
3
+ import type { Command, CommandContext } from '../types'
4
+
5
+ /**
6
+ * Who may read which object types.
7
+ *
8
+ * Addressed by NAME rather than id, because an organization key holds no workspace
9
+ * and the ids are not otherwise discoverable from a terminal — the whole point of the
10
+ * `org-workspaces` and `org-agents` reads, which the lane already opens. An ambiguous
11
+ * name is an error listing the candidates: guessing which of two workspaces called
12
+ * "Ops" was meant is a guess about who can read the Blueprint.
13
+ */
14
+
15
+ function api(ctx: CommandContext): BlueprintAuthoringApi {
16
+ return new BlueprintAuthoringApi(ctx.apiUrl, ctx.token)
17
+ }
18
+
19
+ async function resolveSubject(
20
+ client: BlueprintAuthoringApi,
21
+ subject: 'workspace' | 'agent',
22
+ nameOrId: string,
23
+ ): Promise<{ id: string; name: string }> {
24
+ const candidates = subject === 'workspace' ? await client.orgWorkspaces() : await client.orgAgents()
25
+ const byId = candidates.find((candidate) => candidate.id === nameOrId)
26
+ if (byId?.id) return { id: byId.id, name: byId.name ?? byId.id }
27
+
28
+ const matches = candidates.filter((candidate) =>
29
+ candidate.name === nameOrId
30
+ || (subject === 'workspace' && (candidate as { slug?: string }).slug === nameOrId))
31
+ const only = matches.length === 1 ? matches[0] : undefined
32
+ if (only?.id) return { id: only.id, name: only.name ?? only.id }
33
+ if (matches.length > 1) {
34
+ throw new CliError(
35
+ `"${nameOrId}" names ${matches.length} ${subject}s in this organization.`,
36
+ {
37
+ code: 'USAGE',
38
+ hint: `Use the id: ${matches.map((match) => match.id).join(', ')}`,
39
+ },
40
+ )
41
+ }
42
+ throw new CliError(`No ${subject} named "${nameOrId}" in this organization.`, {
43
+ code: 'NOT_FOUND',
44
+ hint: candidates.length
45
+ ? `Known ${subject}s: ${candidates.map((candidate) => candidate.name).filter(Boolean).join(', ')}`
46
+ : `No ${subject}s are visible to this credential.`,
47
+ })
48
+ }
49
+
50
+ export const blueprintGrant: Command = {
51
+ meta: {
52
+ noun: 'blueprint',
53
+ verb: 'grant',
54
+ args: [
55
+ { name: 'subject', required: true, description: 'workspace | agent | list' },
56
+ { name: 'name', required: false, description: 'Workspace or agent, by name or id' },
57
+ { name: 'apiName...', required: false, description: 'One or more object type API names' },
58
+ ],
59
+ flags: { revoke: 'boolean' },
60
+ summary: 'Grant or revoke an object type for a workspace or an agent',
61
+ examples: [
62
+ 'frontera blueprint grant workspace Ops Customer Policy',
63
+ 'frontera blueprint grant agent "Claims bot" Customer --revoke',
64
+ 'frontera blueprint grant list',
65
+ ],
66
+ },
67
+ async run(ctx) {
68
+ const client = api(ctx)
69
+ const [subject, name, ...apiNames] = ctx.positional
70
+
71
+ if (subject === 'list') {
72
+ const workspaces = await client.orgWorkspaces()
73
+ const agents = await client.orgAgents()
74
+ const rows: Array<{
75
+ subject: string
76
+ name: string
77
+ granted: string[]
78
+ unreadable?: string
79
+ }> = []
80
+ /**
81
+ * A subject the key cannot read is REPORTED, not fatal.
82
+ *
83
+ * Grants are workspace- and agent-addressed, so a key whose workspace list omits
84
+ * one is refused for that row alone. Failing the whole listing because a single
85
+ * subject is out of scope would hide every subject that IS in scope — and the
86
+ * out-of-scope ones are exactly what the reader needs to see to understand why.
87
+ */
88
+ const readGrants = async (
89
+ subject: 'workspace' | 'agent',
90
+ id: string,
91
+ name: string,
92
+ ) => {
93
+ try {
94
+ const grants = subject === 'workspace'
95
+ ? await client.workspaceGrants(id)
96
+ : await client.agentGrants(id)
97
+ rows.push({
98
+ subject,
99
+ name,
100
+ granted: grants.map((grant) => grant.objectTypeApiName ?? '').filter(Boolean),
101
+ })
102
+ } catch (err) {
103
+ const forbidden = err instanceof CliError && err.code === 'FORBIDDEN'
104
+ if (!forbidden) throw err
105
+ rows.push({
106
+ subject,
107
+ name,
108
+ granted: [],
109
+ unreadable: `not in this key's ${subject} list`,
110
+ })
111
+ }
112
+ }
113
+
114
+ for (const workspace of workspaces) {
115
+ if (workspace.id) await readGrants('workspace', workspace.id, workspace.name ?? workspace.id)
116
+ }
117
+ for (const agent of agents) {
118
+ if (agent.id) await readGrants('agent', agent.id, agent.name ?? agent.id)
119
+ }
120
+ const text = rows.length === 0
121
+ ? 'No workspaces or agents are visible to this credential.'
122
+ : rows
123
+ .map((row) => `${row.subject} ${row.name}: `
124
+ + (row.unreadable ? `— ${row.unreadable}` : row.granted.join(', ') || '(none)'))
125
+ .join('\n')
126
+ return { data: rows, text }
127
+ }
128
+
129
+ if (subject !== 'workspace' && subject !== 'agent') {
130
+ throw new CliError(
131
+ `Unknown subject "${subject ?? ''}". Expected workspace, agent or list.`,
132
+ { code: 'USAGE', hint: 'frontera blueprint grant --help' },
133
+ )
134
+ }
135
+ if (!name) {
136
+ throw new CliError(`A ${subject} name or id is required.`, {
137
+ code: 'USAGE',
138
+ hint: `frontera blueprint grant ${subject} <name> <apiName…>`,
139
+ })
140
+ }
141
+ if (apiNames.length === 0) {
142
+ throw new CliError('At least one object type apiName is required.', {
143
+ code: 'USAGE',
144
+ hint: `frontera blueprint grant ${subject} ${name} Customer`,
145
+ })
146
+ }
147
+
148
+ const resolved = await resolveSubject(client, subject, name)
149
+ const revoke = ctx.flags.revoke === true
150
+ for (const apiName of apiNames) {
151
+ if (subject === 'workspace') {
152
+ if (revoke) await client.revokeWorkspace(resolved.id, apiName)
153
+ else await client.grantWorkspace(resolved.id, apiName)
154
+ } else if (revoke) await client.revokeAgent(resolved.id, apiName)
155
+ else await client.grantAgent(resolved.id, apiName)
156
+ }
157
+
158
+ return {
159
+ data: { subject, id: resolved.id, name: resolved.name, apiNames, revoked: revoke },
160
+ text: `${revoke ? 'Revoked' : 'Granted'} ${apiNames.join(', ')} `
161
+ + `${revoke ? 'from' : 'to'} ${subject} "${resolved.name}".`,
162
+ }
163
+ },
164
+ }
@@ -0,0 +1,431 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+
4
+ import { DatasetApi, type DatasetColumn } from '../../api/dataset-api'
5
+ import { CliError } from '../../errors'
6
+ import { table } from '../../table'
7
+ import type { Command } from '../types'
8
+
9
+ /**
10
+ * Datasets — the source contract a Blueprint object type reads.
11
+ *
12
+ * `blueprint bind` names a dataset, and `backing.dataset` in a committed tree names one
13
+ * too. Until this noun existed the CLI could author everything that REFERRED to a
14
+ * dataset and nothing that CREATED one, so standing up an organization always broke at
15
+ * the same step: open the Console, make the dataset by hand, come back.
16
+ *
17
+ * All three forms the Console offers are here, chosen by `kind` in the definition file
18
+ * rather than by three verbs: they differ in where the COLUMNS come from, and nothing
19
+ * else. `blank` declares them, `source` reads them from a relation on a connected
20
+ * Source, `file` reads them from a CSV.
21
+ *
22
+ * `blank` is the only one a committed tree can fully describe, and the only one a
23
+ * Blueprint binding strictly needs. The other two are here because an FDE has to be
24
+ * able to do them without the Console, not because a tree can hold them: a Source is
25
+ * made elsewhere and a CSV's bytes live outside the tree by nature, so both are named
26
+ * rather than inlined.
27
+ */
28
+
29
+ const COLUMN_TYPES = ['text', 'numeric', 'boolean', 'date', 'timestamptz'] as const
30
+
31
+ /** Same rule the service enforces, checked here to save a round trip. */
32
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/
33
+
34
+ interface DatasetFile {
35
+ kind?: unknown
36
+ apiName?: unknown
37
+ displayName?: unknown
38
+ description?: unknown
39
+ columns?: unknown
40
+ keyColumns?: unknown
41
+ deterministicKeyConfirmed?: unknown
42
+ /** `source` only. */
43
+ source?: unknown
44
+ schema?: unknown
45
+ relation?: unknown
46
+ /** `source` and `file`. */
47
+ includedColumns?: unknown
48
+ /** `file` only — a path, relative to the definition file's own directory. */
49
+ path?: unknown
50
+ }
51
+
52
+ function names(raw: unknown, field: string, path: string): string[] {
53
+ if (!Array.isArray(raw) || raw.length === 0 || raw.some((n) => typeof n !== 'string' || !n)) {
54
+ throw new CliError(`${path} needs a non-empty \`${field}\` list of column names.`, {
55
+ code: 'USAGE',
56
+ hint: `frontera dataset preview ./file.csv lists what a CSV holds; frontera dataset sources lists Sources.`,
57
+ })
58
+ }
59
+ return raw as string[]
60
+ }
61
+
62
+ function readColumns(raw: unknown, path: string): DatasetColumn[] {
63
+ if (!Array.isArray(raw) || raw.length === 0) {
64
+ throw new CliError(`${path} declares no \`columns\`.`, {
65
+ code: 'USAGE',
66
+ hint: 'columns: [{ name, databaseType, nullable }] — at least one.',
67
+ })
68
+ }
69
+ return raw.map((entry, position) => {
70
+ const where = `${path}: columns[${position}]`
71
+ if (!entry || typeof entry !== 'object') {
72
+ throw new CliError(`${where} is not a mapping.`, {
73
+ code: 'USAGE',
74
+ hint: 'Each column is { name, databaseType, nullable }.',
75
+ })
76
+ }
77
+ const column = entry as Record<string, unknown>
78
+ const name = column.name
79
+ if (typeof name !== 'string' || !IDENTIFIER.test(name)) {
80
+ throw new CliError(`${where} has an unusable \`name\`.`, {
81
+ code: 'USAGE',
82
+ // Named rather than normalised: silently lowercasing a column would produce a
83
+ // contract that does not match the source it is supposed to describe.
84
+ hint: 'A column name is lower_snake_case: a letter or underscore, then letters, digits, underscores.',
85
+ })
86
+ }
87
+ const databaseType = column.databaseType
88
+ if (typeof databaseType !== 'string' || !COLUMN_TYPES.includes(databaseType as never)) {
89
+ throw new CliError(`${where} has an unknown \`databaseType\`.`, {
90
+ code: 'USAGE',
91
+ hint: `One of: ${COLUMN_TYPES.join(', ')}`,
92
+ })
93
+ }
94
+ if (typeof column.nullable !== 'boolean') {
95
+ throw new CliError(`${where} does not say whether it is \`nullable\`.`, {
96
+ code: 'USAGE',
97
+ // Not defaulted: `required` on a Blueprint property is enforced against this,
98
+ // and guessing it wrong is discovered at bind time or not at all.
99
+ hint: 'nullable: true or false. A Blueprint `required` property may not read a nullable column carrying nulls.',
100
+ })
101
+ }
102
+ return { name, databaseType, nullable: column.nullable } as DatasetColumn
103
+ })
104
+ }
105
+
106
+ const list: Command = {
107
+ meta: {
108
+ noun: 'dataset',
109
+ verb: 'list',
110
+ args: [],
111
+ flags: {},
112
+ summary: 'List the datasets this organization can bind an object type to',
113
+ examples: ['frontera dataset list', 'frontera dataset list --json'],
114
+ },
115
+ async run(ctx) {
116
+ const rows = await new DatasetApi(ctx.apiUrl, ctx.token).list()
117
+ return {
118
+ data: rows,
119
+ text: rows.length === 0
120
+ ? 'No datasets in this organization.'
121
+ : table(
122
+ ['apiName', 'displayName', 'id'],
123
+ rows.map((row) => [row.apiName ?? row.name ?? '?', row.displayName ?? '', row.id ?? '']),
124
+ [30, 30, undefined],
125
+ ),
126
+ }
127
+ },
128
+ }
129
+
130
+ const get: Command = {
131
+ meta: {
132
+ noun: 'dataset',
133
+ verb: 'get',
134
+ args: [{ name: 'apiName', required: true, description: 'The dataset’s API name' }],
135
+ flags: {},
136
+ summary: 'Show a dataset and the column contract of its current revision',
137
+ examples: ['frontera dataset get customers'],
138
+ },
139
+ async run(ctx) {
140
+ const apiName = ctx.positional[0]
141
+ if (!apiName) {
142
+ throw new CliError('A dataset apiName is required.', {
143
+ code: 'USAGE',
144
+ hint: 'frontera dataset list',
145
+ })
146
+ }
147
+ const api = new DatasetApi(ctx.apiUrl, ctx.token)
148
+ const datasets = await api.list()
149
+ const dataset = datasets.find((entry) => (entry.apiName ?? entry.name) === apiName)
150
+ if (!dataset?.id) {
151
+ throw new CliError(`No dataset named "${apiName}" in this organization.`, {
152
+ code: 'NOT_FOUND',
153
+ // The empty case reads as "missing" and is usually a capability, not a fact.
154
+ hint: datasets.length === 0
155
+ ? 'No datasets are visible at all — the key may have been minted without dataset:read.'
156
+ : `Visible: ${datasets.map((entry) => entry.apiName ?? entry.name).filter(Boolean).join(', ')}`,
157
+ })
158
+ }
159
+ const revisions = await api.revisions(dataset.id)
160
+ const current = revisions.find((entry) => entry.id === dataset.currentRevisionId) ?? revisions.at(-1)
161
+ return {
162
+ data: { dataset, revision: current },
163
+ text: [
164
+ `${apiName} — ${dataset.displayName ?? ''}`.trim(),
165
+ current?.columns?.length
166
+ ? table(
167
+ ['column', 'type', 'nullable'],
168
+ current.columns.map((column) => [
169
+ column.name ?? '?', column.databaseType ?? '?', String(column.nullable ?? ''),
170
+ ]),
171
+ )
172
+ : 'No published revision yet — nothing can bind to it.',
173
+ ].join('\n'),
174
+ }
175
+ },
176
+ }
177
+
178
+ const sources: Command = {
179
+ meta: {
180
+ noun: 'dataset',
181
+ verb: 'sources',
182
+ args: [],
183
+ flags: {},
184
+ summary: 'List the connected Sources a dataset can be pulled from',
185
+ examples: ['frontera dataset sources'],
186
+ },
187
+ async run(ctx) {
188
+ const rows = await new DatasetApi(ctx.apiUrl, ctx.token).listSources()
189
+ return {
190
+ data: rows,
191
+ text: rows.length === 0
192
+ ? 'No Sources in this organization. A Source is connected in the Console.'
193
+ : table(
194
+ ['displayName', 'type', 'status', 'id'],
195
+ rows.map((row) => [
196
+ row.displayName ?? '?', row.connectorType ?? '', row.status ?? '', row.id ?? '',
197
+ ]),
198
+ ),
199
+ }
200
+ },
201
+ }
202
+
203
+ const preview: Command = {
204
+ meta: {
205
+ noun: 'dataset',
206
+ verb: 'preview',
207
+ args: [{ name: 'path', required: true, description: 'Path to a CSV' }],
208
+ flags: {},
209
+ summary: 'Read a CSV’s columns without creating anything',
210
+ examples: ['frontera dataset preview ./customers.csv'],
211
+ },
212
+ async run(ctx) {
213
+ const path = ctx.positional[0]
214
+ if (!path) {
215
+ throw new CliError('A path to a CSV is required.', {
216
+ code: 'USAGE',
217
+ hint: 'frontera dataset preview ./customers.csv',
218
+ })
219
+ }
220
+ // The file form has to declare `includedColumns`, and nobody can write that list
221
+ // before seeing what the CSV holds. This is that step, and it creates nothing.
222
+ const result = await new DatasetApi(ctx.apiUrl, ctx.token)
223
+ .uploadPreview(resolve(ctx.cwd, path))
224
+ const columns = result.columns ?? []
225
+ return {
226
+ data: result,
227
+ text: [
228
+ `${columns.length} column${columns.length === 1 ? '' : 's'}, ${result.rowCount ?? 0} rows`,
229
+ table(
230
+ ['column', 'type', 'nullable'],
231
+ columns.map((c) => [c.name ?? '?', c.databaseType ?? '?', String(c.nullable ?? '')]),
232
+ ),
233
+ ].join('\n'),
234
+ }
235
+ },
236
+ }
237
+
238
+ const testSource: Command = {
239
+ meta: {
240
+ noun: 'dataset',
241
+ verb: 'test-source',
242
+ args: [{ name: 'source', required: true, description: 'The Source’s displayName' }],
243
+ flags: {},
244
+ summary: 'Verify a Source can be reached, which `kind: source` requires',
245
+ examples: ['frontera dataset test-source "Local Postgres"'],
246
+ },
247
+ async run(ctx) {
248
+ const name = ctx.positional[0]
249
+ if (!name) {
250
+ throw new CliError('A Source displayName is required.', {
251
+ code: 'USAGE',
252
+ hint: 'frontera dataset sources',
253
+ })
254
+ }
255
+ const api = new DatasetApi(ctx.apiUrl, ctx.token)
256
+ const source = (await api.listSources()).find((entry) => entry.displayName === name)
257
+ const revisionId = source?.currentRevision?.id
258
+ if (!revisionId) {
259
+ throw new CliError(`No Source named "${name}", or it has no current revision.`, {
260
+ code: 'NOT_FOUND',
261
+ hint: 'frontera dataset sources',
262
+ })
263
+ }
264
+ // A separate verb rather than something `create` does implicitly: this opens a
265
+ // connection to somebody else's database, and a command that reaches the network
266
+ // as a side effect of reading a file is a command nobody can predict.
267
+ const result = await api.testSourceRevision(revisionId)
268
+ // A test that could not connect still answers 200 — the verdict is in the body, and
269
+ // reading only the status code reported "reachable" for a refused connection, which
270
+ // then surfaced as an unexplained CONFLICT at `create`.
271
+ const test = result.latestConnectionTest ?? result
272
+ if (test.status !== 'verified') {
273
+ throw new CliError(
274
+ `"${name}" could not be reached: ${test.errorMessage ?? test.status ?? 'unknown'}.`,
275
+ {
276
+ code: 'FAILURE',
277
+ hint: test.errorCode === 'NETWORK_DENIED'
278
+ // Worth naming: a private or loopback address is refused unless the
279
+ // deployment explicitly permits that path, which reads as a broken Source.
280
+ ? 'The adapter refused the address. A private or loopback host needs the deployment to permit it explicitly.'
281
+ : `frontera dataset sources — the Source is registered but its connection failed (${test.errorCode ?? 'no code'}).`,
282
+ },
283
+ )
284
+ }
285
+ return {
286
+ data: { source: name, revisionId, test },
287
+ text: `"${name}" is reachable. It can now back a dataset: frontera dataset create --file <kind: source>`,
288
+ }
289
+ },
290
+ }
291
+
292
+ const create: Command = {
293
+ meta: {
294
+ noun: 'dataset',
295
+ verb: 'create',
296
+ args: [],
297
+ flags: { file: 'string' },
298
+ aliases: { f: 'file' },
299
+ summary: 'Create a dataset — from declared columns, a Source relation, or a CSV',
300
+ examples: [
301
+ 'frontera dataset create --file ./customers.json',
302
+ ],
303
+ },
304
+ async run(ctx) {
305
+ const filePath = ctx.flags.file as string | undefined
306
+ if (!filePath) {
307
+ throw new CliError('A dataset definition is required: pass --file <file>.', {
308
+ code: 'USAGE',
309
+ hint: 'The file names a `kind`: blank, source, or file.',
310
+ })
311
+ }
312
+ const path = resolve(ctx.cwd, filePath)
313
+ const document = JSON.parse(readFileSync(path, 'utf8')) as DatasetFile
314
+
315
+ const apiName = document.apiName
316
+ if (typeof apiName !== 'string' || !apiName) {
317
+ throw new CliError(`${filePath} names no \`apiName\`.`, {
318
+ code: 'USAGE',
319
+ hint: 'apiName is how `backing.dataset` and `blueprint bind` refer to it.',
320
+ })
321
+ }
322
+ const displayName = typeof document.displayName === 'string' && document.displayName
323
+ ? document.displayName
324
+ : apiName
325
+ const description = typeof document.description === 'string'
326
+ ? { description: document.description }
327
+ : {}
328
+
329
+ const keyColumns = document.keyColumns === undefined || document.keyColumns === null
330
+ ? null
331
+ : document.keyColumns
332
+ if (keyColumns !== null && !Array.isArray(keyColumns)) {
333
+ throw new CliError(`${filePath} has \`keyColumns\` that is neither a list nor null.`, {
334
+ code: 'USAGE',
335
+ hint: 'keyColumns: ["id"] for a keyed dataset, or null when no column identifies a row.',
336
+ })
337
+ }
338
+ // Only ever true when the file DECLARED key columns. The flag attests that the key
339
+ // identifies a row uniquely, and attesting that of a key nobody named is a claim
340
+ // the file never made.
341
+ const deterministicKeyConfirmed = document.deterministicKeyConfirmed === true && keyColumns !== null
342
+ const common = {
343
+ apiName,
344
+ displayName,
345
+ ...description,
346
+ keyColumns: keyColumns as string[] | null,
347
+ deterministicKeyConfirmed,
348
+ }
349
+
350
+ const api = new DatasetApi(ctx.apiUrl, ctx.token)
351
+ const kind = document.kind ?? 'blank'
352
+ let created
353
+
354
+ if (kind === 'blank') {
355
+ const columns = readColumns(document.columns, filePath)
356
+ const declared = new Set(columns.map((column) => column.name))
357
+ for (const key of (keyColumns ?? []) as string[]) {
358
+ if (!declared.has(key)) {
359
+ throw new CliError(`${filePath}: keyColumns names "${key}", which is not a declared column.`, {
360
+ code: 'USAGE',
361
+ hint: `Declared: ${[...declared].join(', ')}`,
362
+ })
363
+ }
364
+ }
365
+ created = await api.createBlank({ ...common, columns })
366
+ } else if (kind === 'source') {
367
+ const sourceName = document.source
368
+ if (typeof sourceName !== 'string' || !sourceName) {
369
+ throw new CliError(`${filePath} names no \`source\`.`, {
370
+ code: 'USAGE',
371
+ hint: 'frontera dataset sources — the file names one by displayName, never by id.',
372
+ })
373
+ }
374
+ // Named, not id'd: a Source id is minted per deployment, so a definition
375
+ // carrying one would be correct here and wrong in the next organization —
376
+ // the same reason nothing in a Blueprint tree carries a uuid.
377
+ const all = await api.listSources()
378
+ const source = all.find((entry) => entry.displayName === sourceName)
379
+ if (!source?.id) {
380
+ throw new CliError(`No Source named "${sourceName}" in this organization.`, {
381
+ code: 'NOT_FOUND',
382
+ hint: all.length === 0
383
+ ? 'No Sources are visible — a Source is connected in the Console, and reading them needs dataSource:read.'
384
+ : `Visible: ${all.map((entry) => entry.displayName).filter(Boolean).join(', ')}`,
385
+ })
386
+ }
387
+ const schema = document.schema
388
+ const relation = document.relation
389
+ if (typeof schema !== 'string' || !schema || typeof relation !== 'string' || !relation) {
390
+ throw new CliError(`${filePath} needs both \`schema\` and \`relation\`.`, {
391
+ code: 'USAGE',
392
+ hint: 'The relation this dataset pulls, e.g. schema: public, relation: customers.',
393
+ })
394
+ }
395
+ created = await api.createFromSource({
396
+ ...common,
397
+ sourceId: source.id,
398
+ schema,
399
+ relation,
400
+ includedColumns: names(document.includedColumns, 'includedColumns', filePath),
401
+ })
402
+ } else if (kind === 'file') {
403
+ const csv = document.path
404
+ if (typeof csv !== 'string' || !csv) {
405
+ throw new CliError(`${filePath} names no \`path\` to a CSV.`, {
406
+ code: 'USAGE',
407
+ hint: 'path is resolved relative to the definition file, so the pair can move together.',
408
+ })
409
+ }
410
+ // Relative to the DEFINITION, not the shell's cwd: the two travel together in a
411
+ // repo, and resolving against cwd broke the moment anyone ran it from elsewhere.
412
+ created = await api.createFromFile(
413
+ resolve(dirname(path), csv),
414
+ { ...common, includedColumns: names(document.includedColumns, 'includedColumns', filePath) },
415
+ )
416
+ } else {
417
+ throw new CliError(`${filePath} has an unknown \`kind\`: ${String(kind)}.`, {
418
+ code: 'USAGE',
419
+ hint: 'One of: blank, source, file.',
420
+ })
421
+ }
422
+
423
+ return {
424
+ data: created,
425
+ text: `Created dataset "${apiName}". `
426
+ + `Bind an object type to it: frontera blueprint bind <ObjectType> --dataset ${apiName} --plan ./map.json`,
427
+ }
428
+ },
429
+ }
430
+
431
+ export const datasetCommands: Command[] = [list, get, sources, preview, testSource, create]