@transloadit/node 4.3.0 → 4.3.1
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 +58 -4
- package/dist/Transloadit.d.ts +26 -4
- package/dist/Transloadit.d.ts.map +1 -1
- package/dist/Transloadit.js +82 -21
- package/dist/Transloadit.js.map +1 -1
- package/dist/alphalib/goldenTemplates.d.ts +52 -0
- package/dist/alphalib/goldenTemplates.d.ts.map +1 -0
- package/dist/alphalib/goldenTemplates.js +46 -0
- package/dist/alphalib/goldenTemplates.js.map +1 -0
- package/dist/inputFiles.d.ts +41 -0
- package/dist/inputFiles.d.ts.map +1 -0
- package/dist/inputFiles.js +214 -0
- package/dist/inputFiles.js.map +1 -0
- package/dist/robots.d.ts +38 -0
- package/dist/robots.d.ts.map +1 -0
- package/dist/robots.js +230 -0
- package/dist/robots.js.map +1 -0
- package/dist/tus.d.ts +5 -1
- package/dist/tus.d.ts.map +1 -1
- package/dist/tus.js +80 -6
- package/dist/tus.js.map +1 -1
- package/package.json +1 -1
- package/src/Transloadit.ts +131 -26
- package/src/alphalib/goldenTemplates.ts +53 -0
- package/src/inputFiles.ts +278 -0
- package/src/robots.ts +317 -0
- package/src/tus.ts +91 -5
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { isIP } from 'node:net'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { basename, join } from 'node:path'
|
|
6
|
+
import type { Readable } from 'node:stream'
|
|
7
|
+
import { pipeline } from 'node:stream/promises'
|
|
8
|
+
import got from 'got'
|
|
9
|
+
import type { Input as IntoStreamInput } from 'into-stream'
|
|
10
|
+
import type { CreateAssemblyParams } from './apiTypes.ts'
|
|
11
|
+
|
|
12
|
+
export type InputFile =
|
|
13
|
+
| {
|
|
14
|
+
kind: 'path'
|
|
15
|
+
field: string
|
|
16
|
+
path: string
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
kind: 'base64'
|
|
20
|
+
field: string
|
|
21
|
+
base64: string
|
|
22
|
+
filename: string
|
|
23
|
+
contentType?: string
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
kind: 'url'
|
|
27
|
+
field: string
|
|
28
|
+
url: string
|
|
29
|
+
filename?: string
|
|
30
|
+
contentType?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type UploadInput = Readable | IntoStreamInput
|
|
34
|
+
|
|
35
|
+
export type Base64Strategy = 'buffer' | 'tempfile'
|
|
36
|
+
export type UrlStrategy = 'import' | 'download' | 'import-if-present'
|
|
37
|
+
|
|
38
|
+
export type PrepareInputFilesOptions = {
|
|
39
|
+
inputFiles?: InputFile[]
|
|
40
|
+
params?: CreateAssemblyParams
|
|
41
|
+
fields?: Record<string, unknown>
|
|
42
|
+
base64Strategy?: Base64Strategy
|
|
43
|
+
urlStrategy?: UrlStrategy
|
|
44
|
+
maxBase64Bytes?: number
|
|
45
|
+
allowPrivateUrls?: boolean
|
|
46
|
+
tempDir?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type PrepareInputFilesResult = {
|
|
50
|
+
params: CreateAssemblyParams
|
|
51
|
+
files: Record<string, string>
|
|
52
|
+
uploads: Record<string, UploadInput>
|
|
53
|
+
cleanup: Array<() => Promise<void>>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
57
|
+
typeof value === 'object' && value !== null
|
|
58
|
+
|
|
59
|
+
const ensureUnique = (field: string, used: Set<string>): void => {
|
|
60
|
+
if (used.has(field)) {
|
|
61
|
+
throw new Error(`Duplicate file field: ${field}`)
|
|
62
|
+
}
|
|
63
|
+
used.add(field)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ensureUniqueStepName = (baseName: string, used: Set<string>): string => {
|
|
67
|
+
let name = baseName
|
|
68
|
+
let counter = 1
|
|
69
|
+
while (used.has(name)) {
|
|
70
|
+
name = `${baseName}_${counter}`
|
|
71
|
+
counter += 1
|
|
72
|
+
}
|
|
73
|
+
used.add(name)
|
|
74
|
+
return name
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const decodeBase64 = (value: string): Buffer => Buffer.from(value, 'base64')
|
|
78
|
+
|
|
79
|
+
const estimateBase64DecodedBytes = (value: string): number => {
|
|
80
|
+
const trimmed = value.trim()
|
|
81
|
+
if (!trimmed) return 0
|
|
82
|
+
let padding = 0
|
|
83
|
+
if (trimmed.endsWith('==')) padding = 2
|
|
84
|
+
else if (trimmed.endsWith('=')) padding = 1
|
|
85
|
+
return Math.floor((trimmed.length * 3) / 4) - padding
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const getFilenameFromUrl = (value: string): string | null => {
|
|
89
|
+
try {
|
|
90
|
+
const pathname = new URL(value).pathname
|
|
91
|
+
const base = basename(pathname)
|
|
92
|
+
if (base && base !== '/' && base !== '.') return base
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isHttpImportStep = (value: unknown): value is Record<string, unknown> =>
|
|
100
|
+
isRecord(value) && value.robot === '/http/import'
|
|
101
|
+
|
|
102
|
+
const findImportStepName = (field: string, steps: Record<string, unknown>): string | null => {
|
|
103
|
+
if (isHttpImportStep(steps[field])) return field
|
|
104
|
+
const matches = Object.entries(steps).filter(([, step]) => isHttpImportStep(step))
|
|
105
|
+
if (matches.length === 1) return matches[0]?.[0] ?? null
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const downloadUrlToFile = async (url: string, filePath: string): Promise<void> => {
|
|
110
|
+
await pipeline(got.stream(url), createWriteStream(filePath))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const isPrivateIp = (address: string): boolean => {
|
|
114
|
+
if (address === 'localhost') return true
|
|
115
|
+
const family = isIP(address)
|
|
116
|
+
if (family === 4) {
|
|
117
|
+
const parts = address.split('.').map((chunk) => Number(chunk))
|
|
118
|
+
const [a, b] = parts
|
|
119
|
+
if (a === 10) return true
|
|
120
|
+
if (a === 127) return true
|
|
121
|
+
if (a === 0) return true
|
|
122
|
+
if (a === 169 && b === 254) return true
|
|
123
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
124
|
+
if (a === 192 && b === 168) return true
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
if (family === 6) {
|
|
128
|
+
const normalized = address.toLowerCase()
|
|
129
|
+
if (normalized === '::1') return true
|
|
130
|
+
if (normalized.startsWith('fe80:')) return true
|
|
131
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const assertPublicDownloadUrl = (value: string): void => {
|
|
138
|
+
const parsed = new URL(value)
|
|
139
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
140
|
+
throw new Error(`URL downloads are limited to http/https: ${value}`)
|
|
141
|
+
}
|
|
142
|
+
if (isPrivateIp(parsed.hostname)) {
|
|
143
|
+
throw new Error(`URL downloads are limited to public hosts: ${value}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const prepareInputFiles = async (
|
|
148
|
+
options: PrepareInputFilesOptions = {},
|
|
149
|
+
): Promise<PrepareInputFilesResult> => {
|
|
150
|
+
const {
|
|
151
|
+
inputFiles = [],
|
|
152
|
+
params = {},
|
|
153
|
+
fields,
|
|
154
|
+
base64Strategy = 'buffer',
|
|
155
|
+
urlStrategy = 'import',
|
|
156
|
+
maxBase64Bytes,
|
|
157
|
+
allowPrivateUrls = true,
|
|
158
|
+
tempDir,
|
|
159
|
+
} = options
|
|
160
|
+
|
|
161
|
+
let nextParams: CreateAssemblyParams = { ...params }
|
|
162
|
+
const files: Record<string, string> = {}
|
|
163
|
+
const uploads: Record<string, UploadInput> = {}
|
|
164
|
+
const cleanup: Array<() => Promise<void>> = []
|
|
165
|
+
|
|
166
|
+
if (fields && Object.keys(fields).length > 0) {
|
|
167
|
+
nextParams = {
|
|
168
|
+
...nextParams,
|
|
169
|
+
fields: {
|
|
170
|
+
...(isRecord(nextParams.fields) ? nextParams.fields : {}),
|
|
171
|
+
...fields,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const steps = isRecord(nextParams.steps) ? { ...nextParams.steps } : {}
|
|
177
|
+
const usedSteps = new Set(Object.keys(steps))
|
|
178
|
+
const usedFields = new Set<string>()
|
|
179
|
+
const importUrlsByStep = new Map<string, string[]>()
|
|
180
|
+
const importStepNames = Object.keys(steps).filter((name) => isHttpImportStep(steps[name]))
|
|
181
|
+
const sharedImportStep = importStepNames.length === 1 ? importStepNames[0] : null
|
|
182
|
+
|
|
183
|
+
let tempRoot: string | null = null
|
|
184
|
+
const ensureTempRoot = async (): Promise<string> => {
|
|
185
|
+
if (!tempRoot) {
|
|
186
|
+
const root = await mkdtemp(join(tempDir ?? tmpdir(), 'transloadit-input-'))
|
|
187
|
+
tempRoot = root
|
|
188
|
+
cleanup.push(() => rm(root, { recursive: true, force: true }))
|
|
189
|
+
}
|
|
190
|
+
return tempRoot
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
for (const file of inputFiles) {
|
|
195
|
+
ensureUnique(file.field, usedFields)
|
|
196
|
+
if (file.kind === 'path') {
|
|
197
|
+
files[file.field] = file.path
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (file.kind === 'base64') {
|
|
201
|
+
if (maxBase64Bytes) {
|
|
202
|
+
const estimated = estimateBase64DecodedBytes(file.base64)
|
|
203
|
+
if (estimated > maxBase64Bytes) {
|
|
204
|
+
throw new Error(`Base64 payload exceeds ${maxBase64Bytes} bytes.`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const buffer = decodeBase64(file.base64)
|
|
208
|
+
if (maxBase64Bytes && buffer.length > maxBase64Bytes) {
|
|
209
|
+
throw new Error(`Base64 payload exceeds ${maxBase64Bytes} bytes.`)
|
|
210
|
+
}
|
|
211
|
+
if (base64Strategy === 'tempfile') {
|
|
212
|
+
const root = await ensureTempRoot()
|
|
213
|
+
const filename = file.filename ? basename(file.filename) : `${file.field}.bin`
|
|
214
|
+
const filePath = join(root, filename)
|
|
215
|
+
await writeFile(filePath, buffer)
|
|
216
|
+
files[file.field] = filePath
|
|
217
|
+
} else {
|
|
218
|
+
uploads[file.field] = buffer
|
|
219
|
+
}
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (file.kind === 'url') {
|
|
223
|
+
const matchedStep = findImportStepName(file.field, steps)
|
|
224
|
+
const targetStep = matchedStep ?? sharedImportStep
|
|
225
|
+
const shouldImport =
|
|
226
|
+
urlStrategy === 'import' || (urlStrategy === 'import-if-present' && targetStep)
|
|
227
|
+
|
|
228
|
+
if (shouldImport) {
|
|
229
|
+
const stepName = targetStep ?? ensureUniqueStepName(file.field, usedSteps)
|
|
230
|
+
const urls = importUrlsByStep.get(stepName) ?? []
|
|
231
|
+
urls.push(file.url)
|
|
232
|
+
importUrlsByStep.set(stepName, urls)
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const root = await ensureTempRoot()
|
|
237
|
+
const filename =
|
|
238
|
+
(file.filename ? basename(file.filename) : null) ??
|
|
239
|
+
getFilenameFromUrl(file.url) ??
|
|
240
|
+
`${file.field}.bin`
|
|
241
|
+
const filePath = join(root, filename)
|
|
242
|
+
if (!allowPrivateUrls) {
|
|
243
|
+
assertPublicDownloadUrl(file.url)
|
|
244
|
+
}
|
|
245
|
+
await downloadUrlToFile(file.url, filePath)
|
|
246
|
+
files[file.field] = filePath
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
await Promise.all(cleanup.map((fn) => fn()))
|
|
251
|
+
throw error
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (Object.keys(steps).length > 0 || importUrlsByStep.size > 0) {
|
|
255
|
+
if (importUrlsByStep.size > 0) {
|
|
256
|
+
for (const [stepName, urls] of importUrlsByStep.entries()) {
|
|
257
|
+
const existing = isRecord(steps[stepName]) ? steps[stepName] : {}
|
|
258
|
+
steps[stepName] = {
|
|
259
|
+
...existing,
|
|
260
|
+
robot: '/http/import',
|
|
261
|
+
url: urls.length === 1 ? urls[0] : urls,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
nextParams = {
|
|
267
|
+
...nextParams,
|
|
268
|
+
steps,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
params: nextParams,
|
|
274
|
+
files,
|
|
275
|
+
uploads,
|
|
276
|
+
cleanup,
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/robots.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
import { robotsMeta, robotsSchema } from './alphalib/types/robots/_index.ts'
|
|
3
|
+
|
|
4
|
+
export type RobotListOptions = {
|
|
5
|
+
category?: string
|
|
6
|
+
search?: string
|
|
7
|
+
limit?: number
|
|
8
|
+
cursor?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RobotListItem = {
|
|
12
|
+
name: string
|
|
13
|
+
title?: string
|
|
14
|
+
summary: string
|
|
15
|
+
category?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type RobotListResult = {
|
|
19
|
+
robots: RobotListItem[]
|
|
20
|
+
nextCursor?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RobotParamHelp = {
|
|
24
|
+
name: string
|
|
25
|
+
type: string
|
|
26
|
+
description?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type RobotHelp = {
|
|
30
|
+
name: string
|
|
31
|
+
summary: string
|
|
32
|
+
requiredParams: RobotParamHelp[]
|
|
33
|
+
optionalParams: RobotParamHelp[]
|
|
34
|
+
examples?: Array<{ description: string; snippet: Record<string, unknown> }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RobotHelpOptions = {
|
|
38
|
+
robotName: string
|
|
39
|
+
detailLevel?: 'summary' | 'params' | 'examples'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type RobotsMetaMap = typeof robotsMeta
|
|
43
|
+
type RobotMeta = RobotsMetaMap[keyof RobotsMetaMap]
|
|
44
|
+
|
|
45
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
46
|
+
typeof value === 'object' && value !== null
|
|
47
|
+
|
|
48
|
+
const getDef = (schema: z.ZodTypeAny): Record<string, unknown> =>
|
|
49
|
+
(schema as unknown as { _def?: Record<string, unknown>; def?: Record<string, unknown> })._def ??
|
|
50
|
+
(schema as unknown as { def?: Record<string, unknown> }).def ??
|
|
51
|
+
{}
|
|
52
|
+
|
|
53
|
+
const getDefType = (def: Record<string, unknown>): string | undefined =>
|
|
54
|
+
(def.type as string | undefined) ?? (def.typeName as string | undefined)
|
|
55
|
+
|
|
56
|
+
const robotNameToPath = (name: string): string => {
|
|
57
|
+
const base = name.replace(/Robot$/, '')
|
|
58
|
+
const spaced = base
|
|
59
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
60
|
+
.replace(/([A-Z]+)([A-Z][a-z0-9])/g, '$1 $2')
|
|
61
|
+
const parts = spaced.split(/\s+/).filter(Boolean)
|
|
62
|
+
return `/${parts.map((part) => part.toLowerCase()).join('/')}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const selectSummary = (meta: RobotMeta): string =>
|
|
66
|
+
meta.purpose_sentence ?? meta.purpose_words ?? meta.purpose_word ?? meta.title ?? meta.name
|
|
67
|
+
|
|
68
|
+
const resolveRobotPath = (robotName: string): string =>
|
|
69
|
+
robotName.startsWith('/') ? robotName : robotNameToPath(robotName)
|
|
70
|
+
|
|
71
|
+
const unwrapSchema = (schema: z.ZodTypeAny): { base: z.ZodTypeAny; optional: boolean } => {
|
|
72
|
+
let base = schema
|
|
73
|
+
let optional = typeof base.isOptional === 'function' ? base.isOptional() : false
|
|
74
|
+
|
|
75
|
+
while (true) {
|
|
76
|
+
const def = getDef(base)
|
|
77
|
+
const defType = getDefType(def)
|
|
78
|
+
if (
|
|
79
|
+
defType === 'optional' ||
|
|
80
|
+
defType === 'default' ||
|
|
81
|
+
defType === 'nullable' ||
|
|
82
|
+
defType === 'catch' ||
|
|
83
|
+
defType === 'ZodOptional' ||
|
|
84
|
+
defType === 'ZodDefault' ||
|
|
85
|
+
defType === 'ZodNullable' ||
|
|
86
|
+
defType === 'ZodCatch'
|
|
87
|
+
) {
|
|
88
|
+
const inner = def.innerType as z.ZodTypeAny | undefined
|
|
89
|
+
if (inner) {
|
|
90
|
+
base = inner
|
|
91
|
+
if (defType !== 'nullable' && defType !== 'ZodNullable') {
|
|
92
|
+
optional = true
|
|
93
|
+
}
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { base, optional }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const describeSchemaType = (schema: z.ZodTypeAny): string => {
|
|
104
|
+
const { base } = unwrapSchema(schema)
|
|
105
|
+
const def = getDef(base)
|
|
106
|
+
const defType = getDefType(def)
|
|
107
|
+
|
|
108
|
+
switch (defType) {
|
|
109
|
+
case 'string':
|
|
110
|
+
case 'ZodString':
|
|
111
|
+
return 'string'
|
|
112
|
+
case 'number':
|
|
113
|
+
case 'ZodNumber':
|
|
114
|
+
return 'number'
|
|
115
|
+
case 'boolean':
|
|
116
|
+
case 'ZodBoolean':
|
|
117
|
+
return 'boolean'
|
|
118
|
+
case 'bigint':
|
|
119
|
+
case 'ZodBigInt':
|
|
120
|
+
return 'bigint'
|
|
121
|
+
case 'literal':
|
|
122
|
+
case 'ZodLiteral': {
|
|
123
|
+
const value = (def.values as unknown[] | undefined)?.[0] ?? def.value
|
|
124
|
+
return value === undefined ? 'literal' : JSON.stringify(value)
|
|
125
|
+
}
|
|
126
|
+
case 'enum':
|
|
127
|
+
case 'ZodEnum': {
|
|
128
|
+
const values = Array.isArray(def.values) ? def.values : []
|
|
129
|
+
return values.length ? `enum(${values.join(' | ')})` : 'enum'
|
|
130
|
+
}
|
|
131
|
+
case 'array':
|
|
132
|
+
case 'ZodArray': {
|
|
133
|
+
const element = def.element as z.ZodTypeAny | undefined
|
|
134
|
+
const inner = element ? describeSchemaType(element) : 'unknown'
|
|
135
|
+
return `array<${inner}>`
|
|
136
|
+
}
|
|
137
|
+
case 'object':
|
|
138
|
+
case 'ZodObject':
|
|
139
|
+
return 'object'
|
|
140
|
+
case 'record':
|
|
141
|
+
case 'ZodRecord':
|
|
142
|
+
return 'record'
|
|
143
|
+
case 'union':
|
|
144
|
+
case 'ZodUnion': {
|
|
145
|
+
const options = Array.isArray(def.options) ? def.options : []
|
|
146
|
+
const rendered = options
|
|
147
|
+
.map((option) => describeSchemaType(option as z.ZodTypeAny))
|
|
148
|
+
.join(' | ')
|
|
149
|
+
return rendered ? `union<${rendered}>` : 'union'
|
|
150
|
+
}
|
|
151
|
+
case 'ZodDiscriminatedUnion':
|
|
152
|
+
return 'object'
|
|
153
|
+
default:
|
|
154
|
+
return defType ?? 'unknown'
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const getParamDescription = (schema: z.ZodTypeAny): string | undefined => {
|
|
159
|
+
if (schema.description?.trim()) {
|
|
160
|
+
return schema.description.trim()
|
|
161
|
+
}
|
|
162
|
+
const inner = unwrapSchema(schema).base
|
|
163
|
+
return inner.description?.trim()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const getShape = (schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> => {
|
|
167
|
+
const { base } = unwrapSchema(schema)
|
|
168
|
+
const def = getDef(base)
|
|
169
|
+
const shape = def.shape as
|
|
170
|
+
| Record<string, z.ZodTypeAny>
|
|
171
|
+
| (() => Record<string, z.ZodTypeAny>)
|
|
172
|
+
| undefined
|
|
173
|
+
if (typeof shape === 'function') {
|
|
174
|
+
return shape()
|
|
175
|
+
}
|
|
176
|
+
return shape ?? {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const getRobotParams = (
|
|
180
|
+
schema: z.ZodTypeAny,
|
|
181
|
+
): { required: RobotParamHelp[]; optional: RobotParamHelp[] } => {
|
|
182
|
+
const shape = getShape(schema)
|
|
183
|
+
const required: RobotParamHelp[] = []
|
|
184
|
+
const optional: RobotParamHelp[] = []
|
|
185
|
+
|
|
186
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
187
|
+
if (key === 'robot') continue
|
|
188
|
+
const { optional: isOptional } = unwrapSchema(value)
|
|
189
|
+
const param: RobotParamHelp = {
|
|
190
|
+
name: key,
|
|
191
|
+
type: describeSchemaType(value),
|
|
192
|
+
description: getParamDescription(value),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (isOptional) {
|
|
196
|
+
optional.push(param)
|
|
197
|
+
} else {
|
|
198
|
+
required.push(param)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { required, optional }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const getRobotsMetaIndex = (): {
|
|
206
|
+
byName: Map<string, RobotMeta>
|
|
207
|
+
byPath: Map<string, RobotMeta>
|
|
208
|
+
} => {
|
|
209
|
+
const byName = new Map<string, RobotMeta>()
|
|
210
|
+
const byPath = new Map<string, RobotMeta>()
|
|
211
|
+
|
|
212
|
+
for (const meta of Object.values(robotsMeta)) {
|
|
213
|
+
byName.set(meta.name, meta)
|
|
214
|
+
byPath.set(robotNameToPath(meta.name), meta)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { byName, byPath }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const getRobotSchemaIndex = (): Map<string, z.ZodTypeAny> => {
|
|
221
|
+
const index = new Map<string, z.ZodTypeAny>()
|
|
222
|
+
for (const option of robotsSchema.options) {
|
|
223
|
+
const shape = getShape(option)
|
|
224
|
+
const robotSchema = shape.robot
|
|
225
|
+
if (!robotSchema) continue
|
|
226
|
+
const robotDef = getDef(robotSchema)
|
|
227
|
+
const robotLiteral = (robotDef.values as unknown[] | undefined)?.[0] ?? robotDef.value
|
|
228
|
+
if (typeof robotLiteral === 'string') {
|
|
229
|
+
index.set(robotLiteral, option)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return index
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let cachedMetaIndex: ReturnType<typeof getRobotsMetaIndex> | null = null
|
|
236
|
+
let cachedSchemaIndex: ReturnType<typeof getRobotSchemaIndex> | null = null
|
|
237
|
+
|
|
238
|
+
const getMetaIndex = (): ReturnType<typeof getRobotsMetaIndex> => {
|
|
239
|
+
if (!cachedMetaIndex) {
|
|
240
|
+
cachedMetaIndex = getRobotsMetaIndex()
|
|
241
|
+
}
|
|
242
|
+
return cachedMetaIndex
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const getSchemaIndex = (): ReturnType<typeof getRobotSchemaIndex> => {
|
|
246
|
+
if (!cachedSchemaIndex) {
|
|
247
|
+
cachedSchemaIndex = getRobotSchemaIndex()
|
|
248
|
+
}
|
|
249
|
+
return cachedSchemaIndex
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const listRobots = (options: RobotListOptions = {}): RobotListResult => {
|
|
253
|
+
const normalizedSearch = options.search?.toLowerCase()
|
|
254
|
+
const normalizedCategory = options.category?.toLowerCase()
|
|
255
|
+
const { byPath } = getMetaIndex()
|
|
256
|
+
|
|
257
|
+
const allRobots: RobotListItem[] = Array.from(byPath.entries()).map(([path, meta]) => ({
|
|
258
|
+
name: path,
|
|
259
|
+
title: meta.title,
|
|
260
|
+
summary: selectSummary(meta),
|
|
261
|
+
category: meta.service_slug,
|
|
262
|
+
}))
|
|
263
|
+
|
|
264
|
+
const filtered = allRobots
|
|
265
|
+
.filter((robot) => {
|
|
266
|
+
if (normalizedCategory && robot.category?.toLowerCase() !== normalizedCategory) {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
if (!normalizedSearch) return true
|
|
270
|
+
const haystack = `${robot.name} ${robot.title ?? ''} ${robot.summary}`.toLowerCase()
|
|
271
|
+
return haystack.includes(normalizedSearch)
|
|
272
|
+
})
|
|
273
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
274
|
+
|
|
275
|
+
const start = options.cursor ? Number.parseInt(options.cursor, 10) : 0
|
|
276
|
+
const safeStart = Number.isFinite(start) && start > 0 ? start : 0
|
|
277
|
+
const safeLimit = options.limit && options.limit > 0 ? options.limit : 20
|
|
278
|
+
const page = filtered.slice(safeStart, safeStart + safeLimit)
|
|
279
|
+
const nextCursor =
|
|
280
|
+
safeStart + safeLimit < filtered.length ? String(safeStart + safeLimit) : undefined
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
robots: page,
|
|
284
|
+
nextCursor,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const getRobotHelp = (options: RobotHelpOptions): RobotHelp => {
|
|
289
|
+
const detailLevel = options.detailLevel ?? 'summary'
|
|
290
|
+
const { byPath, byName } = getMetaIndex()
|
|
291
|
+
const schemaIndex = getSchemaIndex()
|
|
292
|
+
|
|
293
|
+
const path = resolveRobotPath(options.robotName)
|
|
294
|
+
const meta = byPath.get(path) ?? byName.get(options.robotName) ?? null
|
|
295
|
+
const summary = meta ? selectSummary(meta) : `Robot ${path}`
|
|
296
|
+
const schema = schemaIndex.get(path)
|
|
297
|
+
const params = schema ? getRobotParams(schema) : { required: [], optional: [] }
|
|
298
|
+
|
|
299
|
+
const help: RobotHelp = {
|
|
300
|
+
name: path,
|
|
301
|
+
summary,
|
|
302
|
+
requiredParams: detailLevel === 'params' ? params.required : [],
|
|
303
|
+
optionalParams: detailLevel === 'params' ? params.optional : [],
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (detailLevel === 'examples' && meta?.example_code) {
|
|
307
|
+
const snippet = isRecord(meta.example_code) ? meta.example_code : {}
|
|
308
|
+
help.examples = [
|
|
309
|
+
{
|
|
310
|
+
description: meta.example_code_description ?? 'Example',
|
|
311
|
+
snippet,
|
|
312
|
+
},
|
|
313
|
+
]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return help
|
|
317
|
+
}
|