@frontera-sdk/cli 1.45.6 → 1.45.8
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 -4
- package/src/api/automation-api.ts +11 -1
- package/src/api/blueprint-authoring-api.ts +27 -26
- package/src/api/credential-failure.ts +77 -0
- package/src/api/dataset-api.ts +40 -1
- package/src/api/governed-action-api.ts +32 -9
- package/src/api/platform-api.ts +11 -0
- package/src/api/validation-detail.ts +87 -0
- package/src/commands/action/deploy.ts +1 -1
- package/src/commands/action/grant.ts +1 -1
- package/src/commands/action/list.ts +1 -1
- package/src/commands/action/prepare.ts +1 -1
- package/src/commands/action/requests.ts +4 -1
- package/src/commands/action/review.ts +1 -1
- package/src/commands/agent/index-commands.ts +29 -8
- package/src/commands/app/promote.ts +6 -1
- package/src/commands/app/sdk.ts +1 -1
- package/src/commands/automation/dev.ts +1 -1
- package/src/commands/automation/index-commands.ts +5 -5
- package/src/commands/automation/pull.ts +1 -1
- package/src/commands/automation/run.ts +3 -3
- package/src/commands/blueprint/authoring.ts +51 -12
- package/src/commands/blueprint/declarative.ts +7 -1
- package/src/commands/blueprint/generate-types.ts +3 -0
- package/src/commands/blueprint/get.ts +3 -0
- package/src/commands/blueprint/grants.ts +32 -5
- package/src/commands/blueprint/list.ts +3 -0
- package/src/commands/blueprint/query.ts +6 -0
- package/src/commands/dataset/index-commands.ts +152 -5
- package/src/commands/kit/status.ts +24 -3
- package/src/commands/knowledge/index-commands.ts +25 -16
- package/src/commands/knowledge/upload-batch.ts +55 -0
- package/src/commands/secret/index-commands.ts +4 -13
- package/src/commands/skill/bundle-commands.ts +9 -11
- package/src/commands/source/index-commands.ts +79 -1
- package/src/commands/types.ts +23 -0
- package/src/commands/workspace-id.ts +38 -0
- package/src/main.ts +69 -32
- package/src/scopes.ts +56 -0
- package/src/vendor/kit-assets.json +9 -9
- package/src/vendor/sdk-sources.json +1 -1
|
@@ -132,7 +132,7 @@ const deploy: Command = {
|
|
|
132
132
|
)
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
135
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
136
136
|
const result = await client.deploy(slug, {
|
|
137
137
|
manifest,
|
|
138
138
|
bundle,
|
|
@@ -173,7 +173,7 @@ const list: Command = {
|
|
|
173
173
|
},
|
|
174
174
|
|
|
175
175
|
async run(ctx) {
|
|
176
|
-
const rows = await new AutomationApi(ctx.apiUrl, ctx.token).list()
|
|
176
|
+
const rows = await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).list()
|
|
177
177
|
|
|
178
178
|
return {
|
|
179
179
|
data: rows,
|
|
@@ -209,7 +209,7 @@ const versions: Command = {
|
|
|
209
209
|
|
|
210
210
|
async run(ctx) {
|
|
211
211
|
const slug = requireSlug(ctx)
|
|
212
|
-
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
212
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
213
213
|
|
|
214
214
|
// Which version is LIVE is on the automation row, not on any version, so
|
|
215
215
|
// it takes a second read. Worth it: "which one is running" is the question
|
|
@@ -268,7 +268,7 @@ const promote: Command = {
|
|
|
268
268
|
)
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
const result = await new AutomationApi(ctx.apiUrl, ctx.token).promote(slug, version)
|
|
271
|
+
const result = await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).promote(slug, version)
|
|
272
272
|
return { data: result, text: `Promoted ${slug} to v${result.version}` }
|
|
273
273
|
},
|
|
274
274
|
}
|
|
@@ -292,7 +292,7 @@ function enabledCommand(verb: 'enable' | 'disable', summary: string): Command {
|
|
|
292
292
|
|
|
293
293
|
async run(ctx) {
|
|
294
294
|
const slug = requireSlug(ctx)
|
|
295
|
-
const result = await new AutomationApi(ctx.apiUrl, ctx.token).setEnabled(slug, enabled)
|
|
295
|
+
const result = await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).setEnabled(slug, enabled)
|
|
296
296
|
return {
|
|
297
297
|
data: result,
|
|
298
298
|
text: `${enabled ? 'Enabled' : 'Disabled'} ${slug}`,
|
|
@@ -85,7 +85,7 @@ export const automationPull: Command = {
|
|
|
85
85
|
if (!raw) throw new UsageError('missing <slug>', 'frontera automation pull <slug>[@version]')
|
|
86
86
|
const { slug, version } = parseTarget(raw)
|
|
87
87
|
|
|
88
|
-
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
88
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
89
89
|
|
|
90
90
|
// "Latest" is resolved against the version list rather than by inventing a
|
|
91
91
|
// server endpoint for it — `versions` already exists and is authoritative.
|
|
@@ -218,7 +218,7 @@ export const automationRun: Command = {
|
|
|
218
218
|
|
|
219
219
|
async run(ctx) {
|
|
220
220
|
const slug = requireSlug(ctx)
|
|
221
|
-
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
221
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
222
222
|
// Parsed here rather than passed through as a string: a typo should fail
|
|
223
223
|
// before the registry wait, not after it, and `--version abc` reaching the
|
|
224
224
|
// service as `NaN` would come back as a schema error naming a field the
|
|
@@ -364,7 +364,7 @@ export const automationRuns: Command = {
|
|
|
364
364
|
// where it is explained, so the two belong under one name.
|
|
365
365
|
const runId = ctx.positional[1]
|
|
366
366
|
if (runId) {
|
|
367
|
-
const api = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
367
|
+
const api = new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
368
368
|
const [detail, steps] = await Promise.all([
|
|
369
369
|
api.runById(slug, runId),
|
|
370
370
|
// A run that failed before its first step has none, and that is a fact
|
|
@@ -393,7 +393,7 @@ export const automationRuns: Command = {
|
|
|
393
393
|
// without this a dev run is invisible from the CLI — the same blind spot
|
|
394
394
|
// that made `run --dev` wait out its timeout.
|
|
395
395
|
const dev = flagBool(ctx, 'dev')
|
|
396
|
-
const rows = await new AutomationApi(ctx.apiUrl, ctx.token).runs(
|
|
396
|
+
const rows = await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).runs(
|
|
397
397
|
slug,
|
|
398
398
|
undefined,
|
|
399
399
|
dev ? 'dev' : 'live',
|
|
@@ -79,12 +79,21 @@ function readDocument(ctx: CommandContext, positionalIndex = 0): Record<string,
|
|
|
79
79
|
const KINDS = ['object-type', 'link-type', 'metric', 'action', 'object-set'] as const
|
|
80
80
|
type Kind = (typeof KINDS)[number]
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* `create`, `update` and `delete` all start with a kind, so they all land here
|
|
84
|
+
* — and the hint named `create` for all three. Someone who mistyped
|
|
85
|
+
* `blueprint update` was sent to a different verb's help, whose shape is not
|
|
86
|
+
* the one they need: update and delete take an apiName that create does not.
|
|
87
|
+
*
|
|
88
|
+
* The caller's own verb is passed in, and the hint is that verb's first
|
|
89
|
+
* example, which shows every argument rather than only the one that failed.
|
|
90
|
+
*/
|
|
91
|
+
function readKind(ctx: CommandContext, verb: string, example: string): Kind {
|
|
83
92
|
const kind = ctx.positional[0] as Kind
|
|
84
93
|
if (!KINDS.includes(kind)) {
|
|
85
94
|
throw new CliError(
|
|
86
95
|
`Unknown kind "${ctx.positional[0] ?? ''}". Expected one of: ${KINDS.join(', ')}.`,
|
|
87
|
-
{ code: 'USAGE', hint:
|
|
96
|
+
{ code: 'USAGE', hint: `${example} — see \`frontera blueprint ${verb} --help\`` },
|
|
88
97
|
)
|
|
89
98
|
}
|
|
90
99
|
return kind
|
|
@@ -121,7 +130,7 @@ export const blueprintCreate: Command = {
|
|
|
121
130
|
],
|
|
122
131
|
},
|
|
123
132
|
async run(ctx) {
|
|
124
|
-
const kind = readKind(ctx)
|
|
133
|
+
const kind = readKind(ctx, 'create', 'frontera blueprint create object-type --file customer.json')
|
|
125
134
|
const client = api(ctx)
|
|
126
135
|
const document = readDocument(ctx, 1)
|
|
127
136
|
const revision = await client.revision()
|
|
@@ -152,7 +161,7 @@ export const blueprintUpdate: Command = {
|
|
|
152
161
|
examples: ['frontera blueprint update object-type Customer --file customer.json'],
|
|
153
162
|
},
|
|
154
163
|
async run(ctx) {
|
|
155
|
-
const kind = readKind(ctx)
|
|
164
|
+
const kind = readKind(ctx, 'update', 'frontera blueprint update object-type Customer --file customer.json')
|
|
156
165
|
const apiName = ctx.positional[1]
|
|
157
166
|
if (!apiName) {
|
|
158
167
|
throw new CliError('An apiName is required.', {
|
|
@@ -188,7 +197,7 @@ export const blueprintDelete: Command = {
|
|
|
188
197
|
examples: ['frontera blueprint delete metric revenuePerCustomer'],
|
|
189
198
|
},
|
|
190
199
|
async run(ctx) {
|
|
191
|
-
const kind = readKind(ctx)
|
|
200
|
+
const kind = readKind(ctx, 'delete', 'frontera blueprint delete metric revenuePerCustomer')
|
|
192
201
|
const apiName = ctx.positional[1]
|
|
193
202
|
if (!apiName) {
|
|
194
203
|
throw new CliError('An apiName is required.', {
|
|
@@ -238,16 +247,46 @@ export const blueprintValidate: Command = {
|
|
|
238
247
|
},
|
|
239
248
|
async run(ctx) {
|
|
240
249
|
const report = await api(ctx).validate()
|
|
241
|
-
|
|
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
|
-
}
|
|
250
|
+
return { data: report, text: renderValidationReport(report) }
|
|
248
251
|
},
|
|
249
252
|
}
|
|
250
253
|
|
|
254
|
+
type ValidationReport = Awaited<ReturnType<BlueprintAuthoringApi['validate']>>
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Say WHAT passed, not only that something did.
|
|
258
|
+
*
|
|
259
|
+
* This validates the SERVER draft. `plan` and `apply` read the file tree, and
|
|
260
|
+
* an audit ran the three as one pipeline: plan said "+1 object type", validate
|
|
261
|
+
* said "Draft validated", apply refused — validate had passed the still-empty
|
|
262
|
+
* server draft, and its bare success line was the false confidence in the
|
|
263
|
+
* middle. The counts make emptiness visible, and the unchanged-draft case
|
|
264
|
+
* names the verb that moves files into it.
|
|
265
|
+
*
|
|
266
|
+
* The report id stays on the page: `publish` takes it, which is what ties a
|
|
267
|
+
* release to the validation that cleared it rather than to whatever the draft
|
|
268
|
+
* holds later.
|
|
269
|
+
*/
|
|
270
|
+
export function renderValidationReport(report: ValidationReport): string {
|
|
271
|
+
const id = report?.id ?? report?.reportId
|
|
272
|
+
const objectTypes = report?.catalogProjection?.objectTypes?.length ?? 0
|
|
273
|
+
const linkTypes = report?.catalogProjection?.linkTypes?.length ?? 0
|
|
274
|
+
const unchanged = Boolean(
|
|
275
|
+
report?.draftDigest && report.draftDigest === report.baseReleaseDigest,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const what = `Draft revision ${report?.draftRevision ?? '?'} validated — `
|
|
279
|
+
+ `${objectTypes} object type${objectTypes === 1 ? '' : 's'}, `
|
|
280
|
+
+ `${linkTypes} link type${linkTypes === 1 ? '' : 's'}`
|
|
281
|
+
+ (unchanged ? ', unchanged since the base release' : '')
|
|
282
|
+
const note = unchanged
|
|
283
|
+
? '\n This checks the draft on the SERVER. Local files are not part of it until '
|
|
284
|
+
+ '`frontera blueprint apply` moves them there.'
|
|
285
|
+
: ''
|
|
286
|
+
|
|
287
|
+
return `${what}.${note}\n Report ${id} — pass it to \`frontera blueprint publish\`.`
|
|
288
|
+
}
|
|
289
|
+
|
|
251
290
|
export const blueprintPublish: Command = {
|
|
252
291
|
meta: {
|
|
253
292
|
noun: 'blueprint',
|
|
@@ -1108,9 +1108,15 @@ export const blueprintApply: Command = {
|
|
|
1108
1108
|
const done = applied.length
|
|
1109
1109
|
? `Applied before failing:\n${applied.map((line) => ` ${line}`).join('\n')}\n`
|
|
1110
1110
|
: 'Nothing was applied.\n'
|
|
1111
|
+
// The inner error's hint survives: it names the fields the service
|
|
1112
|
+
// refused. Replacing it with "re-plan" was advice for a DIFFERENT
|
|
1113
|
+
// failure — a draft that moved — handed out for every failure, so a
|
|
1114
|
+
// validation refusal read as "Validation failed / re-plan", which no
|
|
1115
|
+
// amount of re-planning fixes.
|
|
1116
|
+
const inner = err instanceof CliError ? err.hint : undefined
|
|
1111
1117
|
throw new CliError(`${done}${(err as Error).message}`, {
|
|
1112
1118
|
code: err instanceof CliError ? err.code : 'FAILURE',
|
|
1113
|
-
hint: 'frontera blueprint plan — re-plan against the current draft, then re-apply.',
|
|
1119
|
+
hint: inner ?? 'frontera blueprint plan — re-plan against the current draft, then re-apply.',
|
|
1114
1120
|
})
|
|
1115
1121
|
}
|
|
1116
1122
|
|
|
@@ -13,6 +13,9 @@ export const blueprintGenerateTypes: Command = {
|
|
|
13
13
|
meta: {
|
|
14
14
|
noun: 'blueprint',
|
|
15
15
|
verb: 'generate-types',
|
|
16
|
+
// Reads the slice granted to ONE workspace, unlike the rest of this
|
|
17
|
+
// noun — see `scope` on CommandMeta.
|
|
18
|
+
scope: 'workspace' as const,
|
|
16
19
|
args: [],
|
|
17
20
|
flags: { output: 'string', check: 'boolean' },
|
|
18
21
|
summary: 'Generate App-local TypeScript types from this workspace Blueprint',
|
|
@@ -62,6 +62,9 @@ export const blueprintGet: Command = {
|
|
|
62
62
|
meta: {
|
|
63
63
|
noun: 'blueprint',
|
|
64
64
|
verb: 'get',
|
|
65
|
+
// Reads the slice granted to ONE workspace, unlike the rest of this
|
|
66
|
+
// noun — see `scope` on CommandMeta.
|
|
67
|
+
scope: 'workspace' as const,
|
|
65
68
|
args: [
|
|
66
69
|
{
|
|
67
70
|
name: 'apiName',
|
|
@@ -148,11 +148,38 @@ export const blueprintGrant: Command = {
|
|
|
148
148
|
const resolved = await resolveSubject(client, subject, name)
|
|
149
149
|
const revoke = ctx.flags.revoke === true
|
|
150
150
|
for (const apiName of apiNames) {
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
try {
|
|
152
|
+
if (subject === 'workspace') {
|
|
153
|
+
if (revoke) await client.revokeWorkspace(resolved.id, apiName)
|
|
154
|
+
else await client.grantWorkspace(resolved.id, apiName)
|
|
155
|
+
} else if (revoke) await client.revokeAgent(resolved.id, apiName)
|
|
156
|
+
else await client.grantAgent(resolved.id, apiName)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
/**
|
|
159
|
+
* Grants address the PUBLISHED catalog, and the draft is a different
|
|
160
|
+
* store — the runtime catalog materializes at release. So an object
|
|
161
|
+
* type that was just applied answers "not found" here, from the verb a
|
|
162
|
+
* caller reaches for immediately after `apply` succeeds.
|
|
163
|
+
*
|
|
164
|
+
* "Not found" is true of the catalog and a lie about the work: the
|
|
165
|
+
* type exists, one step from here. When the draft holds the name, say
|
|
166
|
+
* which step — a comprehension audit read the bare 404 as the whole
|
|
167
|
+
* pipeline being broken.
|
|
168
|
+
*/
|
|
169
|
+
const notFound = err instanceof CliError && err.code === 'NOT_FOUND'
|
|
170
|
+
if (!notFound) throw err
|
|
171
|
+
const onDraft = await client.getObjectType(apiName).then((t) => Boolean(t?.id)).catch(() => false)
|
|
172
|
+
if (!onDraft) throw err
|
|
173
|
+
throw new CliError(
|
|
174
|
+
`"${apiName}" is on the draft but not in any release yet.`,
|
|
175
|
+
{
|
|
176
|
+
code: 'NOT_FOUND',
|
|
177
|
+
hint: 'grants and queries read the published catalog — publishing is what '
|
|
178
|
+
+ 'materializes the draft into it. Ask for a release, then re-run: '
|
|
179
|
+
+ '`frontera blueprint publish` needs an explicit go-ahead.',
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
}
|
|
156
183
|
}
|
|
157
184
|
|
|
158
185
|
return {
|
|
@@ -21,6 +21,9 @@ export const blueprintList: Command = {
|
|
|
21
21
|
meta: {
|
|
22
22
|
noun: 'blueprint',
|
|
23
23
|
verb: 'list',
|
|
24
|
+
// Reads the slice granted to ONE workspace, unlike the rest of this
|
|
25
|
+
// noun — see `scope` on CommandMeta.
|
|
26
|
+
scope: 'workspace' as const,
|
|
24
27
|
args: [],
|
|
25
28
|
flags: {},
|
|
26
29
|
summary: 'List the object types this workspace can read',
|
|
@@ -123,6 +123,9 @@ export const blueprintQuery: Command = {
|
|
|
123
123
|
meta: {
|
|
124
124
|
noun: 'blueprint',
|
|
125
125
|
verb: 'query',
|
|
126
|
+
// Reads the slice granted to ONE workspace, unlike the rest of this
|
|
127
|
+
// noun — see `scope` on CommandMeta.
|
|
128
|
+
scope: 'workspace' as const,
|
|
126
129
|
args: [
|
|
127
130
|
{
|
|
128
131
|
name: 'apiName',
|
|
@@ -254,6 +257,9 @@ export const blueprintInstance: Command = {
|
|
|
254
257
|
meta: {
|
|
255
258
|
noun: 'blueprint',
|
|
256
259
|
verb: 'instance',
|
|
260
|
+
// Reads the slice granted to ONE workspace, unlike the rest of this
|
|
261
|
+
// noun — see `scope` on CommandMeta.
|
|
262
|
+
scope: 'workspace' as const,
|
|
257
263
|
args: [
|
|
258
264
|
{ name: 'apiName', required: true, description: 'object type, from `frontera blueprint list`' },
|
|
259
265
|
{ name: 'pk', required: true, description: 'primary key value of the record to read' },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { dirname, resolve } from 'node:path'
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { DatasetApi, type DatasetColumn } from '../../api/dataset-api'
|
|
5
5
|
import { CliError } from '../../errors'
|
|
@@ -204,7 +204,8 @@ const rows: Command = {
|
|
|
204
204
|
text:
|
|
205
205
|
`No rows in the current revision of "${apiName}".\n`
|
|
206
206
|
+ ' The column contract is published, so anything bound to it will bind and read nothing.\n'
|
|
207
|
-
+ ` \`frontera dataset
|
|
207
|
+
+ ` \`frontera dataset upload ${apiName} ./rows.csv\` loads rows that match the contract; `
|
|
208
|
+
+ `\`frontera dataset get ${apiName}\` shows it.`,
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
211
|
|
|
@@ -386,8 +387,14 @@ const create: Command = {
|
|
|
386
387
|
flags: { file: 'string' },
|
|
387
388
|
aliases: { f: 'file' },
|
|
388
389
|
summary: 'Create a dataset — from declared columns, a Source relation, or a CSV',
|
|
390
|
+
// One example per kind, because the summary promises three forms and a
|
|
391
|
+
// single JSON example taught callers that the third did not exist: an
|
|
392
|
+
// agent given only this surface concluded "rows can never be loaded from
|
|
393
|
+
// a CSV through the CLI" while `kind: "file"` sat unadvertised.
|
|
389
394
|
examples: [
|
|
390
395
|
'frontera dataset create --file ./customers.json',
|
|
396
|
+
'frontera dataset create --file ./orders.json # kind:"file" ingests a CSV',
|
|
397
|
+
'frontera dataset create --file ./stock.json # kind:"source" pulls a relation',
|
|
391
398
|
],
|
|
392
399
|
},
|
|
393
400
|
async run(ctx) {
|
|
@@ -399,7 +406,22 @@ const create: Command = {
|
|
|
399
406
|
})
|
|
400
407
|
}
|
|
401
408
|
const path = resolve(ctx.cwd, filePath)
|
|
402
|
-
|
|
409
|
+
let document: DatasetFile
|
|
410
|
+
try {
|
|
411
|
+
document = JSON.parse(readFileSync(path, 'utf8')) as DatasetFile
|
|
412
|
+
} catch (e) {
|
|
413
|
+
// The likeliest way to get here is handing over the CSV itself — the
|
|
414
|
+
// summary says "from … a CSV" and this is the only flag. That used to be
|
|
415
|
+
// a raw parser crash: `INTERNAL_ERROR: JSON Parse error: Unexpected
|
|
416
|
+
// identifier "id"`, exit 1, naming the first CSV header as the fault.
|
|
417
|
+
throw new CliError(`${filePath} is not a JSON definition: ${(e as Error).message}`, {
|
|
418
|
+
code: 'USAGE',
|
|
419
|
+
hint: filePath.toLowerCase().endsWith('.csv')
|
|
420
|
+
? '--file takes the definition document, not the CSV. To ingest a CSV, the document '
|
|
421
|
+
+ 'names it: { "apiName": "…", "kind": "file", "path": "./' + basename(filePath) + '" }'
|
|
422
|
+
: 'the document is JSON: { apiName, kind: blank | source | file, … } — see `frontera dataset create --help`',
|
|
423
|
+
})
|
|
424
|
+
}
|
|
403
425
|
|
|
404
426
|
const apiName = document.apiName
|
|
405
427
|
if (typeof apiName !== 'string' || !apiName) {
|
|
@@ -437,7 +459,56 @@ const create: Command = {
|
|
|
437
459
|
}
|
|
438
460
|
|
|
439
461
|
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* `kind` defaults to blank, and blank reads NOTHING — so a document that
|
|
465
|
+
* carried `source` or `path` without saying `kind` was created as an empty
|
|
466
|
+
* schema, its row-bearing keys silently dropped. "Created" was the whole
|
|
467
|
+
* answer, and the caller found the zero rows later, somewhere else.
|
|
468
|
+
*
|
|
469
|
+
* The default stays, because a plain columns document is the common case
|
|
470
|
+
* and has always worked. What goes is the silence: keys that only mean
|
|
471
|
+
* something under another kind refuse rather than vanish.
|
|
472
|
+
*/
|
|
473
|
+
const doc = document as Record<string, unknown>
|
|
440
474
|
const kind = document.kind ?? 'blank'
|
|
475
|
+
|
|
476
|
+
const KIND_KEYS: Record<string, Set<string>> = {
|
|
477
|
+
blank: new Set(['columns']),
|
|
478
|
+
source: new Set(['source', 'schema', 'relation', 'includedColumns']),
|
|
479
|
+
file: new Set(['path', 'includedColumns']),
|
|
480
|
+
}
|
|
481
|
+
const COMMON = new Set([
|
|
482
|
+
'apiName', 'displayName', 'description', 'kind', 'keyColumns', 'deterministicKeyConfirmed',
|
|
483
|
+
])
|
|
484
|
+
// Where a stray key probably meant to point. `csv`, `rows` and `data` are
|
|
485
|
+
// what people actually reached for when probing how rows get in.
|
|
486
|
+
const MEANT: Record<string, string> = {
|
|
487
|
+
csv: 'kind: "file" with `path`',
|
|
488
|
+
rows: 'kind: "file" with `path` (rows ride in the CSV)',
|
|
489
|
+
data: 'kind: "file" with `path`',
|
|
490
|
+
source: 'kind: "source"',
|
|
491
|
+
path: 'kind: "file"',
|
|
492
|
+
schema: 'kind: "source"',
|
|
493
|
+
relation: 'kind: "source"',
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const allowed = KIND_KEYS[kind as string] ?? new Set<string>()
|
|
497
|
+
const strays = Object.keys(doc).filter((k) => !COMMON.has(k) && !allowed.has(k))
|
|
498
|
+
if (strays.length > 0) {
|
|
499
|
+
const guesses = strays
|
|
500
|
+
.map((k) => (MEANT[k] && !allowed.has(k) ? `\`${k}\` belongs to ${MEANT[k]}` : null))
|
|
501
|
+
.filter(Boolean)
|
|
502
|
+
const named = strays.map((k) => '`' + k + '`').join(', ')
|
|
503
|
+
const strayHint = guesses.length > 0
|
|
504
|
+
? `${guesses.join('; ')} — say the kind explicitly, it does not default from the keys`
|
|
505
|
+
: `kind "${kind}" reads: ${[...COMMON, ...allowed].join(', ')}`
|
|
506
|
+
throw new CliError(`${filePath} carries ${named}, which kind "${kind}" does not read.`, {
|
|
507
|
+
code: 'USAGE',
|
|
508
|
+
hint: strayHint,
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
441
512
|
let created
|
|
442
513
|
|
|
443
514
|
if (kind === 'blank') {
|
|
@@ -511,10 +582,86 @@ const create: Command = {
|
|
|
511
582
|
|
|
512
583
|
return {
|
|
513
584
|
data: created,
|
|
514
|
-
|
|
585
|
+
// Blank is a CONTRACT with no rows, and that surprised everyone who
|
|
586
|
+
// reached it by default. Said here, at creation, rather than left for
|
|
587
|
+
// `dataset rows` to reveal after the binding is already built.
|
|
588
|
+
text: `Created dataset "${apiName}"${kind === 'blank'
|
|
589
|
+
? ` — declared columns only, no rows. Load them with \`frontera dataset upload ${apiName} ./rows.csv\`,`
|
|
590
|
+
+ ' or create with kind "file" (a CSV) or kind "source" instead.'
|
|
591
|
+
: '.'} `
|
|
515
592
|
+ `Bind an object type to it: frontera blueprint bind <ObjectType> --dataset ${apiName} --plan ./map.json`,
|
|
516
593
|
}
|
|
517
594
|
},
|
|
518
595
|
}
|
|
519
596
|
|
|
520
|
-
|
|
597
|
+
const upload: Command = {
|
|
598
|
+
meta: {
|
|
599
|
+
noun: 'dataset',
|
|
600
|
+
verb: 'upload',
|
|
601
|
+
args: [
|
|
602
|
+
{ name: 'apiName', required: true, description: 'The dataset’s API name, from `frontera dataset list`' },
|
|
603
|
+
{ name: 'path', required: true, description: 'Path to a CSV matching the dataset’s column contract' },
|
|
604
|
+
],
|
|
605
|
+
flags: {},
|
|
606
|
+
summary: 'Load rows from a CSV into an existing dataset, as a new snapshot',
|
|
607
|
+
examples: [
|
|
608
|
+
'frontera dataset upload customers ./customers.csv',
|
|
609
|
+
'frontera dataset preview ./customers.csv # check the columns first',
|
|
610
|
+
],
|
|
611
|
+
},
|
|
612
|
+
/**
|
|
613
|
+
* Rows into a dataset that already exists.
|
|
614
|
+
*
|
|
615
|
+
* `create` with kind "file" loads a CSV at creation, and nothing loaded one
|
|
616
|
+
* afterwards. A blank dataset — declared columns, no rows — is the common
|
|
617
|
+
* first step, and the moment someone had one and a CSV to put in it, the
|
|
618
|
+
* CLI had no verb. An agent told its user the capability did not exist;
|
|
619
|
+
* the service had it all along.
|
|
620
|
+
*
|
|
621
|
+
* The file must match the CURRENT contract exactly: names, types,
|
|
622
|
+
* nullability. That is the service's rule, and a sound one — a re-upload
|
|
623
|
+
* that changed the schema would silently alter what every binding reads.
|
|
624
|
+
* A schema change is a new revision, not this verb.
|
|
625
|
+
*/
|
|
626
|
+
async run(ctx) {
|
|
627
|
+
const [apiName, path] = ctx.positional
|
|
628
|
+
if (!apiName) {
|
|
629
|
+
throw new CliError('A dataset apiName is required.', { code: 'USAGE', hint: 'frontera dataset list' })
|
|
630
|
+
}
|
|
631
|
+
if (!path) {
|
|
632
|
+
throw new CliError('A path to a CSV is required.', {
|
|
633
|
+
code: 'USAGE',
|
|
634
|
+
hint: `frontera dataset upload ${apiName} ./rows.csv`,
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
639
|
+
const dataset = await resolveDataset(api, apiName)
|
|
640
|
+
|
|
641
|
+
let result
|
|
642
|
+
try {
|
|
643
|
+
result = await api.uploadSnapshot(dataset.id!, resolve(ctx.cwd, path))
|
|
644
|
+
} catch (err) {
|
|
645
|
+
// The service's refusal says WHAT is wrong (contract mismatch) and the
|
|
646
|
+
// caller needs to see WHERE: the dataset's contract on one side and the
|
|
647
|
+
// file's columns on the other. Both are one command away.
|
|
648
|
+
const mismatch = err instanceof CliError && err.code === 'CONFLICT'
|
|
649
|
+
if (!mismatch) throw err
|
|
650
|
+
throw new CliError((err as Error).message, {
|
|
651
|
+
code: 'CONFLICT',
|
|
652
|
+
hint: `compare \`frontera dataset get ${apiName}\` (the contract) with `
|
|
653
|
+
+ `\`frontera dataset preview ${path}\` (the file) — names, types and nullability must all match. `
|
|
654
|
+
+ 'A schema change is a new revision, not an upload.',
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const revision = (result as { currentRevision?: { revision?: number } }).currentRevision?.revision
|
|
659
|
+
return {
|
|
660
|
+
data: result,
|
|
661
|
+
text: `Loaded ${basename(path)} into "${apiName}"${revision !== undefined ? ` — now at revision ${revision}` : ''}.\n`
|
|
662
|
+
+ ` \`frontera dataset rows ${apiName}\` reads them back.`,
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export const datasetCommands: Command[] = [list, get, rows, sources, preview, testSource, create, upload]
|
|
@@ -5,6 +5,28 @@ import { checkCompatibility, digest, findDuplicateSources, KIT, LOCK_FILE, readL
|
|
|
5
5
|
import type { Command } from '../types'
|
|
6
6
|
import { cliVersion, kitRoot } from './shared'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* A drift list, counted and capped.
|
|
10
|
+
*
|
|
11
|
+
* `modified` was summarised with a count and `missing` was not, so a tree short
|
|
12
|
+
* of a whole skill printed fifty bare paths under a headline reading
|
|
13
|
+
* "(current)" — and the two lines that actually needed acting on, the
|
|
14
|
+
* duplicate-source and compatibility warnings, scrolled off the top.
|
|
15
|
+
*
|
|
16
|
+
* Truncation is stated rather than silent, and `--json` still carries every
|
|
17
|
+
* path, because the full list is what `sync` is judged against.
|
|
18
|
+
*/
|
|
19
|
+
export function driftLines(files: string[], what: string, bullet: string, shown = 8): string[] {
|
|
20
|
+
if (files.length === 0) return []
|
|
21
|
+
return [
|
|
22
|
+
` ${files.length} generated file(s) ${what}:`,
|
|
23
|
+
...files.slice(0, shown).map((f) => ` ${bullet} ${f}`),
|
|
24
|
+
...(files.length > shown
|
|
25
|
+
? [` … and ${files.length - shown} more — \`frontera kit status --json\` lists them all`]
|
|
26
|
+
: []),
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
/**
|
|
9
31
|
* What this repository has, what this CLI carries, and whether they agree.
|
|
10
32
|
*
|
|
@@ -73,9 +95,8 @@ export const kitStatus: Command = {
|
|
|
73
95
|
lines.push(` bundled kit ${KIT.kitVersion}; run \`frontera kit vendor\` to commit it to this repository`)
|
|
74
96
|
} else {
|
|
75
97
|
lines.push(`kit ${lock.kitVersion} vendored${outdated ? ` — this CLI carries ${KIT.kitVersion}` : ' (current)'}`)
|
|
76
|
-
|
|
77
|
-
lines.push(...
|
|
78
|
-
if (missing.length > 0) lines.push(...missing.map((f) => ` - ${f} (missing)`))
|
|
98
|
+
lines.push(...driftLines(modified, 'edited locally', '·'))
|
|
99
|
+
lines.push(...driftLines(missing, 'missing — run `frontera kit sync`', '-'))
|
|
79
100
|
}
|
|
80
101
|
if (claudeImportsAgents === false) {
|
|
81
102
|
lines.push(' ! CLAUDE.md does not import @AGENTS.md — run `frontera kit vendor` to repair it')
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
1
3
|
import { PlatformApi } from '../../api/platform-api'
|
|
2
4
|
import { CliError, UsageError } from '../../errors'
|
|
3
5
|
import { table } from '../../table'
|
|
4
6
|
import { resolveAgentRef } from '../agent/resolve'
|
|
5
7
|
import { flagString, type Command } from '../types'
|
|
6
|
-
import {
|
|
8
|
+
import { resolveWorkspaceId } from '../workspace-id'
|
|
9
|
+
import {
|
|
10
|
+
countExistingSources,
|
|
11
|
+
describeExistingSources,
|
|
12
|
+
renderUploadSummary,
|
|
13
|
+
summarizeUploads,
|
|
14
|
+
uploadAll,
|
|
15
|
+
} from './upload-batch'
|
|
7
16
|
import { planUploads } from './upload-plan'
|
|
8
17
|
|
|
9
18
|
interface KnowledgeRow {
|
|
@@ -13,24 +22,13 @@ interface KnowledgeRow {
|
|
|
13
22
|
description?: string
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
/** The workspace a `sk-ws-` key is scoped to. Not visible from anywhere else. */
|
|
17
|
-
async function requireWorkspaceId(api: PlatformApi): Promise<string> {
|
|
18
|
-
const me = await api.whoami()
|
|
19
|
-
if (!me.workspaceId) {
|
|
20
|
-
throw new CliError('this credential is not scoped to a workspace', {
|
|
21
|
-
code: 'FORBIDDEN',
|
|
22
|
-
hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
return me.workspaceId
|
|
26
|
-
}
|
|
27
25
|
|
|
28
26
|
/** Accept a base by name or id — `knowledge list` shows both. */
|
|
29
27
|
async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
|
|
30
28
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
31
29
|
if (UUID.test(ref)) return ref
|
|
32
30
|
|
|
33
|
-
const workspaceId = await
|
|
31
|
+
const workspaceId = await resolveWorkspaceId(client)
|
|
34
32
|
const rows = (await client.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
35
33
|
const match = rows.find((k) => (k.name ?? '').toLowerCase() === ref.toLowerCase())
|
|
36
34
|
if (match?.id) return match.id
|
|
@@ -54,7 +52,7 @@ const list: Command = {
|
|
|
54
52
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
55
53
|
// The route takes the workspace explicitly, and a key holder cannot see
|
|
56
54
|
// its own workspace id from anywhere else — hence /v1/whoami.
|
|
57
|
-
const workspaceId = await
|
|
55
|
+
const workspaceId = await resolveWorkspaceId(api)
|
|
58
56
|
const rows = (await api.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
59
57
|
return {
|
|
60
58
|
data: rows,
|
|
@@ -292,7 +290,7 @@ const create: Command = {
|
|
|
292
290
|
}
|
|
293
291
|
|
|
294
292
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
295
|
-
const workspaceId = await
|
|
293
|
+
const workspaceId = await resolveWorkspaceId(api)
|
|
296
294
|
|
|
297
295
|
// Checked here rather than left to the unique constraint: the service
|
|
298
296
|
// answers a duplicate with a bare CONFLICT, whose generic hint ("resolve
|
|
@@ -388,9 +386,20 @@ const upload: Command = {
|
|
|
388
386
|
// code says so, and name the first cause rather than a count.
|
|
389
387
|
if (summary.uploaded === 0) {
|
|
390
388
|
const first = outcomes.find((o) => o.status === 'failed')
|
|
389
|
+
|
|
390
|
+
// The hint used to promise that "a retry duplicates nothing already
|
|
391
|
+
// ingested". It is not true: the service registers the source and then
|
|
392
|
+
// fails downstream, so the row exists and re-running adds another. Rather
|
|
393
|
+
// than soften the claim, look.
|
|
394
|
+
const existing = describeExistingSources(
|
|
395
|
+
await countExistingSources(api, baseId, plan.files.map((f) => basename(f.path))),
|
|
396
|
+
)
|
|
397
|
+
|
|
391
398
|
throw new CliError(`all ${summary.failed} upload(s) failed: ${first?.error ?? 'unknown error'}`, {
|
|
392
399
|
code: 'FAILURE',
|
|
393
|
-
hint:
|
|
400
|
+
hint: existing
|
|
401
|
+
? `${existing}. Check \`frontera knowledge sources ${ref}\` before re-running`
|
|
402
|
+
: `fix the cause above, then re-run — nothing was registered, so \`frontera knowledge sources ${ref}\` should still be empty for this file`,
|
|
394
403
|
})
|
|
395
404
|
}
|
|
396
405
|
|