@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/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
+ }