@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.
Files changed (37) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -4
  3. package/src/api/blueprint-authoring-api.ts +52 -0
  4. package/src/api/credential-failure.ts +34 -4
  5. package/src/api/dataset-api.ts +1 -1
  6. package/src/api/governed-action-api.ts +75 -7
  7. package/src/api/media-api.ts +181 -0
  8. package/src/api/platform-api.ts +59 -0
  9. package/src/api/workflow-api.ts +1 -1
  10. package/src/auth-verify.ts +1 -1
  11. package/src/commands/action/available.ts +59 -0
  12. package/src/commands/action/cancel.ts +34 -0
  13. package/src/commands/action/decide.ts +54 -0
  14. package/src/commands/action/deploy.ts +4 -1
  15. package/src/commands/action/index-commands.ts +16 -6
  16. package/src/commands/action/prepare.ts +3 -1
  17. package/src/commands/action/request-summary.ts +13 -0
  18. package/src/commands/action/requests.ts +6 -5
  19. package/src/commands/action/submit.ts +183 -0
  20. package/src/commands/blueprint/get.ts +139 -29
  21. package/src/commands/chat-app/index-commands.ts +475 -0
  22. package/src/commands/knowledge/upload-plan.ts +14 -8
  23. package/src/commands/marking/index-commands.ts +171 -0
  24. package/src/commands/media/index-commands.ts +592 -0
  25. package/src/commands/registry.ts +28 -6
  26. package/src/commands/types.ts +6 -0
  27. package/src/commands/workflow/list.ts +1 -1
  28. package/src/errors.ts +3 -2
  29. package/src/exit.ts +126 -1
  30. package/src/flag-help.ts +24 -2
  31. package/src/forge/touches.ts +2 -0
  32. package/src/harness.ts +5 -3
  33. package/src/main.ts +23 -38
  34. package/src/scopes.ts +43 -0
  35. package/src/template.ts +6 -4
  36. package/src/templates/next-app-files.ts +8 -4
  37. 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 (ACCEPTED_EXTENSIONS as readonly string[]).includes(extensionOf(path))
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(paths: string[]): UploadPlan {
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: ${ACCEPTED_EXTENSIONS.join(', ')}`,
110
+ reason: `unsupported type — accepted: ${accepted.join(', ')}`,
105
111
  })
106
112
  continue
107
113
  }