@frontera-sdk/cli 1.50.79 → 1.50.81
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/README.md +6 -0
- package/package.json +4 -4
- package/src/api/blueprint-authoring-api.ts +52 -0
- package/src/api/credential-failure.ts +34 -4
- package/src/api/dataset-api.ts +1 -1
- package/src/api/governed-action-api.ts +75 -7
- package/src/api/media-api.ts +181 -0
- package/src/api/platform-api.ts +59 -0
- package/src/api/workflow-api.ts +1 -1
- package/src/auth-verify.ts +1 -1
- package/src/commands/action/available.ts +59 -0
- package/src/commands/action/cancel.ts +34 -0
- package/src/commands/action/decide.ts +54 -0
- package/src/commands/action/deploy.ts +4 -1
- package/src/commands/action/index-commands.ts +16 -6
- package/src/commands/action/prepare.ts +3 -1
- package/src/commands/action/request-summary.ts +13 -0
- package/src/commands/action/requests.ts +6 -5
- package/src/commands/action/submit.ts +183 -0
- package/src/commands/blueprint/get.ts +139 -29
- package/src/commands/chat-app/index-commands.ts +475 -0
- package/src/commands/knowledge/upload-plan.ts +14 -8
- package/src/commands/marking/index-commands.ts +171 -0
- package/src/commands/media/index-commands.ts +592 -0
- package/src/commands/registry.ts +28 -6
- package/src/commands/types.ts +6 -0
- package/src/commands/workflow/list.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/exit.ts +126 -1
- package/src/flag-help.ts +24 -2
- package/src/forge/touches.ts +2 -0
- package/src/harness.ts +5 -3
- package/src/main.ts +23 -38
- package/src/scopes.ts +43 -0
- package/src/template.ts +6 -4
- package/src/templates/next-app-files.ts +8 -4
- package/src/vendor/sdk-sources.json +16 -15
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { table } from '../../table'
|
|
6
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Chat Apps — an Agent roster presented to an audience, published as an
|
|
10
|
+
* immutable version.
|
|
11
|
+
*
|
|
12
|
+
* The editor routes have always accepted a workspace key, so a Chat App could
|
|
13
|
+
* be staged and published from a script; nothing in the CLI reached them, which
|
|
14
|
+
* left every rollout a sequence of Console clicks.
|
|
15
|
+
*
|
|
16
|
+
* What is NOT here: talking to one. The runtime routes (`/chat`, Sessions,
|
|
17
|
+
* Agent mode) authenticate people, not keys, and refuse agent API keys
|
|
18
|
+
* outright — an App answers the person using it. Verify a publish by opening
|
|
19
|
+
* the App.
|
|
20
|
+
*
|
|
21
|
+
* Drafts are per principal. A key's draft is its own, separate from the one a
|
|
22
|
+
* person is editing in the Console, so `draft` then `publish` from the same key
|
|
23
|
+
* never picks up someone else's work in progress.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface ChatAppRow {
|
|
27
|
+
id?: string
|
|
28
|
+
slug?: string
|
|
29
|
+
displayName?: string
|
|
30
|
+
status?: string
|
|
31
|
+
kind?: string
|
|
32
|
+
systemKey?: string | null
|
|
33
|
+
deployedVersionId?: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Finding {
|
|
37
|
+
level?: string
|
|
38
|
+
code?: string
|
|
39
|
+
message?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EditorState {
|
|
43
|
+
app?: ChatAppRow
|
|
44
|
+
draft?: { configuration?: unknown; saved?: boolean; updatedAt?: string | null }
|
|
45
|
+
live?: { id?: string; version?: string; createdAt?: string } | null
|
|
46
|
+
findings?: Finding[]
|
|
47
|
+
grants?: Array<{ principalKind?: string; principalId?: string }>
|
|
48
|
+
endpoints?: Array<{ kind?: string; enabled?: boolean }>
|
|
49
|
+
agents?: Array<{ agentConfigId?: string; slug?: string; name?: string; publishedVersion?: number | null }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
53
|
+
const ENDPOINTS = ['platform', 'standalone', 'widget'] as const
|
|
54
|
+
const ACCENTS = ['blue', 'violet', 'green', 'amber', 'rose', 'teal', 'slate']
|
|
55
|
+
|
|
56
|
+
/** The workspace's Chat Apps. The built-in Chat is an application too, but not an editable one. */
|
|
57
|
+
async function chatApps(api: PlatformApi): Promise<ChatAppRow[]> {
|
|
58
|
+
return ((await api.platformApps()) as ChatAppRow[]).filter((a) => a.kind === 'chat' && !a.systemKey)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function resolveChatAppRef(api: PlatformApi, ref: string): Promise<string> {
|
|
62
|
+
if (UUID.test(ref)) return ref
|
|
63
|
+
|
|
64
|
+
const rows = await chatApps(api)
|
|
65
|
+
const match = rows.find((a) => (a.slug ?? '').toLowerCase() === ref.toLowerCase())
|
|
66
|
+
if (match?.id) return match.id
|
|
67
|
+
|
|
68
|
+
throw new CliError(`no Chat App named "${ref}"`, {
|
|
69
|
+
code: 'NOT_FOUND',
|
|
70
|
+
hint: `known Chat Apps: ${rows.map((a) => a.slug).filter(Boolean).join(', ') || '(none) — frontera chat-app create <slug> --name <name>'}`,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readConfiguration(path: string): Promise<unknown> {
|
|
75
|
+
let raw: string
|
|
76
|
+
if (path === '-') {
|
|
77
|
+
const chunks: Uint8Array[] = []
|
|
78
|
+
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk)
|
|
79
|
+
raw = Buffer.concat(chunks).toString('utf8')
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
raw = readFileSync(path, 'utf8')
|
|
83
|
+
} catch {
|
|
84
|
+
throw new UsageError(`could not read ${path}`, 'check the path, or pipe the configuration in with -')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(raw)
|
|
89
|
+
} catch {
|
|
90
|
+
throw new UsageError(
|
|
91
|
+
'the configuration is not valid JSON',
|
|
92
|
+
'start from `frontera chat-app get <app> --json | jq .draft.configuration`',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findingLines(findings: Finding[]): string[] {
|
|
98
|
+
return findings.map((f) => ` ${f.level ?? '?'} ${f.message ?? f.code ?? ''}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Turn a Chat App `VALIDATION_ERROR` into one the caller can act on.
|
|
103
|
+
*
|
|
104
|
+
* The service uses that code for two shapes. A blocked publish carries level-
|
|
105
|
+
* tagged findings; a file that is not a Chat App configuration at all carries
|
|
106
|
+
* plain `path: problem` strings. Rendering both as findings printed "?" for
|
|
107
|
+
* every schema error and called a malformed file a blocked publish.
|
|
108
|
+
*
|
|
109
|
+
* `ownDraft` is a publish of the caller's saved draft. A key's draft is its
|
|
110
|
+
* own, so an empty roster there usually means no `draft` ran from this key —
|
|
111
|
+
* not that the App the Console shows is empty.
|
|
112
|
+
*/
|
|
113
|
+
function rethrowValidation(err: unknown, ref: string, ownDraft = false): never {
|
|
114
|
+
const e = err as { code?: string; message?: string; details?: { details?: unknown } }
|
|
115
|
+
const entries = e.details?.details
|
|
116
|
+
if (e.code !== 'VALIDATION_ERROR' || !Array.isArray(entries)) throw err
|
|
117
|
+
|
|
118
|
+
const leveled = entries.some((x) => typeof x === 'object' && x !== null && 'level' in x)
|
|
119
|
+
const lines = entries.map((x) => (typeof x === 'string' ? x : (findingLines([x as Finding])[0] ?? '').trim()))
|
|
120
|
+
if (ownDraft && entries.some((x) => (x as Finding)?.code === 'roster.empty')) {
|
|
121
|
+
lines.push(`a key publishes its own draft — run \`frontera chat-app draft ${ref} --from ./app.json\` first`)
|
|
122
|
+
}
|
|
123
|
+
throw new CliError(leveled ? 'the configuration cannot be published yet' : (e.message ?? 'Invalid Chat App configuration'), {
|
|
124
|
+
code: 'VALIDATION_ERROR',
|
|
125
|
+
hint: lines.join('; '),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appRef(ctx: { positional: string[] }, verb: string): string {
|
|
130
|
+
const ref = ctx.positional[0]
|
|
131
|
+
if (!ref) throw new UsageError('missing <app>', `frontera chat-app list — then frontera chat-app ${verb} <slug>`)
|
|
132
|
+
return ref
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const list: Command = {
|
|
136
|
+
meta: {
|
|
137
|
+
noun: 'chat-app',
|
|
138
|
+
verb: 'list',
|
|
139
|
+
args: [],
|
|
140
|
+
flags: {},
|
|
141
|
+
summary: 'List Chat Apps in this workspace',
|
|
142
|
+
examples: ['frontera chat-app list', 'frontera chat-app list --json'],
|
|
143
|
+
},
|
|
144
|
+
async run(ctx) {
|
|
145
|
+
const rows = await chatApps(new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId))
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
data: rows,
|
|
149
|
+
text:
|
|
150
|
+
rows.length === 0
|
|
151
|
+
? 'No Chat Apps in this workspace. Create one with `frontera chat-app create <slug> --name <name>`.'
|
|
152
|
+
: table(
|
|
153
|
+
['slug', 'name', 'live', 'status', 'id'],
|
|
154
|
+
rows.map((a) => [
|
|
155
|
+
a.slug ?? '?',
|
|
156
|
+
a.displayName ?? '',
|
|
157
|
+
a.deployedVersionId ? 'yes' : 'no',
|
|
158
|
+
a.status ?? '',
|
|
159
|
+
a.id ?? '',
|
|
160
|
+
]),
|
|
161
|
+
),
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const create: Command = {
|
|
167
|
+
meta: {
|
|
168
|
+
noun: 'chat-app',
|
|
169
|
+
verb: 'create',
|
|
170
|
+
args: [{ name: 'slug', required: true, description: 'lowercase letters, digits and hyphens' }],
|
|
171
|
+
flags: { name: 'string', description: 'string', accent: 'string' },
|
|
172
|
+
summary: 'Create a Chat App, or return the one that already has this slug',
|
|
173
|
+
examples: ['frontera chat-app create support-desk --name "Support Desk"'],
|
|
174
|
+
},
|
|
175
|
+
async run(ctx) {
|
|
176
|
+
const slug = ctx.positional[0]
|
|
177
|
+
if (!slug) throw new UsageError('missing <slug>', 'frontera chat-app create support-desk --name "Support Desk"')
|
|
178
|
+
if (!/^[a-z0-9-]+$/.test(slug) || slug.length > 60) {
|
|
179
|
+
throw new UsageError(`"${slug}" is not a valid slug`, 'lowercase letters, digits and hyphens, up to 60 — e.g. support-desk')
|
|
180
|
+
}
|
|
181
|
+
const displayName = flagString(ctx, 'name') ?? slug
|
|
182
|
+
const accent = flagString(ctx, 'accent')
|
|
183
|
+
if (accent && !ACCENTS.includes(accent)) {
|
|
184
|
+
throw new UsageError(`unknown accent "${accent}"`, `one of: ${ACCENTS.join(', ')}`)
|
|
185
|
+
}
|
|
186
|
+
const description = flagString(ctx, 'description')
|
|
187
|
+
|
|
188
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
189
|
+
const result = await api.ensureChatApp({
|
|
190
|
+
slug,
|
|
191
|
+
displayName,
|
|
192
|
+
...(description ? { description } : {}),
|
|
193
|
+
...(accent ? { accent } : {}),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// `ensure` matches on slug and kind, and the built-in Chat is a Chat App by
|
|
197
|
+
// kind — so its slug hands back its id, which no editor route will accept.
|
|
198
|
+
// Checked only on the `exists` path; a new App cannot be the built-in.
|
|
199
|
+
if (!result.created) {
|
|
200
|
+
const hit = ((await api.platformApps()) as ChatAppRow[]).find((a) => a.id === result.id)
|
|
201
|
+
if (hit?.systemKey) {
|
|
202
|
+
throw new CliError(`"${slug}" is the built-in Chat, not an editable Chat App`, {
|
|
203
|
+
code: 'CONFLICT',
|
|
204
|
+
hint: 'pick another slug — e.g. frontera chat-app create support-desk --name "Support Desk"',
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// `ensure` returns an existing App untouched, so presentation flags passed
|
|
210
|
+
// with its slug did nothing — say so rather than imply they landed.
|
|
211
|
+
const ignored = result.created ? [] : (['name', 'description', 'accent'] as const).filter((f) => flagString(ctx, f))
|
|
212
|
+
return {
|
|
213
|
+
data: result,
|
|
214
|
+
text: [
|
|
215
|
+
`${result.created ? 'created' : 'exists'} ${result.slug} ${result.id}`,
|
|
216
|
+
...(ignored.length
|
|
217
|
+
? [` exists — ${ignored.map((f) => `--${f}`).join(', ')} not applied; change presentation in the App editor`]
|
|
218
|
+
: []),
|
|
219
|
+
// A new App opens to the whole workspace but has no roster, so it cannot
|
|
220
|
+
// publish yet — the next step is the configuration, not the audience.
|
|
221
|
+
` next: frontera chat-app get ${result.slug} --json | jq .draft.configuration > app.json`,
|
|
222
|
+
].join('\n'),
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const get: Command = {
|
|
228
|
+
meta: {
|
|
229
|
+
noun: 'chat-app',
|
|
230
|
+
verb: 'get',
|
|
231
|
+
args: [{ name: 'app', required: true, description: 'slug or id, from `frontera chat-app list`' }],
|
|
232
|
+
flags: {},
|
|
233
|
+
summary: 'Show a Chat App: live version, draft findings, roster, audience and endpoints',
|
|
234
|
+
examples: [
|
|
235
|
+
'frontera chat-app get support-desk',
|
|
236
|
+
'frontera chat-app get support-desk --json | jq .draft.configuration > app.json',
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
async run(ctx) {
|
|
240
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
241
|
+
const state = (await api.chatAppEditor(await resolveChatAppRef(api, appRef(ctx, 'get')))) as EditorState
|
|
242
|
+
|
|
243
|
+
const findings = state.findings ?? []
|
|
244
|
+
const grants = state.grants ?? []
|
|
245
|
+
const lines = [
|
|
246
|
+
`${state.app?.displayName ?? state.app?.slug ?? '?'} (${state.app?.slug ?? ''})`,
|
|
247
|
+
` live: ${state.live ? `v${state.live.version}` : 'not published'}`,
|
|
248
|
+
` draft: ${state.draft?.saved ? `saved ${state.draft.updatedAt ?? ''}`.trim() : 'none saved — shows the live configuration'}`,
|
|
249
|
+
` access: ${
|
|
250
|
+
grants.length === 0
|
|
251
|
+
? 'nobody'
|
|
252
|
+
: grants.map((g) => (g.principalKind === 'workspace' ? 'workspace' : `${g.principalKind}:${g.principalId}`)).join(', ')
|
|
253
|
+
}`,
|
|
254
|
+
` endpoints: ${(state.endpoints ?? []).map((e) => `${e.kind} ${e.enabled ? 'on' : 'off'}`).join(', ') || 'defaults'}`,
|
|
255
|
+
'',
|
|
256
|
+
(state.agents ?? []).length === 0
|
|
257
|
+
? ' no Agents on the roster'
|
|
258
|
+
: table(
|
|
259
|
+
['agent', 'name', 'published'],
|
|
260
|
+
(state.agents ?? []).map((a) => [a.slug ?? '?', a.name ?? '', a.publishedVersion ? `v${a.publishedVersion}` : 'no']),
|
|
261
|
+
),
|
|
262
|
+
...(findings.length ? ['', 'findings:', ...findingLines(findings)] : []),
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
return { data: state, text: lines.join('\n') }
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const draft: Command = {
|
|
270
|
+
meta: {
|
|
271
|
+
noun: 'chat-app',
|
|
272
|
+
verb: 'draft',
|
|
273
|
+
args: [{ name: 'app', required: true, description: 'slug or id, from `frontera chat-app list`' }],
|
|
274
|
+
flags: { from: 'string' },
|
|
275
|
+
aliases: { f: 'from' },
|
|
276
|
+
summary: 'Stage a draft configuration from a JSON file, and report what blocks publishing',
|
|
277
|
+
examples: [
|
|
278
|
+
'frontera chat-app draft support-desk --from ./app.json',
|
|
279
|
+
'cat app.json | frontera chat-app draft support-desk --from -',
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
async run(ctx) {
|
|
283
|
+
const ref = appRef(ctx, 'draft')
|
|
284
|
+
const from = flagString(ctx, 'from')
|
|
285
|
+
if (!from) throw new UsageError('missing --from', 'pass `--from ./config.json`, or `--from -` for stdin')
|
|
286
|
+
const configuration = await readConfiguration(from)
|
|
287
|
+
|
|
288
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
289
|
+
const saved = (await api
|
|
290
|
+
.saveChatAppDraft(await resolveChatAppRef(api, ref), configuration)
|
|
291
|
+
.catch((err: unknown) => rethrowValidation(err, ref))) as {
|
|
292
|
+
findings?: Finding[]
|
|
293
|
+
updatedAt?: string
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const findings = saved.findings ?? []
|
|
297
|
+
const blocked = findings.some((f) => f.level === 'error')
|
|
298
|
+
return {
|
|
299
|
+
data: saved,
|
|
300
|
+
text: [
|
|
301
|
+
`draft saved${saved.updatedAt ? ` ${saved.updatedAt}` : ''}`,
|
|
302
|
+
...findingLines(findings),
|
|
303
|
+
blocked ? ' fix the errors above before publishing' : ` next: frontera chat-app publish ${ref}`,
|
|
304
|
+
].join('\n'),
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const publish: Command = {
|
|
310
|
+
meta: {
|
|
311
|
+
noun: 'chat-app',
|
|
312
|
+
verb: 'publish',
|
|
313
|
+
args: [{ name: 'app', required: true, description: 'slug or id, from `frontera chat-app list`' }],
|
|
314
|
+
flags: { from: 'string', validate: 'boolean' },
|
|
315
|
+
aliases: { f: 'from' },
|
|
316
|
+
summary: 'Publish the staged draft (or a file) as the live version',
|
|
317
|
+
examples: [
|
|
318
|
+
'frontera chat-app publish support-desk --validate',
|
|
319
|
+
'frontera chat-app publish support-desk',
|
|
320
|
+
'frontera chat-app publish support-desk --from ./app.json',
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
async run(ctx) {
|
|
324
|
+
const ref = appRef(ctx, 'publish')
|
|
325
|
+
const from = flagString(ctx, 'from')
|
|
326
|
+
const dryRun = flagBool(ctx, 'validate')
|
|
327
|
+
const configuration = from ? await readConfiguration(from) : undefined
|
|
328
|
+
|
|
329
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
330
|
+
const appId = await resolveChatAppRef(api, ref)
|
|
331
|
+
let result: { findings?: Finding[]; version?: { id?: string; version?: string } | null }
|
|
332
|
+
try {
|
|
333
|
+
result = (await api.publishChatApp(appId, {
|
|
334
|
+
...(configuration !== undefined ? { configuration } : {}),
|
|
335
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
336
|
+
})) as typeof result
|
|
337
|
+
} catch (err) {
|
|
338
|
+
rethrowValidation(err, ref, configuration === undefined)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const findings = result.findings ?? []
|
|
342
|
+
const text = dryRun
|
|
343
|
+
? [
|
|
344
|
+
findings.some((f) => f.level === 'error') ? 'not ready to publish' : 'ready to publish',
|
|
345
|
+
...findingLines(findings),
|
|
346
|
+
]
|
|
347
|
+
: [`published v${result.version?.version ?? '?'} ${result.version?.id ?? ''}`.trim(), ...findingLines(findings)]
|
|
348
|
+
return { data: result, text: text.join('\n') }
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const access: Command = {
|
|
353
|
+
meta: {
|
|
354
|
+
noun: 'chat-app',
|
|
355
|
+
verb: 'access',
|
|
356
|
+
args: [
|
|
357
|
+
{ name: 'app', required: true, description: 'slug or id, from `frontera chat-app list`' },
|
|
358
|
+
{
|
|
359
|
+
name: 'grants',
|
|
360
|
+
required: true,
|
|
361
|
+
description: 'workspace, user:ID or group:ID, one or more — replaces the audience',
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
flags: {},
|
|
365
|
+
summary: 'Replace who may open a Chat App',
|
|
366
|
+
examples: [
|
|
367
|
+
'frontera chat-app access support-desk workspace',
|
|
368
|
+
'frontera chat-app access help user:4b1f0c2e-8a7d-4c1b-9e2f-3a6d5c7b8e90',
|
|
369
|
+
'frontera chat-app access help group:9d3e2a1b-5c4f-4e6d-8b7a-0f1e2d3c4b5a',
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
async run(ctx) {
|
|
373
|
+
const ref = appRef(ctx, 'access')
|
|
374
|
+
const specs = ctx.positional.slice(1)
|
|
375
|
+
// No empty form: zero grants closes the App to everyone, which is too
|
|
376
|
+
// large an effect for an argument someone forgot.
|
|
377
|
+
if (specs.length === 0) {
|
|
378
|
+
throw new UsageError('missing <grants>', 'pass `workspace`, or `user:<id>` / `group:<id>` — user ids come from `frontera chat-app members <app>`')
|
|
379
|
+
}
|
|
380
|
+
const grants = specs.map((spec) => {
|
|
381
|
+
if (spec === 'workspace') return { principalKind: 'workspace' as const }
|
|
382
|
+
const at = spec.indexOf(':')
|
|
383
|
+
const kind = spec.slice(0, at)
|
|
384
|
+
const id = spec.slice(at + 1)
|
|
385
|
+
if (at > 0 && id && (kind === 'user' || kind === 'group')) {
|
|
386
|
+
return { principalKind: kind as 'user' | 'group', principalId: id }
|
|
387
|
+
}
|
|
388
|
+
throw new UsageError(`"${spec}" is not a grant`, 'use `workspace`, `user:<userId>` or `group:<groupId>`')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
392
|
+
const rows = (await api.setChatAppAccess(await resolveChatAppRef(api, ref), grants)) as Array<{
|
|
393
|
+
principalKind?: string
|
|
394
|
+
principalId?: string
|
|
395
|
+
}>
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
data: rows,
|
|
399
|
+
text: `access: ${
|
|
400
|
+
rows.map((g) => (g.principalKind === 'workspace' ? 'workspace' : `${g.principalKind}:${g.principalId}`)).join(', ') || 'nobody'
|
|
401
|
+
}`,
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const endpoint: Command = {
|
|
407
|
+
meta: {
|
|
408
|
+
noun: 'chat-app',
|
|
409
|
+
verb: 'endpoint',
|
|
410
|
+
args: [
|
|
411
|
+
{ name: 'app', required: true, description: 'slug or id, from `frontera chat-app list`' },
|
|
412
|
+
{ name: 'kind', required: true, description: 'platform, standalone or widget' },
|
|
413
|
+
{ name: 'state', required: true, description: 'on or off' },
|
|
414
|
+
],
|
|
415
|
+
flags: {},
|
|
416
|
+
summary: 'Turn one of a Chat App’s endpoints on or off — effective on the next request',
|
|
417
|
+
examples: [
|
|
418
|
+
'frontera chat-app endpoint support-desk standalone on',
|
|
419
|
+
'frontera chat-app endpoint support-desk widget off',
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
async run(ctx) {
|
|
423
|
+
const ref = appRef(ctx, 'endpoint')
|
|
424
|
+
const kind = ctx.positional[1]
|
|
425
|
+
const state = ctx.positional[2]
|
|
426
|
+
if (!kind || !(ENDPOINTS as readonly string[]).includes(kind)) {
|
|
427
|
+
throw new UsageError(kind ? `unknown endpoint "${kind}"` : 'missing <kind>', `one of: ${ENDPOINTS.join(', ')}`)
|
|
428
|
+
}
|
|
429
|
+
if (state !== 'on' && state !== 'off') {
|
|
430
|
+
throw new UsageError(state ? `"${state}" is not on or off` : 'missing <state>', `frontera chat-app endpoint ${ref} ${kind} on`)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
434
|
+
const row = (await api.setChatAppEndpoint(
|
|
435
|
+
await resolveChatAppRef(api, ref),
|
|
436
|
+
kind as (typeof ENDPOINTS)[number],
|
|
437
|
+
state === 'on',
|
|
438
|
+
)) as { kind?: string; enabled?: boolean }
|
|
439
|
+
|
|
440
|
+
return { data: row, text: `${row.kind ?? kind} ${row.enabled ? 'on' : 'off'}` }
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const members: Command = {
|
|
445
|
+
meta: {
|
|
446
|
+
noun: 'chat-app',
|
|
447
|
+
verb: 'members',
|
|
448
|
+
args: [{ name: 'app', required: true, description: 'slug or id, from `frontera chat-app list`' }],
|
|
449
|
+
flags: {},
|
|
450
|
+
summary: 'List workspace members — the user ids `chat-app access` takes',
|
|
451
|
+
examples: ['frontera chat-app members support-desk', 'frontera chat-app members support-desk --json'],
|
|
452
|
+
},
|
|
453
|
+
async run(ctx) {
|
|
454
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
455
|
+
const rows = (await api.chatAppMembers(await resolveChatAppRef(api, appRef(ctx, 'members')))) as Array<{
|
|
456
|
+
userId?: string
|
|
457
|
+
name?: string
|
|
458
|
+
email?: string
|
|
459
|
+
role?: string
|
|
460
|
+
}>
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
data: rows,
|
|
464
|
+
text:
|
|
465
|
+
rows.length === 0
|
|
466
|
+
? 'No members in this workspace.'
|
|
467
|
+
: table(
|
|
468
|
+
['name', 'email', 'role', 'grant'],
|
|
469
|
+
rows.map((m) => [m.name ?? '', m.email ?? '', m.role ?? '', `user:${m.userId ?? '?'}`]),
|
|
470
|
+
),
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export const chatAppCommands: Command[] = [list, create, get, draft, publish, access, endpoint, members]
|
|
@@ -37,8 +37,8 @@ export function extensionOf(path: string): string {
|
|
|
37
37
|
return dot === -1 ? '' : path.slice(dot).toLowerCase()
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function isAccepted(path: string): boolean {
|
|
41
|
-
return
|
|
40
|
+
export function isAccepted(path: string, accepted: readonly string[] = ACCEPTED_EXTENSIONS): boolean {
|
|
41
|
+
return accepted.includes(extensionOf(path))
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Content type for a path. Unknown extensions never reach here. */
|
|
@@ -50,6 +50,8 @@ export interface PlannedFile {
|
|
|
50
50
|
path: string
|
|
51
51
|
/** Why it is in the plan — decides how a rejection is reported. */
|
|
52
52
|
source: 'named' | 'scanned'
|
|
53
|
+
/** For a scanned file, the directory the caller named that it was found under. */
|
|
54
|
+
root?: string
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface UploadPlan {
|
|
@@ -69,16 +71,20 @@ export interface UploadPlan {
|
|
|
69
71
|
* make directory upload unusable on any real machine. Both outcomes are
|
|
70
72
|
* reported — the skipped count is printed, never silent.
|
|
71
73
|
*/
|
|
72
|
-
export function planUploads(
|
|
74
|
+
export function planUploads(
|
|
75
|
+
paths: string[],
|
|
76
|
+
/** The extensions the destination accepts. Knowledge's by default; Media Sets pass theirs. */
|
|
77
|
+
accepted: readonly string[] = ACCEPTED_EXTENSIONS,
|
|
78
|
+
): UploadPlan {
|
|
73
79
|
const files: PlannedFile[] = []
|
|
74
80
|
const rejected: Array<{ path: string; reason: string }> = []
|
|
75
81
|
let skippedByType = 0
|
|
76
82
|
const seen = new Set<string>()
|
|
77
83
|
|
|
78
|
-
const push = (path: string, source: PlannedFile['source']): void => {
|
|
84
|
+
const push = (path: string, source: PlannedFile['source'], root?: string): void => {
|
|
79
85
|
if (seen.has(path)) return
|
|
80
86
|
seen.add(path)
|
|
81
|
-
files.push({ path, source })
|
|
87
|
+
files.push(root === undefined ? { path, source } : { path, source, root })
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
for (const path of paths) {
|
|
@@ -92,16 +98,16 @@ export function planUploads(paths: string[]): UploadPlan {
|
|
|
92
98
|
|
|
93
99
|
if (info.isDirectory()) {
|
|
94
100
|
for (const found of walk(path)) {
|
|
95
|
-
if (isAccepted(found)) push(found, 'scanned')
|
|
101
|
+
if (isAccepted(found, accepted)) push(found, 'scanned', path)
|
|
96
102
|
else skippedByType += 1
|
|
97
103
|
}
|
|
98
104
|
continue
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
if (!isAccepted(path)) {
|
|
107
|
+
if (!isAccepted(path, accepted)) {
|
|
102
108
|
rejected.push({
|
|
103
109
|
path,
|
|
104
|
-
reason: `unsupported type — accepted: ${
|
|
110
|
+
reason: `unsupported type — accepted: ${accepted.join(', ')}`,
|
|
105
111
|
})
|
|
106
112
|
continue
|
|
107
113
|
}
|