@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.45.
|
|
3
|
+
"version": "1.45.8",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
"build:release": "bun run scripts/build-release.ts"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@frontera-sdk/automation": "1.45.
|
|
42
|
-
"@frontera-sdk/core": "1.45.
|
|
41
|
+
"@frontera-sdk/automation": "1.45.7",
|
|
42
|
+
"@frontera-sdk/core": "1.45.7",
|
|
43
43
|
"gray-matter": "^4.0.3",
|
|
44
44
|
"yaml": "^2.9.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@types/bun": "
|
|
47
|
+
"@types/bun": "^1.3.14",
|
|
48
48
|
"typescript": "^5.9.3"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -101,10 +101,20 @@ export interface RegistryStatus {
|
|
|
101
101
|
export class AutomationApi {
|
|
102
102
|
private readonly client: FronteraClient
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* `workspaceId` because every automation belongs to one — the rows carry a
|
|
106
|
+
* `workspace_id` and the router refuses a request without the header.
|
|
107
|
+
*
|
|
108
|
+
* It was omitted, which left `automation` unreachable by an organization key
|
|
109
|
+
* in both directions: without the flag the service answered "Workspace
|
|
110
|
+
* context required", and with it the CLI answered "--workspace does nothing
|
|
111
|
+
* on automation". Two refusals and no way through.
|
|
112
|
+
*/
|
|
113
|
+
constructor(apiBaseUrl: string, token: string, workspaceId?: string) {
|
|
105
114
|
this.client = new FronteraClient({
|
|
106
115
|
apiBaseUrl,
|
|
107
116
|
credential: { kind: 'apiKey', key: token },
|
|
117
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
108
118
|
})
|
|
109
119
|
}
|
|
110
120
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { CliError } from '../errors'
|
|
2
|
+
import { credentialFailure } from './credential-failure'
|
|
3
|
+
import { formatValidationDetails } from './validation-detail'
|
|
2
4
|
import type { DefinitionBundle } from '../blueprint/model'
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -40,30 +42,12 @@ export class BlueprintAuthoringApi {
|
|
|
40
42
|
if (!res.ok) {
|
|
41
43
|
// The credential failures worth telling apart, because each has a different
|
|
42
44
|
// fix and a generic "request failed" sends the reader to the wrong one.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
if (res.status === 403) {
|
|
51
|
-
throw new CliError(
|
|
52
|
-
payload?.message
|
|
53
|
-
? `${payload.message} — the key may not carry the capability this route needs, its `
|
|
54
|
-
+ `creator's role may have changed, or the row is in a workspace the key does not list`
|
|
55
|
-
: "The key is not permitted to author this organization's Blueprint.",
|
|
56
|
-
{
|
|
57
|
-
code: 'FORBIDDEN',
|
|
58
|
-
// Three distinct causes with three distinct fixes, and the service cannot
|
|
59
|
-
// tell them apart for us. Naming all three beats naming the wrong one:
|
|
60
|
-
// a workspace-addressed route refuses a key whose workspace list omits
|
|
61
|
-
// that workspace, which has nothing to do with organization:update.
|
|
62
|
-
hint: 'Check the key carries the capability, and — for a workspace- or '
|
|
63
|
-
+ 'agent-addressed route — that its workspace list includes that workspace.',
|
|
64
|
-
},
|
|
65
|
-
)
|
|
66
|
-
}
|
|
45
|
+
// Shared with the other clients so the three of them cannot drift again.
|
|
46
|
+
const credential = credentialFailure(res.status, payload?.message, {
|
|
47
|
+
token: this.token,
|
|
48
|
+
whenSilent: "The key is not permitted to author this organization's Blueprint.",
|
|
49
|
+
})
|
|
50
|
+
if (credential) throw credential
|
|
67
51
|
if (res.status === 409) {
|
|
68
52
|
throw new CliError(
|
|
69
53
|
payload?.message
|
|
@@ -77,7 +61,16 @@ export class BlueprintAuthoringApi {
|
|
|
77
61
|
hint: 'frontera blueprint status',
|
|
78
62
|
})
|
|
79
63
|
}
|
|
80
|
-
|
|
64
|
+
// The service's refusal often carries a TypeBox details array naming the
|
|
65
|
+
// exact fields — `/pluralName`, a valueType outside the enum — and this
|
|
66
|
+
// path used to flatten all of it to "Validation failed". On the verb that
|
|
67
|
+
// WRITES the draft, that reasonless refusal was the whole error a caller
|
|
68
|
+
// saw, and the audit read it as an unusable write path.
|
|
69
|
+
const detail = formatValidationDetails(payload?.details)
|
|
70
|
+
throw new CliError(payload?.message ?? `${res.status} ${text.slice(0, 200)}`, {
|
|
71
|
+
code: (payload?.code as string) ?? 'FAILURE',
|
|
72
|
+
hint: detail ?? 'frontera blueprint status',
|
|
73
|
+
})
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
return (payload?.data ?? payload) as T
|
|
@@ -330,7 +323,15 @@ export class BlueprintAuthoringApi {
|
|
|
330
323
|
|
|
331
324
|
// ── release lifecycle ─────────────────────────────────────────────────────
|
|
332
325
|
|
|
333
|
-
validate(): Promise<{
|
|
326
|
+
validate(): Promise<{
|
|
327
|
+
id?: string
|
|
328
|
+
reportId?: string
|
|
329
|
+
draftRevision?: number
|
|
330
|
+
draftDigest?: string
|
|
331
|
+
baseReleaseDigest?: string
|
|
332
|
+
catalogProjection?: { objectTypes?: unknown[]; linkTypes?: unknown[] }
|
|
333
|
+
evolution?: { changes?: unknown[] }
|
|
334
|
+
}> {
|
|
334
335
|
return this.call('/v1/blueprint/lifecycle/validate', { method: 'POST', body: {} })
|
|
335
336
|
}
|
|
336
337
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import { orgScopedServiceNouns } from '../scopes'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two credential failures, worded once.
|
|
6
|
+
*
|
|
7
|
+
* Every HTTP client here mapped its own 401/403, and they drifted: an audit of
|
|
8
|
+
* the read surface found `workspace list` and `blueprint status` explaining a
|
|
9
|
+
* 403 and naming a route out, while `dataset list`, `source list` and
|
|
10
|
+
* `action list` answered the same status with a bare "Insufficient
|
|
11
|
+
* permissions". Same error class, unequal help — and the bare form lands on
|
|
12
|
+
* exactly the nouns a workspace key is most likely to be refused by.
|
|
13
|
+
*
|
|
14
|
+
* A generic "request failed" sends the reader to the wrong fix, so the causes
|
|
15
|
+
* are named. The service cannot tell them apart for us, and naming all of them
|
|
16
|
+
* beats naming the wrong one.
|
|
17
|
+
*
|
|
18
|
+
* `PlatformApi` is deliberately NOT routed through here, and adding it would be
|
|
19
|
+
* a regression rather than a completion: it reads the workspace's granted
|
|
20
|
+
* slice, where a workspace key is the correct credential. The lane note below
|
|
21
|
+
* would then tell a caller holding the right kind of key to go and find the
|
|
22
|
+
* wrong one. What a 403 there usually means is an organization key that never
|
|
23
|
+
* named a workspace — which the dispatcher already warns about before the
|
|
24
|
+
* request leaves.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The lane split, which is the first thing to check and the easiest to miss. */
|
|
28
|
+
function laneNote(token: string): string {
|
|
29
|
+
// A workspace key is refused organization scope BY DESIGN — it is not a
|
|
30
|
+
// narrower version of an organization key, it is a different lane. Reading
|
|
31
|
+
// that refusal as "my key lacks a capability" sends someone to mint a second
|
|
32
|
+
// workspace key, which fails identically.
|
|
33
|
+
return token.startsWith('sk-ws-')
|
|
34
|
+
? `This is a workspace key (sk-ws-). Organization-scoped commands (${orgScopedServiceNouns().join(', ')}) `
|
|
35
|
+
+ 'refuse one by design; they need an organization key (sk-org-). '
|
|
36
|
+
+ '`frontera auth list` shows which kind each profile holds.'
|
|
37
|
+
: 'Check the key carries the capability, and — for a workspace- or agent-addressed '
|
|
38
|
+
+ 'route — that its workspace list includes that workspace.'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A `CliError` for 401 and 403, or `null` for every other status.
|
|
43
|
+
*
|
|
44
|
+
* Returning null rather than throwing keeps each client's own mapping for the
|
|
45
|
+
* statuses it genuinely treats differently — a 409 on the shared Blueprint
|
|
46
|
+
* draft means "re-read the revision", which is nothing to do with credentials.
|
|
47
|
+
*/
|
|
48
|
+
export function credentialFailure(
|
|
49
|
+
status: number,
|
|
50
|
+
message: string | undefined,
|
|
51
|
+
opts: { token: string; capability?: string; whenSilent?: string },
|
|
52
|
+
): CliError | null {
|
|
53
|
+
if (status === 401) {
|
|
54
|
+
return new CliError(
|
|
55
|
+
'The credential was refused. An organization key that has been revoked or has '
|
|
56
|
+
+ 'expired reads exactly like this — mint or rotate one in Settings → API keys.',
|
|
57
|
+
{ code: 'UNAUTHORIZED', hint: 'frontera auth verify — then `frontera login --api-url <url>`' },
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (status === 403) {
|
|
62
|
+
return new CliError(
|
|
63
|
+
message
|
|
64
|
+
? `${message} — the key may not carry the capability this route needs, its `
|
|
65
|
+
+ `creator's role may have changed, or the row is in a workspace the key does not list`
|
|
66
|
+
: (opts.whenSilent ?? 'The key is not permitted to reach that resource.'),
|
|
67
|
+
{
|
|
68
|
+
code: 'FORBIDDEN',
|
|
69
|
+
hint: opts.capability
|
|
70
|
+
? `the credential needs ${opts.capability}. ${laneNote(opts.token)}`
|
|
71
|
+
: laneNote(opts.token),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null
|
|
77
|
+
}
|
package/src/api/dataset-api.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { CliError } from '../errors'
|
|
5
|
+
import { credentialFailure } from './credential-failure'
|
|
6
|
+
import { formatValidationDetails } from './validation-detail'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Datasets, from the CLI.
|
|
@@ -99,9 +101,24 @@ export class DatasetApi {
|
|
|
99
101
|
const text = await response.text()
|
|
100
102
|
const payload = text ? JSON.parse(text) : {}
|
|
101
103
|
if (!response.ok) {
|
|
104
|
+
// Datasets and sources are organization-scoped, so a workspace key is
|
|
105
|
+
// refused here by design — and this client used to answer that with a
|
|
106
|
+
// bare "Insufficient permissions" and no route out.
|
|
107
|
+
const credential = credentialFailure(response.status, payload?.message, { token: this.token })
|
|
108
|
+
if (credential) throw credential
|
|
109
|
+
// `details` is TypeBox's own error array. Stringified it was unreadable,
|
|
110
|
+
// and this is the last gate before a Source exists.
|
|
111
|
+
const detail = formatValidationDetails(payload?.details)
|
|
102
112
|
throw new CliError(payload?.message ?? `Request failed (${response.status}).`, {
|
|
103
113
|
code: payload?.code ?? 'FAILURE',
|
|
104
|
-
|
|
114
|
+
// A refused CONNECTION is not a malformed document, and telling someone
|
|
115
|
+
// to re-read their file sends them to the wrong place entirely: the
|
|
116
|
+
// service dials the database itself, from wherever it runs.
|
|
117
|
+
hint: detail
|
|
118
|
+
?? (response.status === 409
|
|
119
|
+
? 'the service dials the database itself — check host and port are reachable '
|
|
120
|
+
+ 'from where the service runs, and that the password on stdin is current'
|
|
121
|
+
: 'check the document against `frontera source create --help`'),
|
|
105
122
|
})
|
|
106
123
|
}
|
|
107
124
|
return (payload?.data ?? payload) as T
|
|
@@ -282,6 +299,28 @@ export class DatasetApi {
|
|
|
282
299
|
return this.call<DatasetSummary>('/v1/data-integration/datasets/from-file', { form })
|
|
283
300
|
}
|
|
284
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Rows into a dataset that already exists, as a new snapshot.
|
|
304
|
+
*
|
|
305
|
+
* `createFromFile` is CREATE-time only. A blank dataset — declared columns,
|
|
306
|
+
* no rows, and the common first step — had no way to receive data afterwards
|
|
307
|
+
* through the CLI, so an agent told its user the capability did not exist.
|
|
308
|
+
* It does: the service republishes against the CURRENT column contract and
|
|
309
|
+
* repoints the dataset atomically, keeping the prior snapshot on any failure.
|
|
310
|
+
*
|
|
311
|
+
* The file must match that contract exactly — names, types, nullability. A
|
|
312
|
+
* schema change is refused here by design; it is a new revision, not a
|
|
313
|
+
* re-upload.
|
|
314
|
+
*/
|
|
315
|
+
uploadSnapshot(datasetId: string, path: string): Promise<DatasetSummary> {
|
|
316
|
+
const form = new FormData()
|
|
317
|
+
form.append('file', new Blob([readFileSync(path)]), basename(path))
|
|
318
|
+
return this.call<DatasetSummary>(
|
|
319
|
+
`/v1/data-integration/datasets/${encodeURIComponent(datasetId)}/snapshots`,
|
|
320
|
+
{ form },
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
285
324
|
createBlank(payload: {
|
|
286
325
|
apiName: string
|
|
287
326
|
displayName: string
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../errors'
|
|
2
|
+
import { credentialFailure } from './credential-failure'
|
|
2
3
|
import type { ObjectTypeShape, PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -37,15 +38,30 @@ export interface ActionRequestSummary {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export class GovernedActionApi {
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* `workspaceId` for the one verb on this plane that needs it: the deployment
|
|
43
|
+
* routes are organization-grain, but `GET /requests` is refused without a
|
|
44
|
+
* workspace, so `action requests` could not run on an organization key.
|
|
45
|
+
*/
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly apiUrl: string,
|
|
48
|
+
private readonly token: string,
|
|
49
|
+
private readonly workspaceId?: string,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
/** Every call carries the workspace when one was named. */
|
|
53
|
+
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
54
|
+
return {
|
|
55
|
+
authorization: `Bearer ${this.token}`,
|
|
56
|
+
...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
|
|
57
|
+
...extra,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
41
60
|
|
|
42
61
|
private async call<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
43
62
|
const response = await fetch(`${this.apiUrl}${path}`, {
|
|
44
63
|
method: init.method ?? 'GET',
|
|
45
|
-
headers: {
|
|
46
|
-
authorization: `Bearer ${this.token}`,
|
|
47
|
-
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
48
|
-
},
|
|
64
|
+
headers: this.headers(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
49
65
|
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
50
66
|
})
|
|
51
67
|
const text = await response.text()
|
|
@@ -54,6 +70,8 @@ export class GovernedActionApi {
|
|
|
54
70
|
|
|
55
71
|
if (!response.ok) {
|
|
56
72
|
const body = payload as { message?: string; code?: string; details?: unknown } | null
|
|
73
|
+
const credential = credentialFailure(response.status, body?.message, { token: this.token })
|
|
74
|
+
if (credential) throw credential
|
|
57
75
|
throw new CliError(body?.message ?? `${response.status} from ${path}`, {
|
|
58
76
|
code: body?.code ?? 'FAILURE',
|
|
59
77
|
...(body?.details ? { hint: JSON.stringify(body.details) } : {}),
|
|
@@ -94,7 +112,7 @@ export class GovernedActionApi {
|
|
|
94
112
|
|
|
95
113
|
const response = await fetch(
|
|
96
114
|
`${this.apiUrl}/v1/blueprint/governed-actions/requests${suffix}`,
|
|
97
|
-
{ headers:
|
|
115
|
+
{ headers: this.headers() },
|
|
98
116
|
)
|
|
99
117
|
const text = await response.text()
|
|
100
118
|
let payload: unknown
|
|
@@ -102,11 +120,16 @@ export class GovernedActionApi {
|
|
|
102
120
|
|
|
103
121
|
if (!response.ok) {
|
|
104
122
|
const body = payload as { message?: string; code?: string } | null
|
|
123
|
+
// The capability is named here because this route knows which one it
|
|
124
|
+
// needs; the shared mapper adds the lane note the bare hint was missing.
|
|
125
|
+
const credential = credentialFailure(response.status, body?.message, {
|
|
126
|
+
token: this.token,
|
|
127
|
+
capability: 'actionRequest:read',
|
|
128
|
+
})
|
|
129
|
+
if (credential) throw credential
|
|
105
130
|
throw new CliError(body?.message ?? `${response.status} from /governed-actions/requests`, {
|
|
106
131
|
code: body?.code ?? 'FAILURE',
|
|
107
|
-
|
|
108
|
-
? { hint: 'the credential needs actionRequest:read — mint a key that carries it' }
|
|
109
|
-
: {}),
|
|
132
|
+
hint: 'frontera action list — to confirm which Actions this credential can see',
|
|
110
133
|
})
|
|
111
134
|
}
|
|
112
135
|
|
package/src/api/platform-api.ts
CHANGED
|
@@ -49,7 +49,18 @@ export class PlatformApi {
|
|
|
49
49
|
* treating it as authority would send a stale id as fact. This comes from
|
|
50
50
|
* `--workspace`, which the caller states now.
|
|
51
51
|
*/
|
|
52
|
+
/**
|
|
53
|
+
* The workspace this client addresses, when one was named.
|
|
54
|
+
*
|
|
55
|
+
* Kept as a field as well as a header because two nouns put the workspace in
|
|
56
|
+
* the URL rather than in `x-workspace-id`, and they were resolving it from
|
|
57
|
+
* `whoami` — which an organization key cannot call — while the answer was
|
|
58
|
+
* already sitting in this constructor.
|
|
59
|
+
*/
|
|
60
|
+
readonly workspaceId: string | undefined
|
|
61
|
+
|
|
52
62
|
constructor(apiBaseUrl: string, token: string, workspaceId?: string) {
|
|
63
|
+
this.workspaceId = workspaceId
|
|
53
64
|
this.client = new FronteraClient({
|
|
54
65
|
apiBaseUrl,
|
|
55
66
|
credential: { kind: 'apiKey', key: token },
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn the service's validation `details` into something a person can act on.
|
|
3
|
+
*
|
|
4
|
+
* The service answers a rejected document with TypeBox's own error array, and
|
|
5
|
+
* the CLI put it on screen with `JSON.stringify`:
|
|
6
|
+
*
|
|
7
|
+
* [{"summary":"Expected 'virtual'","type":32,"schema":{"const":"virtual",
|
|
8
|
+
* "type":"string"},"path":"/datasets/0/mode","message":"Expected 'virtual'",
|
|
9
|
+
* "errors":[]}]
|
|
10
|
+
*
|
|
11
|
+
* Every fact a caller needs is in there, and none of it is legible. The path is
|
|
12
|
+
* buried mid-line, the schema fragment is noise, and `type: 32` is an internal
|
|
13
|
+
* enum. `source create` was unusable through this: it is the last gate before a
|
|
14
|
+
* Source exists, and it answered with a machine's notes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface TypeBoxError {
|
|
18
|
+
path?: string
|
|
19
|
+
message?: string
|
|
20
|
+
summary?: string
|
|
21
|
+
value?: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `/datasets/0/mode` reads better as `datasets[0].mode`. */
|
|
25
|
+
export function readablePath(path: string | undefined): string {
|
|
26
|
+
if (!path || path === '/') return '(document root)'
|
|
27
|
+
return path
|
|
28
|
+
.replace(/^\//, '')
|
|
29
|
+
.split('/')
|
|
30
|
+
.map((segment) => (/^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`))
|
|
31
|
+
.join('')
|
|
32
|
+
.replace(/^\./, '')
|
|
33
|
+
.replace(/\.\[/g, '[')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether these errors all point at a discriminator.
|
|
38
|
+
*
|
|
39
|
+
* A discriminated union reports the field it switches on when ANY branch fails,
|
|
40
|
+
* so `mode: "virtual"` is rejected with "Expected 'virtual'" while the real
|
|
41
|
+
* defect is somewhere else in that branch entirely. Silently correct and
|
|
42
|
+
* completely misleading, and it cost an audit an afternoon — so when the shape
|
|
43
|
+
* matches, the hint says so rather than letting the caller stare at a field
|
|
44
|
+
* that is already right.
|
|
45
|
+
*/
|
|
46
|
+
export function looksLikeDiscriminator(errors: TypeBoxError[]): boolean {
|
|
47
|
+
return (
|
|
48
|
+
errors.length > 0
|
|
49
|
+
&& errors.every((e) => /^Expected '.*'$/.test(e.message ?? e.summary ?? ''))
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One line per error, plus a note when the paths cannot be trusted.
|
|
55
|
+
*
|
|
56
|
+
* Returns `undefined` when there is nothing structured to say, so the caller
|
|
57
|
+
* keeps whatever hint it already had rather than showing an empty one.
|
|
58
|
+
*/
|
|
59
|
+
export function formatValidationDetails(details: unknown): string | undefined {
|
|
60
|
+
const errors = Array.isArray(details) ? (details as TypeBoxError[]) : null
|
|
61
|
+
if (!errors || errors.length === 0) return undefined
|
|
62
|
+
|
|
63
|
+
const lines = errors
|
|
64
|
+
.filter((e) => e && typeof e === 'object')
|
|
65
|
+
.map((e) => {
|
|
66
|
+
const message = e.message ?? e.summary ?? 'is not valid'
|
|
67
|
+
return `${readablePath(e.path)} — ${message}`
|
|
68
|
+
})
|
|
69
|
+
if (lines.length === 0) return undefined
|
|
70
|
+
|
|
71
|
+
// Capped: a document with fifty problems produces fifty lines, and the first
|
|
72
|
+
// few are the ones anybody reads. The count says what was left out rather
|
|
73
|
+
// than letting the list end without saying so.
|
|
74
|
+
const SHOWN = 5
|
|
75
|
+
const shown = lines.slice(0, SHOWN)
|
|
76
|
+
if (lines.length > SHOWN) shown.push(`… and ${lines.length - SHOWN} more`)
|
|
77
|
+
|
|
78
|
+
if (looksLikeDiscriminator(errors)) {
|
|
79
|
+
shown.push(
|
|
80
|
+
'that field may already be correct — a discriminated union reports the '
|
|
81
|
+
+ 'field it switches on when any part of that branch fails, so look at '
|
|
82
|
+
+ 'the rest of the same object',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return shown.join('\n ')
|
|
87
|
+
}
|
|
@@ -16,7 +16,7 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
function api(ctx: CommandContext): GovernedActionApi {
|
|
19
|
-
return new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
19
|
+
return new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export const actionDeploy: Command = {
|
|
@@ -49,7 +49,7 @@ export const actionGrant: Command = {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const revoke = flagBool(ctx, 'revoke')
|
|
52
|
-
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
52
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
53
53
|
const result = await client.setCapabilityGrant(capability, role, revoke)
|
|
54
54
|
|
|
55
55
|
const held = result.actions.length > 0 ? result.actions.join(', ') : '(none)'
|
|
@@ -21,7 +21,7 @@ export const actionList: Command = {
|
|
|
21
21
|
},
|
|
22
22
|
|
|
23
23
|
async run(ctx) {
|
|
24
|
-
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
24
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
25
25
|
const actions = await client.listPublishedActions()
|
|
26
26
|
|
|
27
27
|
if (actions.length === 0) {
|
|
@@ -31,7 +31,7 @@ export const actionPrepare: Command = {
|
|
|
31
31
|
const apiName = ctx.positional[0]
|
|
32
32
|
if (!apiName) throw new UsageError('missing <action>', 'frontera action prepare escalateTicket')
|
|
33
33
|
|
|
34
|
-
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
34
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
35
35
|
const decision = await client.recordDeploymentDecision(
|
|
36
36
|
apiName,
|
|
37
37
|
flagString(ctx, 'reason') ?? 'Published before anything could be bound to it.',
|
|
@@ -19,6 +19,9 @@ export const actionRequests: Command = {
|
|
|
19
19
|
meta: {
|
|
20
20
|
noun: 'action',
|
|
21
21
|
verb: 'requests',
|
|
22
|
+
// Reads the slice granted to ONE workspace, unlike the rest of this
|
|
23
|
+
// noun — see `scope` on CommandMeta.
|
|
24
|
+
scope: 'workspace' as const,
|
|
22
25
|
args: [
|
|
23
26
|
{
|
|
24
27
|
name: 'requestId',
|
|
@@ -36,7 +39,7 @@ export const actionRequests: Command = {
|
|
|
36
39
|
},
|
|
37
40
|
|
|
38
41
|
async run(ctx) {
|
|
39
|
-
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
42
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
40
43
|
const requestId = ctx.positional[0]
|
|
41
44
|
|
|
42
45
|
if (requestId) {
|
|
@@ -19,7 +19,7 @@ import { flagString, type Command, type CommandContext } from '../types'
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
function api(ctx: CommandContext): GovernedActionApi {
|
|
22
|
-
return new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
22
|
+
return new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export const actionReview: Command = {
|
|
@@ -4,6 +4,7 @@ import { PlatformApi } from '../../api/platform-api'
|
|
|
4
4
|
import { CliError, UsageError } from '../../errors'
|
|
5
5
|
import { table } from '../../table'
|
|
6
6
|
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
7
|
+
import { resolveWorkspaceId } from '../workspace-id'
|
|
7
8
|
import { renderComposition, type Lookups } from './compose'
|
|
8
9
|
import { resolveAgentRef, type AgentRow } from './resolve'
|
|
9
10
|
|
|
@@ -33,7 +34,10 @@ async function buildLookups(
|
|
|
33
34
|
): Promise<Lookups> {
|
|
34
35
|
const [plugins, knowledge, skills] = await Promise.all([
|
|
35
36
|
client.pluginInstalls().catch(() => [] as unknown[]),
|
|
36
|
-
|
|
37
|
+
// Resolves `--workspace` first: this used to ask `whoami`, which an
|
|
38
|
+
// organization key cannot call, so the knowledge column silently rendered
|
|
39
|
+
// ids instead of names for every organization-key caller.
|
|
40
|
+
resolveWorkspaceId(client).then((ws) => client.knowledgeBases(ws)).catch(() => [] as unknown[]),
|
|
37
41
|
client.workspaceSkills().catch(() => [] as unknown[]),
|
|
38
42
|
])
|
|
39
43
|
|
|
@@ -364,13 +368,26 @@ const diff: Command = {
|
|
|
364
368
|
},
|
|
365
369
|
async run(ctx) {
|
|
366
370
|
const t = await target(ctx, 'diff')
|
|
367
|
-
|
|
371
|
+
// Both, before either branch answers: "the live version is current" is as
|
|
372
|
+
// wrong for an agent that has never published as "matches the live
|
|
373
|
+
// version" is, and that branch used to answer without ever looking.
|
|
374
|
+
const [draft, live] = await Promise.all([
|
|
375
|
+
t.api.agentDraft(t.id),
|
|
376
|
+
t.api.agent(t.id) as Promise<Record<string, unknown>>,
|
|
377
|
+
])
|
|
378
|
+
const published = Boolean(live.currentVersion ?? live.currentVersionId)
|
|
368
379
|
|
|
369
380
|
if (!draft) {
|
|
370
|
-
return {
|
|
381
|
+
return {
|
|
382
|
+
data: { hasDraft: false, published, changed: [] },
|
|
383
|
+
text: published
|
|
384
|
+
? 'No draft staged — the live version is current.'
|
|
385
|
+
: 'No draft staged, and nothing is published — this agent has no configuration '
|
|
386
|
+
+ 'of its own yet.\n'
|
|
387
|
+
+ ` Stage one with \`frontera agent apply ${t.ref} --file <path>\`.`,
|
|
388
|
+
}
|
|
371
389
|
}
|
|
372
390
|
|
|
373
|
-
const live = (await t.api.agent(t.id)) as Record<string, unknown>
|
|
374
391
|
const liveConfig = (live.config ?? live) as Record<string, unknown>
|
|
375
392
|
const draftConfig = (draft.snapshot.config ?? {}) as Record<string, unknown>
|
|
376
393
|
|
|
@@ -417,11 +434,15 @@ const diff: Command = {
|
|
|
417
434
|
].sort()
|
|
418
435
|
|
|
419
436
|
return {
|
|
420
|
-
data: { hasDraft: true, revision: draft.revision, changed },
|
|
437
|
+
data: { hasDraft: true, revision: draft.revision, published, changed },
|
|
421
438
|
text:
|
|
422
|
-
changed.length
|
|
423
|
-
? `Draft revision ${draft.revision}
|
|
424
|
-
:
|
|
439
|
+
changed.length > 0
|
|
440
|
+
? [`Draft revision ${draft.revision} differs in:`, ...changed.map((k) => ` ${k}`)].join('\n')
|
|
441
|
+
: published
|
|
442
|
+
? `Draft revision ${draft.revision} matches the live version.`
|
|
443
|
+
: `Draft revision ${draft.revision}. Nothing is published yet, so there is `
|
|
444
|
+
+ 'nothing to compare against — the draft IS the whole configuration.\n'
|
|
445
|
+
+ ` Publish it with \`frontera agent publish ${t.ref}\`.`,
|
|
425
446
|
}
|
|
426
447
|
},
|
|
427
448
|
}
|
|
@@ -7,7 +7,12 @@ export const appPromote: Command = {
|
|
|
7
7
|
meta: {
|
|
8
8
|
noun: 'app',
|
|
9
9
|
verb: 'promote',
|
|
10
|
-
args: [{
|
|
10
|
+
args: [{
|
|
11
|
+
name: 'version',
|
|
12
|
+
required: true,
|
|
13
|
+
description: 'version to make live, from `frontera app versions`',
|
|
14
|
+
producer: 'frontera app versions — then `frontera app promote <version>`',
|
|
15
|
+
}],
|
|
11
16
|
flags: {},
|
|
12
17
|
summary: 'Move the live pointer to an already-published version',
|
|
13
18
|
examples: ['frontera app promote 1.4.0'],
|
package/src/commands/app/sdk.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const appSdk: Command = {
|
|
|
7
7
|
meta: {
|
|
8
8
|
noun: 'app',
|
|
9
9
|
verb: 'sdk',
|
|
10
|
-
args: [{ name: 'action', required: true, description: 'sync' }],
|
|
10
|
+
args: [{ name: 'action', required: true, description: 'sync', producer: 'frontera app sdk sync' }],
|
|
11
11
|
flags: {},
|
|
12
12
|
summary: 'Refresh the generated SDK tree in a legacy vendored App',
|
|
13
13
|
examples: ['frontera app sdk sync'],
|
|
@@ -308,7 +308,7 @@ export const automationDev: Command = {
|
|
|
308
308
|
)
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
const api = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
311
|
+
const api = new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
312
312
|
// Identity of THIS process, minted once and sent with every dev call.
|
|
313
313
|
//
|
|
314
314
|
// The session id cannot serve: take-over is an upsert on the automation, so
|