@frontera-sdk/cli 0.1.0 → 1.43.6

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 (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. package/src/commands/blueprint/reserved.ts +0 -40
@@ -1,7 +1,10 @@
1
1
  import { PlatformApi } from '../../api/platform-api'
2
2
  import { CliError, UsageError } from '../../errors'
3
3
  import { table } from '../../table'
4
- import type { Command } from '../types'
4
+ import { resolveAgentRef } from '../agent/resolve'
5
+ import { flagString, type Command } from '../types'
6
+ import { renderUploadSummary, summarizeUploads, uploadAll } from './upload-batch'
7
+ import { planUploads } from './upload-plan'
5
8
 
6
9
  interface KnowledgeRow {
7
10
  id?: string
@@ -10,19 +13,25 @@ interface KnowledgeRow {
10
13
  description?: string
11
14
  }
12
15
 
13
- /** Accept a base by name or id `knowledge list` shows both. */
14
- async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
15
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
16
- if (UUID.test(ref)) return ref
17
-
18
- const me = await client.whoami()
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
19
  if (!me.workspaceId) {
20
20
  throw new CliError('this credential is not scoped to a workspace', {
21
21
  code: 'FORBIDDEN',
22
22
  hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
23
23
  })
24
24
  }
25
- const rows = (await client.knowledgeBases(me.workspaceId)) as KnowledgeRow[]
25
+ return me.workspaceId
26
+ }
27
+
28
+ /** Accept a base by name or id — `knowledge list` shows both. */
29
+ async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
30
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
31
+ if (UUID.test(ref)) return ref
32
+
33
+ const workspaceId = await requireWorkspaceId(client)
34
+ const rows = (await client.knowledgeBases(workspaceId)) as KnowledgeRow[]
26
35
  const match = rows.find((k) => (k.name ?? '').toLowerCase() === ref.toLowerCase())
27
36
  if (match?.id) return match.id
28
37
 
@@ -45,14 +54,8 @@ const list: Command = {
45
54
  const api = new PlatformApi(ctx.apiUrl, ctx.token)
46
55
  // The route takes the workspace explicitly, and a key holder cannot see
47
56
  // its own workspace id from anywhere else — hence /v1/whoami.
48
- const me = await api.whoami()
49
- if (!me.workspaceId) {
50
- throw new CliError('this credential is not scoped to a workspace', {
51
- code: 'FORBIDDEN',
52
- hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
53
- })
54
- }
55
- const rows = (await api.knowledgeBases(me.workspaceId)) as KnowledgeRow[]
57
+ const workspaceId = await requireWorkspaceId(api)
58
+ const rows = (await api.knowledgeBases(workspaceId)) as KnowledgeRow[]
56
59
  return {
57
60
  data: rows,
58
61
  text:
@@ -108,20 +111,259 @@ const sources: Command = {
108
111
  },
109
112
  }
110
113
 
114
+ const create: Command = {
115
+ meta: {
116
+ noun: 'knowledge',
117
+ verb: 'create',
118
+ args: [{ name: 'name', required: true, description: 'base name — lowercase, the value every other knowledge command takes' }],
119
+ flags: { description: 'string' },
120
+ summary: 'Create a knowledge base',
121
+ examples: [
122
+ 'frontera knowledge create logistics-policies',
123
+ 'frontera knowledge create policies --description "SOPs and tariffs"',
124
+ ],
125
+ },
126
+ async run(ctx) {
127
+ const name = ctx.positional[0]
128
+ if (!name) {
129
+ throw new UsageError('missing <name>', 'frontera knowledge create <name> [--description <text>]')
130
+ }
131
+
132
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
133
+ const workspaceId = await requireWorkspaceId(api)
134
+
135
+ // Checked here rather than left to the unique constraint: the service
136
+ // answers a duplicate with a bare CONFLICT, whose generic hint ("resolve
137
+ // what conflicted") sends a caller looking for a race that did not happen.
138
+ const existing = (await api.knowledgeBases(workspaceId)) as KnowledgeRow[]
139
+ if (existing.some((k) => (k.name ?? '').toLowerCase() === name.toLowerCase())) {
140
+ throw new CliError(`a knowledge base named "${name}" already exists`, {
141
+ code: 'CONFLICT',
142
+ hint: `upload into it with \`frontera knowledge upload ${name} <path…>\``,
143
+ })
144
+ }
145
+
146
+ const description = flagString(ctx, 'description')
147
+ const created = (await api.createKnowledgeBase(
148
+ workspaceId,
149
+ description ? { name, description } : { name },
150
+ )) as { data?: KnowledgeRow } | KnowledgeRow
151
+ const row = ((created as { data?: KnowledgeRow }).data ?? created) as KnowledgeRow
152
+
153
+ return {
154
+ data: row,
155
+ text: [
156
+ `created ${row.name ?? name}${row.id ? ` ${row.id}` : ''}`,
157
+ ` next: frontera knowledge upload ${row.name ?? name} <path…>`,
158
+ ].join('\n'),
159
+ }
160
+ },
161
+ }
162
+
163
+ const upload: Command = {
164
+ meta: {
165
+ noun: 'knowledge',
166
+ verb: 'upload',
167
+ args: [
168
+ { name: 'base', required: true, description: 'knowledge base name or id' },
169
+ { name: 'path...', required: true, description: 'files or directories; directories are walked, unsupported types skipped' },
170
+ ],
171
+ flags: { strategy: 'string' },
172
+ summary: 'Upload files into a knowledge base',
173
+ examples: [
174
+ 'frontera knowledge upload logistics-policies ./policies',
175
+ 'frontera knowledge upload logistics-policies tariffs.pdf sop.md',
176
+ 'frontera knowledge upload logistics-policies ./scans --strategy ocr',
177
+ ],
178
+ },
179
+ async run(ctx) {
180
+ const [ref, ...paths] = ctx.positional
181
+ if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
182
+ if (paths.length === 0) {
183
+ throw new UsageError(
184
+ 'missing <path...>',
185
+ 'frontera knowledge upload <base> <file-or-directory…>',
186
+ )
187
+ }
188
+
189
+ const strategy = flagString(ctx, 'strategy')
190
+ if (strategy && !['auto', 'text', 'ocr'].includes(strategy)) {
191
+ throw new UsageError(
192
+ `unknown --strategy "${strategy}"`,
193
+ 'auto (default), text, or ocr — ocr needs an OCR engine configured on the base',
194
+ )
195
+ }
196
+
197
+ const plan = planUploads(paths)
198
+ if (plan.rejected.length > 0) {
199
+ // A path the caller NAMED and cannot be uploaded is a mistake in the
200
+ // command, so nothing is uploaded — a batch half-run on a typo leaves the
201
+ // base in a state the caller did not ask for and cannot infer.
202
+ const first = plan.rejected[0]!
203
+ throw new UsageError(
204
+ `cannot upload ${first.path}: ${first.reason}`,
205
+ plan.rejected.length > 1
206
+ ? `${plan.rejected.length} named paths are unusable — fix them, or pass a directory and let unsupported types be skipped`
207
+ : 'fix the path, or pass a directory and let unsupported types be skipped',
208
+ )
209
+ }
210
+ if (plan.files.length === 0) {
211
+ throw new CliError('nothing to upload', {
212
+ code: 'USAGE',
213
+ hint:
214
+ plan.skippedByType > 0
215
+ ? `${plan.skippedByType} file(s) found, none of a supported type`
216
+ : 'the paths given contain no files',
217
+ })
218
+ }
219
+
220
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
221
+ const baseId = await resolveKnowledgeRef(api, ref)
222
+ const outcomes = await uploadAll(api, baseId, plan.files, strategy)
223
+ const summary = summarizeUploads(ref, baseId, outcomes, plan)
224
+
225
+ // Nothing landed: the mechanism is wrong, not the batch. Fail so the exit
226
+ // code says so, and name the first cause rather than a count.
227
+ if (summary.uploaded === 0) {
228
+ const first = outcomes.find((o) => o.status === 'failed')
229
+ throw new CliError(`all ${summary.failed} upload(s) failed: ${first?.error ?? 'unknown error'}`, {
230
+ code: 'FAILURE',
231
+ hint: 'fix the cause above and re-run — uploading is additive, so a retry duplicates nothing already ingested',
232
+ })
233
+ }
234
+
235
+ return { data: summary, text: renderUploadSummary(summary) }
236
+ },
237
+ }
238
+
239
+ const attach: Command = {
240
+ meta: {
241
+ noun: 'knowledge',
242
+ verb: 'attach',
243
+ args: [
244
+ { name: 'base', required: true, description: 'knowledge base name or id' },
245
+ { name: 'agent', required: true, description: 'agent slug or id, from `frontera agent list`' },
246
+ ],
247
+ flags: {},
248
+ summary: 'Give an agent access to a knowledge base',
249
+ examples: ['frontera knowledge attach logistics-policies ava'],
250
+ },
251
+ async run(ctx) {
252
+ const [baseRef, agentRef] = ctx.positional
253
+ if (!baseRef) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
254
+ if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
255
+
256
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
257
+ const baseId = await resolveKnowledgeRef(api, baseRef)
258
+ const agentId = await resolveAgentRef(api, agentRef)
259
+ await api.attachKnowledgeAgent(baseId, agentId)
260
+
261
+ return {
262
+ data: { baseId, agentId, attached: true },
263
+ text: [
264
+ `attached ${baseRef} to ${agentRef}`,
265
+ // Attachment is what makes knowledge reachable — including from an
266
+ // automation, which has no knowledge capability of its own and reads a
267
+ // corpus only through an agent that has one attached.
268
+ ` verify: frontera agent get ${agentRef}`,
269
+ ].join('\n'),
270
+ }
271
+ },
272
+ }
273
+
274
+ const detach: Command = {
275
+ meta: {
276
+ noun: 'knowledge',
277
+ verb: 'detach',
278
+ args: [
279
+ { name: 'base', required: true, description: 'knowledge base name or id' },
280
+ { name: 'agent', required: true, description: 'agent slug or id' },
281
+ ],
282
+ flags: {},
283
+ summary: 'Take an agent’s access to a knowledge base away',
284
+ examples: ['frontera knowledge detach logistics-policies ava'],
285
+ },
286
+ async run(ctx) {
287
+ const [baseRef, agentRef] = ctx.positional
288
+ if (!baseRef) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
289
+ if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
290
+
291
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
292
+ const baseId = await resolveKnowledgeRef(api, baseRef)
293
+ const agentId = await resolveAgentRef(api, agentRef)
294
+ await api.detachKnowledgeAgent(baseId, agentId)
295
+
296
+ return {
297
+ data: { baseId, agentId, attached: false },
298
+ // Said explicitly because the reverse — deleting the corpus — is what a
299
+ // reader might assume from a word like detach.
300
+ text: `detached ${baseRef} from ${agentRef} — the base and its sources are unchanged`,
301
+ }
302
+ },
303
+ }
304
+
305
+ const agents: Command = {
306
+ meta: {
307
+ noun: 'knowledge',
308
+ verb: 'agents',
309
+ args: [{ name: 'base', required: true, description: 'knowledge base name or id' }],
310
+ flags: {},
311
+ summary: 'List the agents that can read one knowledge base',
312
+ examples: ['frontera knowledge agents logistics-policies'],
313
+ },
314
+ async run(ctx) {
315
+ const ref = ctx.positional[0]
316
+ if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
317
+
318
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
319
+ const baseId = await resolveKnowledgeRef(api, ref)
320
+ const rows = (await api.knowledgeAttachments(baseId)) as Array<Record<string, unknown>>
321
+
322
+ // The attachment row carries only `agentId`, so the slug — the value every
323
+ // other agent command takes — has to be looked up. Worth one extra request:
324
+ // a table of bare uuids cannot be acted on without a second command.
325
+ const slugs = new Map<string, string>()
326
+ if (rows.length > 0) {
327
+ const agentRows = (await api.agents().catch(() => [])) as Array<{
328
+ id?: string
329
+ agentId?: string
330
+ name?: string
331
+ }>
332
+ for (const a of agentRows) {
333
+ if (a.id && (a.agentId || a.name)) slugs.set(a.id, a.agentId ?? a.name ?? '')
334
+ }
335
+ }
336
+
337
+ return {
338
+ data: rows,
339
+ text:
340
+ rows.length === 0
341
+ ? `No agent can read ${ref} — attach one with \`frontera knowledge attach ${ref} <agent>\`.`
342
+ : table(
343
+ ['agent', 'id'],
344
+ rows.map((r) => {
345
+ const id = String(r.agentId ?? '')
346
+ // An agent still in `configuring` is absent from `agent list`,
347
+ // so print the id rather than a "?" that reads as corruption.
348
+ return [slugs.get(id) ?? id, id]
349
+ }),
350
+ ),
351
+ }
352
+ },
353
+ }
354
+
111
355
  /**
112
- * `upload` is the strongest case for this noun existing at all — nobody wants
113
- * to drag two hundred PDFs into a browser — and is reserved rather than
114
- * half-built: it needs per-file outcome reporting so one unreadable file does
115
- * not fail a batch, which is the whole reason to prefer it over the UI.
356
+ * `delete` stays reserved.
357
+ *
358
+ * Deleting a base destroys its chunks and every agent attachment with it, and
359
+ * the route takes no confirmation of what is about to be lost so the CLI
360
+ * would be the easiest way to do the most damage. It waits for the count of
361
+ * what will go with it.
116
362
  */
117
- const RESERVED = 'knowledge writes are not available in this release; list and sources are read-only'
363
+ const RESERVED = 'deleting a knowledge base is not available in this release; delete it from the Console'
118
364
 
119
365
  const reserved: Command[] = (
120
- [
121
- ['create', 'Create a knowledge base'],
122
- ['upload', 'Upload files into a knowledge base'],
123
- ['delete', 'Delete a knowledge base'],
124
- ] as const
366
+ [['delete', 'Delete a knowledge base']] as const
125
367
  ).map(([verb, summary]) => ({
126
368
  meta: {
127
369
  noun: 'knowledge',
@@ -137,4 +379,13 @@ const reserved: Command[] = (
137
379
  },
138
380
  }))
139
381
 
140
- export const knowledgeCommands: Command[] = [list, sources, ...reserved]
382
+ export const knowledgeCommands: Command[] = [
383
+ list,
384
+ sources,
385
+ create,
386
+ upload,
387
+ attach,
388
+ detach,
389
+ agents,
390
+ ...reserved,
391
+ ]
@@ -0,0 +1,146 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { table } from '../../table'
4
+ import { mimeFor, type PlannedFile, type UploadPlan } from './upload-plan'
5
+
6
+ export interface UploadOutcome {
7
+ file: string
8
+ status: 'uploaded' | 'failed'
9
+ sourceId?: string
10
+ error?: string
11
+ }
12
+
13
+ /** What `uploadAll` needs from `PlatformApi`, so a test can supply it. */
14
+ export interface SourceUploader {
15
+ uploadKnowledgeSource(
16
+ baseId: string,
17
+ file: { bytes: Uint8Array; filename: string; mime: string },
18
+ opts?: { extractionStrategy?: string },
19
+ ): Promise<{ id?: string; status?: string }>
20
+ }
21
+
22
+ /**
23
+ * Four at a time.
24
+ *
25
+ * Each upload is a whole file over the wire followed by an S3 put, so serial
26
+ * uploads make a 200-file seed minutes of mostly waiting. Four keeps the burst
27
+ * small enough that a wrong credential or a spent quota shows up in a short
28
+ * report rather than after 200 attempts.
29
+ */
30
+ export const UPLOAD_CONCURRENCY = 4
31
+
32
+ export function basename(path: string): string {
33
+ const slash = path.lastIndexOf('/')
34
+ return slash === -1 ? path : path.slice(slash + 1)
35
+ }
36
+
37
+ /**
38
+ * Upload every planned file, and report each one.
39
+ *
40
+ * A failure NEVER ends the batch — that is the whole reason to prefer this over
41
+ * the browser upload it replaces, where one unreadable file in two hundred
42
+ * meant starting over. Outcomes come back in plan order regardless of the order
43
+ * they finished in, so two runs of the same directory are comparable.
44
+ */
45
+ export async function uploadAll(
46
+ api: SourceUploader,
47
+ baseId: string,
48
+ files: PlannedFile[],
49
+ strategy?: string,
50
+ readBytes: (path: string) => Uint8Array = (p) => new Uint8Array(readFileSync(p)),
51
+ ): Promise<UploadOutcome[]> {
52
+ const outcomes: UploadOutcome[] = new Array(files.length)
53
+ let next = 0
54
+
55
+ const worker = async (): Promise<void> => {
56
+ for (;;) {
57
+ const index = next++
58
+ const planned = files[index]
59
+ if (!planned) return
60
+ try {
61
+ const source = await api.uploadKnowledgeSource(
62
+ baseId,
63
+ {
64
+ bytes: readBytes(planned.path),
65
+ filename: basename(planned.path),
66
+ mime: mimeFor(planned.path),
67
+ },
68
+ strategy ? { extractionStrategy: strategy } : {},
69
+ )
70
+ outcomes[index] = {
71
+ file: planned.path,
72
+ status: 'uploaded',
73
+ ...(source.id ? { sourceId: source.id } : {}),
74
+ }
75
+ } catch (err) {
76
+ outcomes[index] = {
77
+ file: planned.path,
78
+ status: 'failed',
79
+ error: err instanceof Error ? err.message : String(err),
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ await Promise.all(
86
+ Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => worker()),
87
+ )
88
+ return outcomes
89
+ }
90
+
91
+ export interface UploadSummary {
92
+ base: string
93
+ baseId: string
94
+ uploaded: number
95
+ failed: number
96
+ skippedByType: number
97
+ sources: UploadOutcome[]
98
+ }
99
+
100
+ export function summarizeUploads(
101
+ base: string,
102
+ baseId: string,
103
+ outcomes: UploadOutcome[],
104
+ plan: UploadPlan,
105
+ ): UploadSummary {
106
+ return {
107
+ base,
108
+ baseId,
109
+ uploaded: outcomes.filter((o) => o.status === 'uploaded').length,
110
+ failed: outcomes.filter((o) => o.status === 'failed').length,
111
+ skippedByType: plan.skippedByType,
112
+ sources: outcomes,
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The text a person reads.
118
+ *
119
+ * Failures lead. A partial batch exits 0 — the per-file report IS the useful
120
+ * answer, and a non-zero exit tells a caller not to parse stdout at all — so
121
+ * the count of what did not land has to be the first thing on the page rather
122
+ * than a column someone has to notice.
123
+ */
124
+ export function renderUploadSummary(summary: UploadSummary): string {
125
+ const lines: string[] = []
126
+ const total = summary.sources.length
127
+
128
+ if (summary.failed > 0) lines.push(`${summary.failed} of ${total} FAILED — see below`)
129
+ lines.push(
130
+ `uploaded ${summary.uploaded} file(s) into ${summary.base}` +
131
+ (summary.skippedByType > 0
132
+ ? `, skipped ${summary.skippedByType} of an unsupported type`
133
+ : ''),
134
+ )
135
+ lines.push(
136
+ table(
137
+ ['file', 'status', 'detail'],
138
+ summary.sources.map((o) => [basename(o.file), o.status, o.error ?? o.sourceId ?? '']),
139
+ [50, undefined, 60],
140
+ ),
141
+ )
142
+ // Ingestion is queued, not done: chunk counts appear minutes later, and a
143
+ // caller that tests retrieval immediately concludes the upload failed.
144
+ lines.push(` ingestion runs asynchronously — frontera knowledge sources ${summary.base}`)
145
+ return lines.join('\n')
146
+ }
@@ -0,0 +1,127 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Which file types the ingestion pipeline accepts.
6
+ *
7
+ * A mirror of `packages/service/src/internal/knowledge/accepted-file-types.ts`,
8
+ * which stays the authority — this exists so a 200-file directory does not
9
+ * become 200 rejected requests, and so an explicitly named `.zip` fails before
10
+ * anything is uploaded. Drift makes the CLI stricter or chattier, never more
11
+ * permissive: the service re-checks every file it is sent.
12
+ */
13
+ export const ACCEPTED_EXTENSIONS = [
14
+ '.pdf',
15
+ '.docx',
16
+ '.xlsx',
17
+ '.xls',
18
+ '.md',
19
+ '.markdown',
20
+ '.txt',
21
+ '.csv',
22
+ ] as const
23
+
24
+ const MIME_BY_EXTENSION: Record<string, string> = {
25
+ '.pdf': 'application/pdf',
26
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
27
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
28
+ '.xls': 'application/vnd.ms-excel',
29
+ '.md': 'text/markdown',
30
+ '.markdown': 'text/markdown',
31
+ '.txt': 'text/plain',
32
+ '.csv': 'text/csv',
33
+ }
34
+
35
+ export function extensionOf(path: string): string {
36
+ const dot = path.lastIndexOf('.')
37
+ return dot === -1 ? '' : path.slice(dot).toLowerCase()
38
+ }
39
+
40
+ export function isAccepted(path: string): boolean {
41
+ return (ACCEPTED_EXTENSIONS as readonly string[]).includes(extensionOf(path))
42
+ }
43
+
44
+ /** Content type for a path. Unknown extensions never reach here. */
45
+ export function mimeFor(path: string): string {
46
+ return MIME_BY_EXTENSION[extensionOf(path)] ?? 'application/octet-stream'
47
+ }
48
+
49
+ export interface PlannedFile {
50
+ path: string
51
+ /** Why it is in the plan — decides how a rejection is reported. */
52
+ source: 'named' | 'scanned'
53
+ }
54
+
55
+ export interface UploadPlan {
56
+ files: PlannedFile[]
57
+ /** Named paths that cannot be uploaded, with the reason. */
58
+ rejected: Array<{ path: string; reason: string }>
59
+ /** Count of scanned files skipped for their type. Not an error. */
60
+ skippedByType: number
61
+ }
62
+
63
+ /**
64
+ * Turn the paths a caller named into the files to upload.
65
+ *
66
+ * A named path and a scanned one are treated differently on purpose. Naming
67
+ * `policy.zip` is a mistake worth failing on; finding `.DS_Store` while walking
68
+ * a directory the caller pointed at is not, and failing the run for it would
69
+ * make directory upload unusable on any real machine. Both outcomes are
70
+ * reported — the skipped count is printed, never silent.
71
+ */
72
+ export function planUploads(paths: string[]): UploadPlan {
73
+ const files: PlannedFile[] = []
74
+ const rejected: Array<{ path: string; reason: string }> = []
75
+ let skippedByType = 0
76
+ const seen = new Set<string>()
77
+
78
+ const push = (path: string, source: PlannedFile['source']): void => {
79
+ if (seen.has(path)) return
80
+ seen.add(path)
81
+ files.push({ path, source })
82
+ }
83
+
84
+ for (const path of paths) {
85
+ let info: ReturnType<typeof statSync>
86
+ try {
87
+ info = statSync(path)
88
+ } catch {
89
+ rejected.push({ path, reason: 'no such file or directory' })
90
+ continue
91
+ }
92
+
93
+ if (info.isDirectory()) {
94
+ for (const found of walk(path)) {
95
+ if (isAccepted(found)) push(found, 'scanned')
96
+ else skippedByType += 1
97
+ }
98
+ continue
99
+ }
100
+
101
+ if (!isAccepted(path)) {
102
+ rejected.push({
103
+ path,
104
+ reason: `unsupported type — accepted: ${ACCEPTED_EXTENSIONS.join(', ')}`,
105
+ })
106
+ continue
107
+ }
108
+ push(path, 'named')
109
+ }
110
+
111
+ // Sorted so a re-run reports in the same order, which is what makes two runs
112
+ // comparable at all.
113
+ files.sort((a, b) => a.path.localeCompare(b.path))
114
+ return { files, rejected, skippedByType }
115
+ }
116
+
117
+ /** Depth-first walk, dotfiles and dot-directories excluded. */
118
+ function walk(dir: string): string[] {
119
+ const out: string[] = []
120
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
121
+ if (entry.name.startsWith('.')) continue
122
+ const path = join(dir, entry.name)
123
+ if (entry.isDirectory()) out.push(...walk(path))
124
+ else if (entry.isFile()) out.push(path)
125
+ }
126
+ return out
127
+ }