@frontera-sdk/cli 1.45.6 → 1.45.7
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 +1 -1
- package/src/api/automation-api.ts +11 -1
- package/src/api/blueprint-authoring-api.ts +7 -24
- package/src/api/credential-failure.ts +77 -0
- package/src/api/dataset-api.ts +6 -0
- package/src/api/governed-action-api.ts +32 -9
- package/src/api/platform-api.ts +11 -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 +5 -1
- 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 +14 -5
- package/src/commands/blueprint/generate-types.ts +3 -0
- package/src/commands/blueprint/get.ts +3 -0
- package/src/commands/blueprint/list.ts +3 -0
- package/src/commands/blueprint/query.ts +6 -0
- package/src/commands/kit/status.ts +24 -3
- package/src/commands/knowledge/index-commands.ts +4 -14
- package/src/commands/secret/index-commands.ts +4 -13
- package/src/commands/skill/bundle-commands.ts +9 -11
- 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/package.json
CHANGED
|
@@ -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,5 @@
|
|
|
1
1
|
import { CliError } from '../errors'
|
|
2
|
+
import { credentialFailure } from './credential-failure'
|
|
2
3
|
import type { DefinitionBundle } from '../blueprint/model'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -40,30 +41,12 @@ export class BlueprintAuthoringApi {
|
|
|
40
41
|
if (!res.ok) {
|
|
41
42
|
// The credential failures worth telling apart, because each has a different
|
|
42
43
|
// 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
|
-
}
|
|
44
|
+
// Shared with the other clients so the three of them cannot drift again.
|
|
45
|
+
const credential = credentialFailure(res.status, payload?.message, {
|
|
46
|
+
token: this.token,
|
|
47
|
+
whenSilent: "The key is not permitted to author this organization's Blueprint.",
|
|
48
|
+
})
|
|
49
|
+
if (credential) throw credential
|
|
67
50
|
if (res.status === 409) {
|
|
68
51
|
throw new CliError(
|
|
69
52
|
payload?.message
|
|
@@ -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,7 @@ 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'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Datasets, from the CLI.
|
|
@@ -99,6 +100,11 @@ export class DatasetApi {
|
|
|
99
100
|
const text = await response.text()
|
|
100
101
|
const payload = text ? JSON.parse(text) : {}
|
|
101
102
|
if (!response.ok) {
|
|
103
|
+
// Datasets and sources are organization-scoped, so a workspace key is
|
|
104
|
+
// refused here by design — and this client used to answer that with a
|
|
105
|
+
// bare "Insufficient permissions" and no route out.
|
|
106
|
+
const credential = credentialFailure(response.status, payload?.message, { token: this.token })
|
|
107
|
+
if (credential) throw credential
|
|
102
108
|
throw new CliError(payload?.message ?? `Request failed (${response.status}).`, {
|
|
103
109
|
code: payload?.code ?? 'FAILURE',
|
|
104
110
|
...(payload?.details ? { hint: JSON.stringify(payload.details) } : {}),
|
|
@@ -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 },
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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.', {
|
|
@@ -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',
|
|
@@ -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' },
|
|
@@ -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')
|
|
@@ -3,6 +3,7 @@ import { CliError, UsageError } from '../../errors'
|
|
|
3
3
|
import { table } from '../../table'
|
|
4
4
|
import { resolveAgentRef } from '../agent/resolve'
|
|
5
5
|
import { flagString, type Command } from '../types'
|
|
6
|
+
import { resolveWorkspaceId } from '../workspace-id'
|
|
6
7
|
import { renderUploadSummary, summarizeUploads, uploadAll } from './upload-batch'
|
|
7
8
|
import { planUploads } from './upload-plan'
|
|
8
9
|
|
|
@@ -13,24 +14,13 @@ interface KnowledgeRow {
|
|
|
13
14
|
description?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
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
17
|
|
|
28
18
|
/** Accept a base by name or id — `knowledge list` shows both. */
|
|
29
19
|
async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
|
|
30
20
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
31
21
|
if (UUID.test(ref)) return ref
|
|
32
22
|
|
|
33
|
-
const workspaceId = await
|
|
23
|
+
const workspaceId = await resolveWorkspaceId(client)
|
|
34
24
|
const rows = (await client.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
35
25
|
const match = rows.find((k) => (k.name ?? '').toLowerCase() === ref.toLowerCase())
|
|
36
26
|
if (match?.id) return match.id
|
|
@@ -54,7 +44,7 @@ const list: Command = {
|
|
|
54
44
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
55
45
|
// The route takes the workspace explicitly, and a key holder cannot see
|
|
56
46
|
// its own workspace id from anywhere else — hence /v1/whoami.
|
|
57
|
-
const workspaceId = await
|
|
47
|
+
const workspaceId = await resolveWorkspaceId(api)
|
|
58
48
|
const rows = (await api.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
59
49
|
return {
|
|
60
50
|
data: rows,
|
|
@@ -292,7 +282,7 @@ const create: Command = {
|
|
|
292
282
|
}
|
|
293
283
|
|
|
294
284
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
295
|
-
const workspaceId = await
|
|
285
|
+
const workspaceId = await resolveWorkspaceId(api)
|
|
296
286
|
|
|
297
287
|
// Checked here rather than left to the unique constraint: the service
|
|
298
288
|
// answers a duplicate with a bare CONFLICT, whose generic hint ("resolve
|
|
@@ -3,6 +3,7 @@ import { CliError, UsageError } from '../../errors'
|
|
|
3
3
|
import { readSecretValue } from '../../secrets'
|
|
4
4
|
import { table } from '../../table'
|
|
5
5
|
import { flagString, type Command } from '../types'
|
|
6
|
+
import { resolveWorkspaceId } from '../workspace-id'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Workspace secrets.
|
|
@@ -30,16 +31,6 @@ interface SecretRow {
|
|
|
30
31
|
dependents?: Array<{ kind?: string; displayName?: string; name?: string }>
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
async function requireWorkspaceId(api: PlatformApi): Promise<string> {
|
|
34
|
-
const me = await api.whoami()
|
|
35
|
-
if (!me.workspaceId) {
|
|
36
|
-
throw new CliError('this credential is not scoped to a workspace', {
|
|
37
|
-
code: 'FORBIDDEN',
|
|
38
|
-
hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
return me.workspaceId
|
|
42
|
-
}
|
|
43
34
|
|
|
44
35
|
const list: Command = {
|
|
45
36
|
meta: {
|
|
@@ -52,7 +43,7 @@ const list: Command = {
|
|
|
52
43
|
},
|
|
53
44
|
async run(ctx) {
|
|
54
45
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
55
|
-
const rows = (await api.workspaceSecrets(await
|
|
46
|
+
const rows = (await api.workspaceSecrets(await resolveWorkspaceId(api))) as SecretRow[]
|
|
56
47
|
|
|
57
48
|
return {
|
|
58
49
|
data: rows,
|
|
@@ -130,7 +121,7 @@ const set: Command = {
|
|
|
130
121
|
}
|
|
131
122
|
|
|
132
123
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
133
|
-
const workspaceId = await
|
|
124
|
+
const workspaceId = await resolveWorkspaceId(api)
|
|
134
125
|
const description = flagString(ctx, 'description')
|
|
135
126
|
|
|
136
127
|
// List, then create or replace. The service has no upsert, and choosing by
|
|
@@ -180,7 +171,7 @@ const remove: Command = {
|
|
|
180
171
|
if (!name) throw new UsageError('missing <name>', 'frontera secret list — then pass a name')
|
|
181
172
|
|
|
182
173
|
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
183
|
-
await api.deleteWorkspaceSecret(await
|
|
174
|
+
await api.deleteWorkspaceSecret(await resolveWorkspaceId(api), name)
|
|
184
175
|
|
|
185
176
|
// No `--force`. The service refuses while anything still resolves the
|
|
186
177
|
// secret and says what, and an override here would only move the outage
|
|
@@ -6,6 +6,7 @@ import matter from 'gray-matter'
|
|
|
6
6
|
import { PlatformApi } from '../../api/platform-api'
|
|
7
7
|
import { CliError, UsageError } from '../../errors'
|
|
8
8
|
import { flagBool, flagString, type Command } from '../types'
|
|
9
|
+
import { resolveWorkspaceId } from '../workspace-id'
|
|
9
10
|
import { resolveSkillRef } from './resolve'
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -123,7 +124,9 @@ const pull: Command = {
|
|
|
123
124
|
|
|
124
125
|
async run(ctx) {
|
|
125
126
|
const ref = ctx.positional[0]
|
|
126
|
-
|
|
127
|
+
// `skill get` and `skill delete` both name the producer; pull restated its
|
|
128
|
+
// own form, which tells a caller nothing they did not already have.
|
|
129
|
+
if (!ref) throw new UsageError('missing <skill>', 'frontera skill list — then pass a name or id')
|
|
127
130
|
|
|
128
131
|
const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
129
132
|
const id = await resolveSkillRef(client, ref)
|
|
@@ -152,21 +155,16 @@ const pull: Command = {
|
|
|
152
155
|
written.push(script.path)
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
// Assets need the workspace segment for the download route
|
|
156
|
-
//
|
|
158
|
+
// Assets need the workspace segment for the download route. `--workspace`
|
|
159
|
+
// answers it for an organization key, which cannot call whoami at all; a
|
|
160
|
+
// workspace key still falls back to its own scope.
|
|
157
161
|
const assets = doc.assets ?? []
|
|
158
162
|
if (assets.length > 0) {
|
|
159
|
-
const
|
|
160
|
-
if (!who.workspaceId) {
|
|
161
|
-
throw new CliError('cannot download assets without a workspace scope', {
|
|
162
|
-
code: 'FORBIDDEN',
|
|
163
|
-
hint: 'use a workspace-scoped API key',
|
|
164
|
-
})
|
|
165
|
-
}
|
|
163
|
+
const workspaceId = await resolveWorkspaceId(client)
|
|
166
164
|
for (const asset of assets) {
|
|
167
165
|
const filename = assetFilename(asset.storageKey)
|
|
168
166
|
if (!filename) continue
|
|
169
|
-
const bytes = await client.downloadSkillAsset(
|
|
167
|
+
const bytes = await client.downloadSkillAsset(workspaceId, filename)
|
|
170
168
|
writeBundleFile(dir, asset.path, bytes)
|
|
171
169
|
written.push(`${asset.path}${asset.visible ? ' *' : ''}`)
|
|
172
170
|
}
|
package/src/commands/types.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface ArgSpec {
|
|
|
6
6
|
name: string
|
|
7
7
|
required: boolean
|
|
8
8
|
description: string
|
|
9
|
+
/**
|
|
10
|
+
* What to run to obtain this value, when the dispatcher reports it missing.
|
|
11
|
+
*
|
|
12
|
+
* Only read for `needsProject` commands, whose own `run` never executes when
|
|
13
|
+
* an argument is absent. Everywhere else the command raises its own error and
|
|
14
|
+
* names its own producer, which is strictly better than anything general.
|
|
15
|
+
*/
|
|
16
|
+
producer?: string
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
export interface CommandMeta {
|
|
@@ -18,6 +26,21 @@ export interface CommandMeta {
|
|
|
18
26
|
summary: string
|
|
19
27
|
/** At least one, copy-pasteable. Agents read --help before documentation. */
|
|
20
28
|
examples: readonly string[]
|
|
29
|
+
/**
|
|
30
|
+
* The grain this VERB acts at, when it differs from its noun's.
|
|
31
|
+
*
|
|
32
|
+
* Scope was tracked per noun, and several nouns span both grains: Blueprint
|
|
33
|
+
* AUTHORING edits one draft shared by the organization while Blueprint
|
|
34
|
+
* READING returns the slice granted to one workspace, and `action list` is
|
|
35
|
+
* organization-wide while `action requests` is not. A per-noun map cannot be
|
|
36
|
+
* right about either — it produced a warning that named the wrong commands,
|
|
37
|
+
* and let `agent list` answer "No agents in this workspace" for a request
|
|
38
|
+
* that had never named a workspace.
|
|
39
|
+
*
|
|
40
|
+
* Omit it and the noun's own grain applies. See `scopes.ts`.
|
|
41
|
+
*/
|
|
42
|
+
scope?: 'workspace' | 'organization'
|
|
43
|
+
|
|
21
44
|
/** Resolve an app project before running, and fail if there is none. */
|
|
22
45
|
needsProject?: boolean
|
|
23
46
|
/**
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PlatformApi } from '../api/platform-api'
|
|
2
|
+
import { CliError } from '../errors'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which workspace a command is acting in.
|
|
6
|
+
*
|
|
7
|
+
* `knowledge` and `secret` name a workspace in the URL, so they have to resolve
|
|
8
|
+
* one before they can call anything. Both asked `whoami`, which is right for a
|
|
9
|
+
* workspace key — it carries its own workspace and nothing else names it — and
|
|
10
|
+
* wrong for an organization key in two compounding ways:
|
|
11
|
+
*
|
|
12
|
+
* `/v1/whoami` answers 403 to an organization key, so the resolution failed
|
|
13
|
+
* before it could report what was actually missing. The caller read
|
|
14
|
+
* "Insufficient permissions" and went looking for a capability, when the key
|
|
15
|
+
* held every one the route needed.
|
|
16
|
+
*
|
|
17
|
+
* `--workspace` was already on the command line. The flag exists precisely so
|
|
18
|
+
* an organization key can say which workspace it means, and these two nouns
|
|
19
|
+
* are in the addressable set — so the CLI accepted the answer and then went
|
|
20
|
+
* and asked someone else.
|
|
21
|
+
*
|
|
22
|
+
* The flag wins when present. `whoami` stays as the fallback, because a
|
|
23
|
+
* workspace key does not pass `--workspace` and should not have to.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveWorkspaceId(api: PlatformApi): Promise<string> {
|
|
26
|
+
if (api.workspaceId) return api.workspaceId
|
|
27
|
+
|
|
28
|
+
const me = await api.whoami().catch(() => null)
|
|
29
|
+
if (me?.workspaceId) return me.workspaceId
|
|
30
|
+
|
|
31
|
+
throw new CliError('this credential does not name a workspace', {
|
|
32
|
+
code: 'FORBIDDEN',
|
|
33
|
+
// Both routes out, because both are real: an organization key names one per
|
|
34
|
+
// invocation, and a workspace key carries one permanently.
|
|
35
|
+
hint: 'pass --workspace <id> (see `frontera workspace list`), '
|
|
36
|
+
+ 'or use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
37
|
+
})
|
|
38
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { CliError, UsageError } from './errors'
|
|
|
6
6
|
import { EXIT } from './exit'
|
|
7
7
|
import { createOutput, type OutputMode } from './output'
|
|
8
8
|
import { readProject } from './project'
|
|
9
|
+
import { commandScope } from './scopes'
|
|
9
10
|
import {
|
|
10
11
|
aliasesFor,
|
|
11
12
|
describeCommand,
|
|
@@ -154,6 +155,29 @@ async function main(): Promise<number> {
|
|
|
154
155
|
const dirFlag = typeof flags.dir === 'string' ? flags.dir : undefined
|
|
155
156
|
const cwd = dirFlag ?? process.cwd()
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A missing argument outranks a missing project directory.
|
|
160
|
+
*
|
|
161
|
+
* `needsProject` is enforced here, before `run`, so a command that
|
|
162
|
+
* validates its own arguments never got to speak: `frontera app promote`
|
|
163
|
+
* with no version reported "not in a Frontera app directory" and never
|
|
164
|
+
* mentioned `<version>` or `app versions`. Two things were wrong and the
|
|
165
|
+
* caller was told the one they had not asked about.
|
|
166
|
+
*
|
|
167
|
+
* Scoped to `needsProject` deliberately. Every other command reaches its
|
|
168
|
+
* own check, and those checks name the command that PRODUCES the missing
|
|
169
|
+
* value — better than anything derivable here, and worth not preempting.
|
|
170
|
+
*/
|
|
171
|
+
const missingArg = command.meta.args.find((arg, i) => arg.required && positional[i] === undefined)
|
|
172
|
+
|
|
173
|
+
if (command.meta.needsProject && missingArg) {
|
|
174
|
+
throw new UsageError(
|
|
175
|
+
`missing <${missingArg.name}>`,
|
|
176
|
+
missingArg.producer ?? command.meta.examples[0]
|
|
177
|
+
?? `frontera ${command.meta.noun} ${command.meta.verb} <${missingArg.name}>`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
157
181
|
let project: AppProject | null = null
|
|
158
182
|
if (command.meta.needsProject || command.meta.optionalProject) {
|
|
159
183
|
const root = findProjectRoot(cwd)
|
|
@@ -187,9 +211,6 @@ async function main(): Promise<number> {
|
|
|
187
211
|
const workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
|
|
188
212
|
|
|
189
213
|
/**
|
|
190
|
-
* Nouns whose commands build a `PlatformApi`, which is the only client that
|
|
191
|
-
* carries `x-workspace-id`.
|
|
192
|
-
*
|
|
193
214
|
* `--workspace` is global — same reasoning as `--profile`: an escape hatch
|
|
194
215
|
* that exists on six commands and not the seventh is useless when the
|
|
195
216
|
* seventh is the one being diagnosed. But global must not mean "silently
|
|
@@ -197,45 +218,61 @@ async function main(): Promise<number> {
|
|
|
197
218
|
* and reported nothing, which is the plausible-wrong-answer shape this CLI
|
|
198
219
|
* refuses everywhere else.
|
|
199
220
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
221
|
+
* Judged per VERB, not per noun: `blueprint status` edits the shared draft
|
|
222
|
+
* and has no workspace to name, while `blueprint list` reads the slice
|
|
223
|
+
* granted to one. A noun-level refusal was wrong about both.
|
|
203
224
|
*/
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
225
|
+
// `offline` as well as organization grain: `init` and `setup` write files
|
|
226
|
+
// and never open a connection, so a workspace means nothing to them. They
|
|
227
|
+
// are not organization-scoped either, so scope alone would have let the
|
|
228
|
+
// flag through — `frontera init --workspace <id>` scaffolded a directory
|
|
229
|
+
// and ignored the flag in silence.
|
|
230
|
+
const label = `${command.meta.noun}${command.meta.verb ? ` ${command.meta.verb}` : ''}`
|
|
231
|
+
if (workspaceFlag && (command.meta.offline || commandScope(command.meta) === 'organization')) {
|
|
210
232
|
throw new UsageError(
|
|
211
|
-
`--workspace does nothing on \`${
|
|
212
|
-
|
|
213
|
-
|
|
233
|
+
`--workspace does nothing on \`${label}\``,
|
|
234
|
+
command.meta.offline
|
|
235
|
+
? 'this command touches no service, so there is no request to address.'
|
|
236
|
+
: 'this command acts on the whole organization. '
|
|
237
|
+
+ '--workspace applies to workspace-scoped commands — '
|
|
238
|
+
+ `try \`frontera ${command.meta.noun} --help\``,
|
|
214
239
|
)
|
|
215
240
|
}
|
|
216
241
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
/**
|
|
243
|
+
* An organization key that named no workspace cannot run a
|
|
244
|
+
* workspace-scoped command, so it is refused rather than warned.
|
|
245
|
+
*
|
|
246
|
+
* This was a note, and a note was not enough. The request still went out,
|
|
247
|
+
* ran at organization scope, matched nothing, and `agent list` printed
|
|
248
|
+
* "No agents in this workspace." and exited 0 — for a request that had
|
|
249
|
+
* never looked at a workspace. Every sibling already refused (`app` and
|
|
250
|
+
* `pack` with 400, `skill` with 403, `knowledge` and `secret` with advice);
|
|
251
|
+
* `agent` was the one that answered a plausible lie with a success code.
|
|
252
|
+
*
|
|
253
|
+
* The wording is `knowledge`'s, because both routes out are real: an
|
|
254
|
+
* organization key names a workspace per invocation, a workspace key
|
|
255
|
+
* carries one permanently.
|
|
256
|
+
*/
|
|
228
257
|
if (
|
|
229
258
|
!command.meta.offline
|
|
230
259
|
&& !workspaceFlag
|
|
231
|
-
|
|
260
|
+
// A missing argument outranks a missing workspace, for the reason the
|
|
261
|
+
// project gate above gives: `skill pull` with no skill named has a
|
|
262
|
+
// better error waiting inside the command, and it names the command
|
|
263
|
+
// that produces the value. Let it be raised.
|
|
264
|
+
&& !missingArg
|
|
265
|
+
&& commandScope(command.meta) === 'workspace'
|
|
232
266
|
&& credential.token.startsWith('sk-org-')
|
|
233
267
|
) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
+ '
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
throw new CliError(
|
|
269
|
+
`\`${label}\` acts inside a workspace, `
|
|
270
|
+
+ 'and this organization key names none',
|
|
271
|
+
{
|
|
272
|
+
code: 'FORBIDDEN',
|
|
273
|
+
hint: 'pass --workspace <id> (see `frontera workspace list`), '
|
|
274
|
+
+ 'or use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
275
|
+
},
|
|
239
276
|
)
|
|
240
277
|
}
|
|
241
278
|
|
package/src/scopes.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which grain each noun acts at — stated once.
|
|
3
|
+
*
|
|
4
|
+
* This map had grown three copies: the dispatcher's unaddressed-organization-key
|
|
5
|
+
* note, the list of nouns `--workspace` can reach, and the hint a 403 carries.
|
|
6
|
+
* They had already disagreed — `action` appeared in none of them, so an
|
|
7
|
+
* organization key running `action list` was warned about workspace scope by a
|
|
8
|
+
* sentence that did not mention `action`, and refused by a hint that called
|
|
9
|
+
* Actions organization-scoped.
|
|
10
|
+
*
|
|
11
|
+
* The noun map is a DEFAULT, not the answer: `commandScope` below lets a verb
|
|
12
|
+
* override it, because several nouns span both grains.
|
|
13
|
+
*
|
|
14
|
+
* There is no scope field on a command to derive this from, and adding one to
|
|
15
|
+
* silence a note would be the wrong trade — the grain is a fact about the
|
|
16
|
+
* SERVICE's routes, not about the command that calls them. So it is listed, and
|
|
17
|
+
* listed here only.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Nouns whose routes answer at organization grain.
|
|
22
|
+
*
|
|
23
|
+
* A workspace key is refused these BY DESIGN — it is not a narrower
|
|
24
|
+
* organization key, it is a different lane. `auth` and `kit` are here because
|
|
25
|
+
* naming a workspace means nothing to them, not because they reach the service.
|
|
26
|
+
*/
|
|
27
|
+
export const ORG_SCOPED_NOUNS = new Set([
|
|
28
|
+
'auth',
|
|
29
|
+
'kit',
|
|
30
|
+
'workspace',
|
|
31
|
+
'blueprint',
|
|
32
|
+
'dataset',
|
|
33
|
+
'source',
|
|
34
|
+
// Governed Actions live under `/v1/blueprint/governed-actions` and carry the
|
|
35
|
+
// Blueprint's grain, which is why a workspace key is refused `action list`.
|
|
36
|
+
'action',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The grain one command acts at.
|
|
41
|
+
*
|
|
42
|
+
* The verb's own declaration wins; otherwise the noun's. Most nouns are
|
|
43
|
+
* uniform, so declaring all 129 would be noise that rots — and the ones that
|
|
44
|
+
* are not uniform are precisely the ones a per-noun map gets wrong.
|
|
45
|
+
*/
|
|
46
|
+
export function commandScope(meta: {
|
|
47
|
+
noun: string
|
|
48
|
+
scope?: 'workspace' | 'organization'
|
|
49
|
+
}): 'workspace' | 'organization' {
|
|
50
|
+
return meta.scope ?? (ORG_SCOPED_NOUNS.has(meta.noun) ? 'organization' : 'workspace')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** For prose: the organization-scoped nouns that actually reach the service. */
|
|
54
|
+
export function orgScopedServiceNouns(): string[] {
|
|
55
|
+
return [...ORG_SCOPED_NOUNS].filter((n) => n !== 'auth' && n !== 'kit').sort()
|
|
56
|
+
}
|