@wevion/cli 1.0.2
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 +46 -0
- package/openapi.json +230849 -0
- package/package.json +24 -0
- package/selftest.mjs +183 -0
- package/src/index.mjs +524 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Wevion CLI — a generic, spec-driven driver over the public OpenAPI spec.
|
|
3
|
+
// It ships a frozen openapi.json snapshot (refreshed by CI on every prod
|
|
4
|
+
// release) and turns every operation into a subcommand at runtime. No codegen.
|
|
5
|
+
//
|
|
6
|
+
// wevion <command> [--param value ...]
|
|
7
|
+
// wevion list # list all commands
|
|
8
|
+
// wevion help <command> # show a command's params
|
|
9
|
+
//
|
|
10
|
+
// Auth: WEVION_API_KEY (x-api-key). Base URL: WEVION_BASE_URL or --base-url.
|
|
11
|
+
|
|
12
|
+
import { parseArgs } from 'node:util'
|
|
13
|
+
import {
|
|
14
|
+
chmodSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { createInterface } from 'node:readline/promises'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { dirname, join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']
|
|
29
|
+
const JSON_CONTENT_TYPE = 'application/json'
|
|
30
|
+
const SUPPORTED_AUTH_SCHEMES = new Set(['apiKeyAuth'])
|
|
31
|
+
// ponytail: hardcoded prod default; stage via --base-url / WEVION_BASE_URL / config.
|
|
32
|
+
const DEFAULT_BASE_URL = 'https://api.wevion.ai'
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
34
|
+
|
|
35
|
+
// ~/.config/wevion/config.json (honours XDG_CONFIG_HOME). Stores { apiKey, baseUrl }.
|
|
36
|
+
export function configPath(env = process.env, platform = process.platform) {
|
|
37
|
+
if (platform === 'win32') {
|
|
38
|
+
const base = env.APPDATA || join(env.USERPROFILE || env.HOME || homedir(), 'AppData', 'Roaming')
|
|
39
|
+
return join(base, 'Wevion', 'config.json')
|
|
40
|
+
}
|
|
41
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config')
|
|
42
|
+
return join(base, 'wevion', 'config.json')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readConfig() {
|
|
46
|
+
const p = configPath()
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err?.code === 'ENOENT') {
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`cannot read ${p}: ${err.message}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeConfig(config) {
|
|
58
|
+
const p = configPath()
|
|
59
|
+
const dir = dirname(p)
|
|
60
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
61
|
+
try {
|
|
62
|
+
chmodSync(dir, 0o700)
|
|
63
|
+
} catch {
|
|
64
|
+
/* best effort on platforms without POSIX modes */
|
|
65
|
+
}
|
|
66
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
|
|
69
|
+
renameSync(tmp, p)
|
|
70
|
+
try {
|
|
71
|
+
chmodSync(p, 0o600)
|
|
72
|
+
} catch {
|
|
73
|
+
/* best effort on platforms without POSIX modes */
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
rmSync(tmp, { force: true })
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore cleanup failure */
|
|
80
|
+
}
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
return p
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readHiddenLine(prompt) {
|
|
87
|
+
if (!process.stdin.isTTY || !process.stdin.setRawMode) {
|
|
88
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
89
|
+
try {
|
|
90
|
+
return await rl.question(prompt)
|
|
91
|
+
} finally {
|
|
92
|
+
rl.close()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
process.stderr.write(prompt)
|
|
97
|
+
process.stdin.setRawMode(true)
|
|
98
|
+
process.stdin.resume()
|
|
99
|
+
process.stdin.setEncoding('utf8')
|
|
100
|
+
let value = ''
|
|
101
|
+
try {
|
|
102
|
+
for await (const chunk of process.stdin) {
|
|
103
|
+
for (const ch of chunk) {
|
|
104
|
+
if (ch === '\u0003') {
|
|
105
|
+
throw new Error('cancelled')
|
|
106
|
+
}
|
|
107
|
+
if (ch === '\r' || ch === '\n') {
|
|
108
|
+
process.stderr.write('\n')
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
112
|
+
value = value.slice(0, -1)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
value += ch
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return value
|
|
119
|
+
} finally {
|
|
120
|
+
process.stdin.setRawMode(false)
|
|
121
|
+
process.stdin.pause()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveApiKey(env, config) {
|
|
126
|
+
return env.WEVION_API_KEY || config.apiKey || null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function login(rest) {
|
|
130
|
+
const { values } = parseArgs({
|
|
131
|
+
args: rest,
|
|
132
|
+
options: { 'api-key': { type: 'string' }, 'base-url': { type: 'string' } },
|
|
133
|
+
allowPositionals: false,
|
|
134
|
+
})
|
|
135
|
+
let apiKey = values['api-key']
|
|
136
|
+
if (!apiKey && !process.stdin.isTTY) apiKey = readFileSync(0, 'utf8').trim() // piped in
|
|
137
|
+
if (!apiKey) {
|
|
138
|
+
apiKey = (await readHiddenLine('Wevion API key: ')).trim()
|
|
139
|
+
}
|
|
140
|
+
if (!apiKey) return fail('no API key provided')
|
|
141
|
+
const cfg = readConfig()
|
|
142
|
+
cfg.apiKey = apiKey
|
|
143
|
+
if (values['base-url']) cfg.baseUrl = values['base-url']
|
|
144
|
+
const p = writeConfig(cfg)
|
|
145
|
+
process.stderr.write(`saved credentials to ${p}\n`)
|
|
146
|
+
return 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function logout() {
|
|
150
|
+
const p = configPath()
|
|
151
|
+
try {
|
|
152
|
+
rmSync(p)
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err?.code !== 'ENOENT') {
|
|
155
|
+
return fail(`cannot remove ${p}: ${err.message}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (process.env.WEVION_API_KEY) {
|
|
159
|
+
process.stderr.write('warning: WEVION_API_KEY is still set in the environment\n')
|
|
160
|
+
}
|
|
161
|
+
process.stderr.write('logged out\n')
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function kebab(s) {
|
|
166
|
+
return s
|
|
167
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
168
|
+
.replace(/[_\s]+/g, '-')
|
|
169
|
+
.toLowerCase()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveRef(spec, schema, seen = new Set()) {
|
|
173
|
+
if (!schema?.$ref) return schema
|
|
174
|
+
if (!schema.$ref.startsWith('#/')) return schema
|
|
175
|
+
if (seen.has(schema.$ref)) return schema
|
|
176
|
+
seen.add(schema.$ref)
|
|
177
|
+
const resolved = schema.$ref
|
|
178
|
+
.slice(2)
|
|
179
|
+
.split('/')
|
|
180
|
+
.reduce((node, part) => node?.[part.replace(/~1/g, '/').replace(/~0/g, '~')], spec)
|
|
181
|
+
return resolveRef(spec, resolved || schema, seen)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeObjectSchemas(spec, schema) {
|
|
185
|
+
const resolved = resolveRef(spec, schema)
|
|
186
|
+
if (!resolved) return undefined
|
|
187
|
+
if (Array.isArray(resolved.allOf)) {
|
|
188
|
+
const parts = resolved.allOf.map((item) => mergeObjectSchemas(spec, item)).filter(Boolean)
|
|
189
|
+
return {
|
|
190
|
+
...resolved,
|
|
191
|
+
properties: Object.assign({}, ...parts.map((part) => part.properties || {}), resolved.properties || {}),
|
|
192
|
+
required: [...new Set(parts.flatMap((part) => part.required || []).concat(resolved.required || []))],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return resolved
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function jsonBodySchema(spec, requestBody) {
|
|
199
|
+
return mergeObjectSchemas(spec, requestBody?.content?.[JSON_CONTENT_TYPE]?.schema)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function supportedAuth(security) {
|
|
203
|
+
if (!Array.isArray(security) || security.length === 0) return false
|
|
204
|
+
return security.some((requirement) =>
|
|
205
|
+
Object.keys(requirement || {}).some((scheme) => SUPPORTED_AUTH_SCHEMES.has(scheme)),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function bodyFieldsFor(bodyProps, params) {
|
|
210
|
+
const used = new Set(['base-url', 'json'])
|
|
211
|
+
for (const p of params) {
|
|
212
|
+
if (p.in === 'path' || p.in === 'query' || p.in === 'header') used.add(p.name)
|
|
213
|
+
}
|
|
214
|
+
return Object.entries(bodyProps).map(([name, schema]) => {
|
|
215
|
+
let flag = name
|
|
216
|
+
if (used.has(flag)) flag = `body-${name}`
|
|
217
|
+
while (used.has(flag)) flag = `body-${flag}`
|
|
218
|
+
used.add(flag)
|
|
219
|
+
return { name, flag, schema }
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Flatten an OpenAPI spec into a map of command-id -> operation descriptor.
|
|
224
|
+
export function listOperations(spec) {
|
|
225
|
+
const ops = new Map()
|
|
226
|
+
for (const [path, item] of Object.entries(spec.paths || {})) {
|
|
227
|
+
for (const method of HTTP_METHODS) {
|
|
228
|
+
const op = item?.[method]
|
|
229
|
+
if (!op) continue
|
|
230
|
+
const id = kebab(op.operationId || `${method}-${path}`)
|
|
231
|
+
const bodySchema = jsonBodySchema(spec, op.requestBody)
|
|
232
|
+
const params = op.parameters || []
|
|
233
|
+
const bodyProps = bodySchema?.properties || {}
|
|
234
|
+
const hasUnsupportedBody = Boolean(op.requestBody && !op.requestBody.content?.[JSON_CONTENT_TYPE])
|
|
235
|
+
const security = op.security ?? spec.security ?? []
|
|
236
|
+
if (!supportedAuth(security)) continue
|
|
237
|
+
ops.set(id, {
|
|
238
|
+
id,
|
|
239
|
+
method: method.toUpperCase(),
|
|
240
|
+
path,
|
|
241
|
+
summary: op.summary || '',
|
|
242
|
+
tags: op.tags || [],
|
|
243
|
+
security,
|
|
244
|
+
supportedAuth: true,
|
|
245
|
+
unsupported: hasUnsupportedBody ? 'request body media type is not supported; use a JSON endpoint' : null,
|
|
246
|
+
params, // {name, in: path|query|header, required, schema}
|
|
247
|
+
bodyProps,
|
|
248
|
+
bodyFields: bodyFieldsFor(bodyProps, params),
|
|
249
|
+
bodyRequired: bodySchema?.required || [],
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return ops
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Build a parseArgs `options` config from an operation's inputs. Everything is
|
|
257
|
+
// a string flag; the backend validates types and reports 400 on mismatch.
|
|
258
|
+
function buildParseOptions(op) {
|
|
259
|
+
const options = { 'base-url': { type: 'string' }, json: { type: 'string' } }
|
|
260
|
+
for (const p of op.params) {
|
|
261
|
+
if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
|
|
262
|
+
options[p.name] = { type: 'string', multiple: p.schema?.type === 'array' }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const field of op.bodyFields) options[field.flag] = { type: 'string' }
|
|
266
|
+
return options
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function valuesForSchema(value, schema) {
|
|
270
|
+
if (value == null) return []
|
|
271
|
+
const values = Array.isArray(value) ? value : [value]
|
|
272
|
+
if (schema?.type === 'array') {
|
|
273
|
+
return values.flatMap((item) => {
|
|
274
|
+
const trimmed = String(item).trim()
|
|
275
|
+
if (trimmed.startsWith('[')) {
|
|
276
|
+
const parsed = JSON.parse(trimmed)
|
|
277
|
+
if (!Array.isArray(parsed)) throw new Error('expected a JSON array')
|
|
278
|
+
return parsed.map(String)
|
|
279
|
+
}
|
|
280
|
+
return String(item)
|
|
281
|
+
.split(',')
|
|
282
|
+
.map((part) => part.trim())
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
return values.map(String)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function coerceValue(value, schema) {
|
|
290
|
+
const type = schema?.type
|
|
291
|
+
if (type === 'array' || type === 'object') {
|
|
292
|
+
return JSON.parse(value)
|
|
293
|
+
}
|
|
294
|
+
if (type === 'integer' || type === 'number') {
|
|
295
|
+
const n = Number(value)
|
|
296
|
+
if (!Number.isFinite(n)) throw new Error(`expected ${type}`)
|
|
297
|
+
if (type === 'integer' && !Number.isInteger(n)) throw new Error('expected integer')
|
|
298
|
+
return n
|
|
299
|
+
}
|
|
300
|
+
if (type === 'boolean') {
|
|
301
|
+
if (value === 'true') return true
|
|
302
|
+
if (value === 'false') return false
|
|
303
|
+
throw new Error('expected boolean true or false')
|
|
304
|
+
}
|
|
305
|
+
if (!type && /^[\[{]|^-?\d+(?:\.\d+)?$|^(?:true|false|null)$/.test(String(value).trim())) {
|
|
306
|
+
return JSON.parse(value)
|
|
307
|
+
}
|
|
308
|
+
return value
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Assemble the HTTP request from parsed flag values. Throws on missing required inputs.
|
|
312
|
+
export function buildRequest(op, values, baseUrl) {
|
|
313
|
+
if (op.unsupported) throw new Error(op.unsupported)
|
|
314
|
+
if (!op.supportedAuth) throw new Error('operation uses an unsupported auth scheme')
|
|
315
|
+
let path = op.path
|
|
316
|
+
const query = new URLSearchParams()
|
|
317
|
+
const headers = {}
|
|
318
|
+
for (const p of op.params) {
|
|
319
|
+
const v = values[p.name]
|
|
320
|
+
if (p.in === 'path') {
|
|
321
|
+
if (v == null) throw new Error(`missing required path param --${p.name}`)
|
|
322
|
+
path = path.replace(`{${p.name}}`, encodeURIComponent(v))
|
|
323
|
+
} else if (p.in === 'query' && v != null) {
|
|
324
|
+
for (const item of valuesForSchema(v, p.schema)) query.append(p.name, item)
|
|
325
|
+
} else if (p.in === 'header' && v != null) {
|
|
326
|
+
headers[p.name] = valuesForSchema(v, p.schema).join(',')
|
|
327
|
+
} else if (p.required && v == null && (p.in === 'query' || p.in === 'header')) {
|
|
328
|
+
throw new Error(`missing required ${p.in} param --${p.name}`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const qs = query.toString()
|
|
332
|
+
const url = `${baseUrl.replace(/\/$/, '')}${path}${qs ? `?${qs}` : ''}`
|
|
333
|
+
|
|
334
|
+
let body
|
|
335
|
+
if (values.json != null) {
|
|
336
|
+
const parsed = JSON.parse(values.json)
|
|
337
|
+
for (const name of op.bodyRequired) {
|
|
338
|
+
if (!parsed || typeof parsed !== 'object' || !(name in parsed)) {
|
|
339
|
+
throw new Error(`missing required body field --${name}`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
body = values.json // raw JSON passthrough for the whole body
|
|
343
|
+
} else if (op.bodyFields.length) {
|
|
344
|
+
const obj = {}
|
|
345
|
+
for (const field of op.bodyFields) {
|
|
346
|
+
if (op.bodyRequired.includes(field.name) && values[field.flag] == null) {
|
|
347
|
+
throw new Error(`missing required body field --${field.flag}`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const field of op.bodyFields) {
|
|
351
|
+
if (values[field.flag] != null) {
|
|
352
|
+
try {
|
|
353
|
+
obj[field.name] = coerceValue(values[field.flag], field.schema)
|
|
354
|
+
} catch (err) {
|
|
355
|
+
throw new Error(`invalid --${field.flag}: ${err.message}`)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (Object.keys(obj).length) body = JSON.stringify(obj)
|
|
360
|
+
}
|
|
361
|
+
return { url, method: op.method, headers, body }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateSpec(spec, source) {
|
|
365
|
+
if (!spec || typeof spec !== 'object' || !/^3\./.test(String(spec.openapi || '')) || !spec.paths) {
|
|
366
|
+
throw new Error(`${source} is not a valid OpenAPI 3 spec`)
|
|
367
|
+
}
|
|
368
|
+
return spec
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function loadSpec(baseUrl) {
|
|
372
|
+
// Prefer the bundled snapshot (offline help, reproducible per release).
|
|
373
|
+
const bundled = join(dirname(fileURLToPath(import.meta.url)), '..', 'openapi.json')
|
|
374
|
+
try {
|
|
375
|
+
return { spec: validateSpec(JSON.parse(readFileSync(bundled, 'utf8')), bundled), source: 'bundled' }
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (existsSync(bundled)) throw err
|
|
378
|
+
return { source: 'remote', url: `${baseUrl.replace(/\/$/, '')}/docs/json` }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function timeoutMs(env = process.env) {
|
|
383
|
+
const parsed = Number(env.WEVION_TIMEOUT_MS)
|
|
384
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function signalFromEnv(env = process.env) {
|
|
388
|
+
return AbortSignal.timeout(timeoutMs(env))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resolveBaseUrl(argv, config, env = process.env) {
|
|
392
|
+
const globalOptions = { 'base-url': { type: 'string' } }
|
|
393
|
+
try {
|
|
394
|
+
const { values } = parseArgs({
|
|
395
|
+
args: argv,
|
|
396
|
+
options: globalOptions,
|
|
397
|
+
allowPositionals: true,
|
|
398
|
+
strict: false,
|
|
399
|
+
})
|
|
400
|
+
return values['base-url'] || env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
|
|
401
|
+
} catch {
|
|
402
|
+
return env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function printList(ops) {
|
|
407
|
+
const byTag = new Map()
|
|
408
|
+
for (const op of ops.values()) {
|
|
409
|
+
const tag = op.tags[0] || 'other'
|
|
410
|
+
if (!byTag.has(tag)) byTag.set(tag, [])
|
|
411
|
+
byTag.get(tag).push(op)
|
|
412
|
+
}
|
|
413
|
+
for (const [tag, list] of [...byTag].sort()) {
|
|
414
|
+
process.stdout.write(`\n${tag}\n`)
|
|
415
|
+
for (const op of list.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
416
|
+
process.stdout.write(` ${op.id.padEnd(40)} ${op.summary}\n`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function printHelp(op) {
|
|
422
|
+
if (op.unsupported) process.stdout.write(`Unsupported: ${op.unsupported}\n\n`)
|
|
423
|
+
process.stdout.write(`${op.method} ${op.path}\n${op.summary}\n\nFlags:\n`)
|
|
424
|
+
for (const p of op.params) {
|
|
425
|
+
if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
|
|
426
|
+
const repeats = p.schema?.type === 'array' ? ' repeat or comma-list' : ''
|
|
427
|
+
process.stdout.write(` --${p.name}${p.required ? ' (required)' : ''}${repeats}\n`)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const field of op.bodyFields) {
|
|
431
|
+
const source = field.flag === field.name ? '' : ` [body: ${field.name}]`
|
|
432
|
+
process.stdout.write(` --${field.flag}${op.bodyRequired.includes(field.name) ? ' (required)' : ''} [body]${source}\n`)
|
|
433
|
+
}
|
|
434
|
+
process.stdout.write(` --json '<raw json>' send raw request body\n`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function main(argv) {
|
|
438
|
+
const [cmd, ...rest] = argv
|
|
439
|
+
|
|
440
|
+
if (cmd === 'login') return login(rest)
|
|
441
|
+
if (cmd === 'logout') return logout()
|
|
442
|
+
|
|
443
|
+
const config = readConfig()
|
|
444
|
+
const baseUrl = resolveBaseUrl(argv, config)
|
|
445
|
+
|
|
446
|
+
const loaded = loadSpec(baseUrl)
|
|
447
|
+
const spec =
|
|
448
|
+
loaded.spec ||
|
|
449
|
+
(await fetch(loaded.url, { signal: signalFromEnv() }).then((r) => {
|
|
450
|
+
if (!r.ok) throw new Error(`cannot fetch spec from ${loaded.url}: ${r.status}`)
|
|
451
|
+
return r.json().then((json) => validateSpec(json, loaded.url))
|
|
452
|
+
}))
|
|
453
|
+
const ops = listOperations(spec)
|
|
454
|
+
|
|
455
|
+
if (!cmd || cmd === 'list' || cmd === '--help' || cmd === '-h') {
|
|
456
|
+
process.stdout.write(`Wevion CLI — ${ops.size} commands (${loaded.source} spec)\n`)
|
|
457
|
+
printList(ops)
|
|
458
|
+
process.stdout.write(`\nUsage: wevion <command> [--param value ...]\n`)
|
|
459
|
+
process.stdout.write(` wevion login | logout | help <command>\n`)
|
|
460
|
+
return 0
|
|
461
|
+
}
|
|
462
|
+
if (cmd === 'help') {
|
|
463
|
+
const op = ops.get(rest[0])
|
|
464
|
+
if (!op) return fail(`unknown command: ${rest[0] || '(none)'}`)
|
|
465
|
+
printHelp(op)
|
|
466
|
+
return 0
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const op = ops.get(cmd)
|
|
470
|
+
if (!op) return fail(`unknown command: ${cmd} (try "wevion list")`)
|
|
471
|
+
|
|
472
|
+
const { values } = parseArgs({
|
|
473
|
+
args: rest,
|
|
474
|
+
options: buildParseOptions(op),
|
|
475
|
+
allowPositionals: false,
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
const apiKey = resolveApiKey(process.env, config)
|
|
479
|
+
if (!apiKey) return fail('no API key — run "wevion login" or set WEVION_API_KEY')
|
|
480
|
+
|
|
481
|
+
const req = buildRequest(op, values, values['base-url'] || baseUrl)
|
|
482
|
+
const res = await fetch(req.url, {
|
|
483
|
+
method: req.method,
|
|
484
|
+
headers: {
|
|
485
|
+
...req.headers,
|
|
486
|
+
'x-api-key': apiKey,
|
|
487
|
+
...(req.body ? { 'content-type': 'application/json' } : {}),
|
|
488
|
+
},
|
|
489
|
+
body: req.body,
|
|
490
|
+
signal: signalFromEnv(),
|
|
491
|
+
})
|
|
492
|
+
const text = await res.text()
|
|
493
|
+
if (!res.ok) {
|
|
494
|
+
process.stderr.write(`${req.method} ${req.url} -> ${res.status}\n`)
|
|
495
|
+
if (text) process.stderr.write(text.endsWith('\n') ? text : text + '\n')
|
|
496
|
+
return 1
|
|
497
|
+
}
|
|
498
|
+
process.stdout.write(text.endsWith('\n') ? text : text + '\n')
|
|
499
|
+
return 0
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function fail(msg) {
|
|
503
|
+
process.stderr.write(`error: ${msg}\n`)
|
|
504
|
+
return 2
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isExecutedAsBin() {
|
|
508
|
+
if (!process.argv[1]) return false
|
|
509
|
+
try {
|
|
510
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1])
|
|
511
|
+
} catch {
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Run only when executed as a bin, not when imported by selftest.
|
|
517
|
+
if (isExecutedAsBin()) {
|
|
518
|
+
main(process.argv.slice(2))
|
|
519
|
+
.then((code) => process.exit(code))
|
|
520
|
+
.catch((err) => {
|
|
521
|
+
process.stderr.write(`error: ${err.message}\n`)
|
|
522
|
+
process.exit(1)
|
|
523
|
+
})
|
|
524
|
+
}
|