@frontera-sdk/cli 0.1.0 → 1.43.5
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,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]
|