@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
|
@@ -121,6 +121,61 @@ export function summarizeUploads(
|
|
|
121
121
|
* the count of what did not land has to be the first thing on the page rather
|
|
122
122
|
* than a column someone has to notice.
|
|
123
123
|
*/
|
|
124
|
+
/**
|
|
125
|
+
* How many source rows already carry these filenames.
|
|
126
|
+
*
|
|
127
|
+
* A failed upload does NOT mean nothing was recorded: the service registers the
|
|
128
|
+
* source and then fails downstream — ingestion, embedding — so `all 1
|
|
129
|
+
* upload(s) failed` was printed while a `pending` row sat in the base. Retrying
|
|
130
|
+
* added a second row for the same file, which is the opposite of the "a retry
|
|
131
|
+
* duplicates nothing" the hint used to promise.
|
|
132
|
+
*
|
|
133
|
+
* Counted rather than guessed, and matched on the filename because that is all
|
|
134
|
+
* a failed upload leaves behind — no id comes back. Names are not unique, so
|
|
135
|
+
* this is reported as "rows carrying this name", never as "your upload".
|
|
136
|
+
*/
|
|
137
|
+
export async function countExistingSources(
|
|
138
|
+
api: { knowledgeSources(id: string): Promise<unknown[]> },
|
|
139
|
+
baseId: string,
|
|
140
|
+
filenames: string[],
|
|
141
|
+
): Promise<Map<string, number>> {
|
|
142
|
+
const wanted = new Set(filenames)
|
|
143
|
+
const counts = new Map<string, number>()
|
|
144
|
+
// Best effort: this runs on a path that is already failing, and a second
|
|
145
|
+
// failure here must not replace the cause the caller needs to see.
|
|
146
|
+
const rows = await api.knowledgeSources(baseId).catch(() => [] as unknown[])
|
|
147
|
+
for (const row of rows as Array<{ fileName?: string }>) {
|
|
148
|
+
const name = row?.fileName
|
|
149
|
+
if (!name || !wanted.has(name)) continue
|
|
150
|
+
counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
151
|
+
}
|
|
152
|
+
return counts
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What to say about rows a failed upload may have left behind.
|
|
157
|
+
*
|
|
158
|
+
* Returns `undefined` when there is nothing to warn about, so a clean failure
|
|
159
|
+
* keeps a clean message.
|
|
160
|
+
*/
|
|
161
|
+
export function describeExistingSources(counts: Map<string, number>): string | undefined {
|
|
162
|
+
const present = [...counts.entries()].filter(([, n]) => n > 0)
|
|
163
|
+
if (present.length === 0) return undefined
|
|
164
|
+
|
|
165
|
+
const total = present.reduce((sum, [, n]) => sum + n, 0)
|
|
166
|
+
const duplicated = present.filter(([, n]) => n > 1)
|
|
167
|
+
return (
|
|
168
|
+
`${total} source row${total === 1 ? '' : 's'} already `
|
|
169
|
+
+ `${total === 1 ? 'carries' : 'carry'} `
|
|
170
|
+
+ `${present.length === 1 ? 'that filename' : 'those filenames'}`
|
|
171
|
+
+ (duplicated.length > 0
|
|
172
|
+
? `, and ${duplicated.length} filename${duplicated.length === 1 ? '' : 's'} `
|
|
173
|
+
+ `${duplicated.length === 1 ? 'has' : 'have'} more than one — `
|
|
174
|
+
+ 'a failed upload still registers a source, so re-running adds another'
|
|
175
|
+
: ' — a failed upload still registers a source')
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
124
179
|
export function renderUploadSummary(summary: UploadSummary): string {
|
|
125
180
|
const lines: string[] = []
|
|
126
181
|
const total = summary.sources.length
|
|
@@ -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
|
}
|
|
@@ -172,7 +172,85 @@ function requireConfig(doc: SourceFile, path: string): Record<string, unknown> {
|
|
|
172
172
|
hint: 'config: { kind: "postgres", host, port, database, username, sslMode, connectTimeoutMs }',
|
|
173
173
|
})
|
|
174
174
|
}
|
|
175
|
-
|
|
175
|
+
const config = doc.config as Record<string, unknown>
|
|
176
|
+
validateConnectionConfig(config, path)
|
|
177
|
+
return config
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const SSL_MODES = ['require', 'verify-ca', 'verify-full', 'disable']
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The connection block, checked here for the reason `validateDatasets` gives.
|
|
184
|
+
*
|
|
185
|
+
* That function exists because the service's refusal for this body names the
|
|
186
|
+
* wrong field — `strictJsonBody` substitutes a sentinel that omits `mode`, so
|
|
187
|
+
* ANY failure anywhere in the document comes back as "Expected 'virtual' at
|
|
188
|
+
* /datasets/0/mode". It pre-empted that for `datasets` and not for `config`,
|
|
189
|
+
* which left the commonest mistakes unreachable: a `config` missing `kind` or
|
|
190
|
+
* `connectTimeoutMs` was reported as a bad `mode` on a dataset whose `mode` was
|
|
191
|
+
* already `"virtual"`.
|
|
192
|
+
*
|
|
193
|
+
* That is how `source create` came to look like a write path with no passing
|
|
194
|
+
* document. It has one — the errors were describing the wrong half of the file.
|
|
195
|
+
*
|
|
196
|
+
* `password` is deliberately absent: it never travels in the file, and a file
|
|
197
|
+
* carrying one is refused rather than stripped, above.
|
|
198
|
+
*/
|
|
199
|
+
export function validateConnectionConfig(config: Record<string, unknown>, path: string): void {
|
|
200
|
+
const where = `${path}: config`
|
|
201
|
+
|
|
202
|
+
if (config.kind !== 'postgres') {
|
|
203
|
+
throw new CliError(`${where} has kind ${JSON.stringify(config.kind)}.`, {
|
|
204
|
+
code: 'USAGE',
|
|
205
|
+
hint: 'kind must be "postgres" — it is the only connector the service accepts today',
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const field of ['host', 'database', 'username'] as const) {
|
|
210
|
+
const value = config[field]
|
|
211
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
212
|
+
throw new CliError(`${where}.${field} is ${JSON.stringify(value)}.`, {
|
|
213
|
+
code: 'USAGE',
|
|
214
|
+
hint: `${field} must be a non-empty string`,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const port = config.port
|
|
220
|
+
if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65_535) {
|
|
221
|
+
throw new CliError(`${where}.port is ${JSON.stringify(port)}.`, {
|
|
222
|
+
code: 'USAGE',
|
|
223
|
+
hint: 'port must be a whole number between 1 and 65535 — 5432 for a default PostgreSQL',
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (typeof config.sslMode !== 'string' || !SSL_MODES.includes(config.sslMode)) {
|
|
228
|
+
throw new CliError(`${where}.sslMode is ${JSON.stringify(config.sslMode)}.`, {
|
|
229
|
+
code: 'USAGE',
|
|
230
|
+
hint: `sslMode must be one of: ${SSL_MODES.join(', ')}`,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const timeout = config.connectTimeoutMs
|
|
235
|
+
if (!Number.isInteger(timeout) || (timeout as number) < 1_000 || (timeout as number) > 15_000) {
|
|
236
|
+
throw new CliError(`${where}.connectTimeoutMs is ${JSON.stringify(timeout)}.`, {
|
|
237
|
+
code: 'USAGE',
|
|
238
|
+
// Easily missed: it has no sensible default the CLI could supply, because
|
|
239
|
+
// the right value depends on where the database is.
|
|
240
|
+
hint: 'connectTimeoutMs must be a whole number of milliseconds between 1000 and 15000',
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The service sets `additionalProperties: false`, so an extra key fails the
|
|
245
|
+
// whole document — and fails it by naming a dataset's `mode`.
|
|
246
|
+
const KNOWN = new Set(['kind', 'host', 'port', 'database', 'username', 'sslMode', 'connectTimeoutMs'])
|
|
247
|
+
const unknown = Object.keys(config).filter((key) => !KNOWN.has(key))
|
|
248
|
+
if (unknown.length > 0) {
|
|
249
|
+
throw new CliError(`${where} carries unknown field(s): ${unknown.join(', ')}.`, {
|
|
250
|
+
code: 'USAGE',
|
|
251
|
+
hint: `config accepts exactly: ${[...KNOWN].join(', ')}`,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
176
254
|
}
|
|
177
255
|
|
|
178
256
|
const list: Command = {
|
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
|
+
}
|