@wevion/cli 1.0.2 → 1.0.3
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 +100 -27
- package/openapi.json +472 -74
- package/package.json +1 -1
- package/selftest.mjs +258 -12
- package/src/index.mjs +540 -27
package/src/index.mjs
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import { parseArgs } from 'node:util'
|
|
13
13
|
import {
|
|
14
14
|
chmodSync,
|
|
15
|
-
existsSync,
|
|
16
15
|
mkdirSync,
|
|
17
16
|
readFileSync,
|
|
18
17
|
realpathSync,
|
|
@@ -24,6 +23,7 @@ import { homedir } from 'node:os'
|
|
|
24
23
|
import { createInterface } from 'node:readline/promises'
|
|
25
24
|
import { fileURLToPath } from 'node:url'
|
|
26
25
|
import { dirname, join } from 'node:path'
|
|
26
|
+
import { spawn } from 'node:child_process'
|
|
27
27
|
|
|
28
28
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']
|
|
29
29
|
const JSON_CONTENT_TYPE = 'application/json'
|
|
@@ -31,6 +31,7 @@ const SUPPORTED_AUTH_SCHEMES = new Set(['apiKeyAuth'])
|
|
|
31
31
|
// ponytail: hardcoded prod default; stage via --base-url / WEVION_BASE_URL / config.
|
|
32
32
|
const DEFAULT_BASE_URL = 'https://api.wevion.ai'
|
|
33
33
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
34
|
+
const DEFAULT_SPEC_TIMEOUT_MS = 3000
|
|
34
35
|
|
|
35
36
|
// ~/.config/wevion/config.json (honours XDG_CONFIG_HOME). Stores { apiKey, baseUrl }.
|
|
36
37
|
export function configPath(env = process.env, platform = process.platform) {
|
|
@@ -162,6 +163,188 @@ function logout() {
|
|
|
162
163
|
return 0
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
// ── Update notifier ──────────────────────────────────────────────
|
|
167
|
+
// Zero-dep: a detached background process refreshes a cached "latest" at most
|
|
168
|
+
// once/day; the notice (stderr only, never stdout) shows on the next run.
|
|
169
|
+
|
|
170
|
+
function pkgInfo() {
|
|
171
|
+
try {
|
|
172
|
+
const p = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
173
|
+
const { name, version } = JSON.parse(readFileSync(p, 'utf8'))
|
|
174
|
+
return { name, version }
|
|
175
|
+
} catch {
|
|
176
|
+
return {}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function updateCheckPath(env = process.env, platform = process.platform) {
|
|
181
|
+
return join(dirname(configPath(env, platform)), 'update-check.json')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseSemver(v) {
|
|
185
|
+
const match = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
|
|
186
|
+
if (!match) return null
|
|
187
|
+
return {
|
|
188
|
+
major: Number(match[1]),
|
|
189
|
+
minor: Number(match[2]),
|
|
190
|
+
patch: Number(match[3]),
|
|
191
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function comparePrerelease(a, b) {
|
|
196
|
+
if (!a.length && !b.length) return 0
|
|
197
|
+
if (!a.length) return 1
|
|
198
|
+
if (!b.length) return -1
|
|
199
|
+
const len = Math.max(a.length, b.length)
|
|
200
|
+
for (let i = 0; i < len; i++) {
|
|
201
|
+
const ai = a[i]
|
|
202
|
+
const bi = b[i]
|
|
203
|
+
if (ai == null) return -1
|
|
204
|
+
if (bi == null) return 1
|
|
205
|
+
const an = /^\d+$/.test(ai) ? Number(ai) : null
|
|
206
|
+
const bn = /^\d+$/.test(bi) ? Number(bi) : null
|
|
207
|
+
if (an !== null && bn !== null && an !== bn) return an - bn
|
|
208
|
+
if (an !== null && bn === null) return -1
|
|
209
|
+
if (an === null && bn !== null) return 1
|
|
210
|
+
if (an === null && bn === null && ai !== bi) return ai < bi ? -1 : 1
|
|
211
|
+
}
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Minimal semver compare: numeric core, prerelease lower than release.
|
|
216
|
+
export function compareVersions(a, b) {
|
|
217
|
+
const pa = parseSemver(a)
|
|
218
|
+
const pb = parseSemver(b)
|
|
219
|
+
if (!pa || !pb) return 0
|
|
220
|
+
return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch || comparePrerelease(pa.prerelease, pb.prerelease)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function isSafeVersion(v) {
|
|
224
|
+
return typeof v === 'string' && /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function updateCheckEnv(env = process.env) {
|
|
228
|
+
const allowlist = [
|
|
229
|
+
'HOME',
|
|
230
|
+
'USERPROFILE',
|
|
231
|
+
'APPDATA',
|
|
232
|
+
'XDG_CONFIG_HOME',
|
|
233
|
+
'HTTP_PROXY',
|
|
234
|
+
'HTTPS_PROXY',
|
|
235
|
+
'NO_PROXY',
|
|
236
|
+
'http_proxy',
|
|
237
|
+
'https_proxy',
|
|
238
|
+
'no_proxy',
|
|
239
|
+
'NODE_EXTRA_CA_CERTS',
|
|
240
|
+
'SSL_CERT_FILE',
|
|
241
|
+
'SSL_CERT_DIR',
|
|
242
|
+
'SystemRoot',
|
|
243
|
+
'WINDIR',
|
|
244
|
+
'TEMP',
|
|
245
|
+
'TMP',
|
|
246
|
+
]
|
|
247
|
+
return Object.fromEntries(allowlist.filter((key) => env[key]).map((key) => [key, env[key]]))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function readUpdateCache() {
|
|
251
|
+
try {
|
|
252
|
+
const cache = JSON.parse(readFileSync(updateCheckPath(), 'utf8'))
|
|
253
|
+
return cache && typeof cache === 'object' ? cache : {}
|
|
254
|
+
} catch {
|
|
255
|
+
return {}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function writePrivateJsonFile(p, payload) {
|
|
260
|
+
const dir = dirname(p)
|
|
261
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
262
|
+
try {
|
|
263
|
+
chmodSync(dir, 0o700)
|
|
264
|
+
} catch {
|
|
265
|
+
/* best effort on platforms without POSIX modes */
|
|
266
|
+
}
|
|
267
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
268
|
+
try {
|
|
269
|
+
writeFileSync(tmp, JSON.stringify(payload) + '\n', { mode: 0o600 })
|
|
270
|
+
renameSync(tmp, p)
|
|
271
|
+
try {
|
|
272
|
+
chmodSync(p, 0o600)
|
|
273
|
+
} catch {
|
|
274
|
+
/* best effort on platforms without POSIX modes */
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
try {
|
|
278
|
+
rmSync(tmp, { force: true })
|
|
279
|
+
} catch {
|
|
280
|
+
/* ignore cleanup failure */
|
|
281
|
+
}
|
|
282
|
+
throw err
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function writeUpdateCache(cache) {
|
|
287
|
+
writePrivateJsonFile(updateCheckPath(), cache)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Hidden `__update-check` subcommand: fetch latest from npm, cache it. Best-effort.
|
|
291
|
+
async function refreshUpdateCache() {
|
|
292
|
+
const cache = readUpdateCache()
|
|
293
|
+
try {
|
|
294
|
+
const { name } = pkgInfo()
|
|
295
|
+
if (!name) return 0
|
|
296
|
+
const ctrl = new AbortController()
|
|
297
|
+
const t = setTimeout(() => ctrl.abort(), 5000)
|
|
298
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
|
|
299
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
300
|
+
signal: ctrl.signal,
|
|
301
|
+
}).finally(() => clearTimeout(t))
|
|
302
|
+
if (!res.ok) return 0
|
|
303
|
+
const data = await res.json()
|
|
304
|
+
const latest = data?.['dist-tags']?.latest
|
|
305
|
+
if (isSafeVersion(latest)) cache.latest = latest
|
|
306
|
+
} catch {
|
|
307
|
+
/* offline / registry down — never surface */
|
|
308
|
+
} finally {
|
|
309
|
+
try {
|
|
310
|
+
writeUpdateCache({ ...cache, lastCheck: Date.now() })
|
|
311
|
+
} catch {
|
|
312
|
+
/* cache is best-effort */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return 0
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Print an update notice from cache (if any) and kick off a background refresh
|
|
319
|
+
// when the cache is stale. Must never block or throw, and never write to stdout.
|
|
320
|
+
function notifyUpdate() {
|
|
321
|
+
try {
|
|
322
|
+
if (process.env.NO_UPDATE_NOTIFIER || process.env.CI || !process.stdout.isTTY || !process.stderr.isTTY) return
|
|
323
|
+
const { name, version } = pkgInfo()
|
|
324
|
+
if (!name || !version) return
|
|
325
|
+
const cache = readUpdateCache()
|
|
326
|
+
if (isSafeVersion(cache.latest) && compareVersions(cache.latest, version) > 0) {
|
|
327
|
+
process.stderr.write(
|
|
328
|
+
`\nUpdate available for ${name}: ${version} → ${cache.latest}\n` +
|
|
329
|
+
`Run: npm i -g ${name}@latest\n\n`,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
const DAY_MS = 86_400_000
|
|
333
|
+
const lastCheck = Number(cache.lastCheck)
|
|
334
|
+
if (!Number.isFinite(lastCheck) || Date.now() - lastCheck > DAY_MS) {
|
|
335
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '__update-check'], {
|
|
336
|
+
detached: true,
|
|
337
|
+
stdio: 'ignore',
|
|
338
|
+
env: updateCheckEnv(),
|
|
339
|
+
windowsHide: true,
|
|
340
|
+
})
|
|
341
|
+
child.unref()
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
/* never break the CLI over an update check */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
165
348
|
export function kebab(s) {
|
|
166
349
|
return s
|
|
167
350
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
@@ -368,15 +551,139 @@ function validateSpec(spec, source) {
|
|
|
368
551
|
return spec
|
|
369
552
|
}
|
|
370
553
|
|
|
371
|
-
|
|
372
|
-
|
|
554
|
+
// The API changes often (with breaking changes), so the live spec is the source
|
|
555
|
+
// of truth: commands/params always match the API. The bundled snapshot is only
|
|
556
|
+
// an offline fallback. A short on-disk cache avoids re-downloading the (large)
|
|
557
|
+
// spec on every command.
|
|
558
|
+
const CLI_VERSION_HEADER = 'x-wevion-cli-version'
|
|
559
|
+
const MIN_CLI_VERSION_HEADER = 'x-wevion-min-cli-version'
|
|
560
|
+
|
|
561
|
+
class UpgradeRequiredError extends Error {}
|
|
562
|
+
|
|
563
|
+
function cliVersion() {
|
|
564
|
+
return pkgInfo().version || '0.0.0'
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function specCachePath(env = process.env, platform = process.platform) {
|
|
568
|
+
return join(dirname(configPath(env, platform)), 'spec-cache.json')
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function specTtlMs(env = process.env) {
|
|
572
|
+
const parsed = Number(env.WEVION_SPEC_TTL_MS)
|
|
573
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 300_000 // 5 min
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function specTimeoutMs(env = process.env) {
|
|
577
|
+
const parsed = Number(env.WEVION_SPEC_TIMEOUT_MS)
|
|
578
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SPEC_TIMEOUT_MS
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function specSignalFromEnv(env = process.env) {
|
|
582
|
+
return AbortSignal.timeout(specTimeoutMs(env))
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Extract the server's upgrade message (426 body), or a sensible default.
|
|
586
|
+
export function upgradeMessage(text, minVersion) {
|
|
587
|
+
try {
|
|
588
|
+
const j = JSON.parse(text)
|
|
589
|
+
if (j && typeof j.message === 'string') return j.message
|
|
590
|
+
} catch {
|
|
591
|
+
/* not JSON */
|
|
592
|
+
}
|
|
593
|
+
return (
|
|
594
|
+
`This @wevion/cli version (${cliVersion()}) is no longer supported.` +
|
|
595
|
+
`${minVersion ? ` Minimum: ${minVersion}.` : ''} Run: npm i -g @wevion/cli@latest`
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function readSpecCache(cachePath) {
|
|
600
|
+
try {
|
|
601
|
+
const cache = JSON.parse(readFileSync(cachePath, 'utf8'))
|
|
602
|
+
return cache && typeof cache === 'object' ? cache : null
|
|
603
|
+
} catch {
|
|
604
|
+
return null
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function validCachedSpec(cache, baseUrl, cachePath) {
|
|
609
|
+
if (!cache || cache.baseUrl !== baseUrl || !Number.isFinite(Number(cache.fetchedAt))) return null
|
|
610
|
+
try {
|
|
611
|
+
return validateSpec(cache.spec, cachePath)
|
|
612
|
+
} catch {
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function assertCliVersionSupported(baseUrl) {
|
|
618
|
+
const url = `${baseUrl.replace(/\/$/, '')}/docs/json`
|
|
619
|
+
try {
|
|
620
|
+
const res = await fetch(url, {
|
|
621
|
+
method: 'HEAD',
|
|
622
|
+
headers: { [CLI_VERSION_HEADER]: cliVersion() },
|
|
623
|
+
signal: specSignalFromEnv(),
|
|
624
|
+
})
|
|
625
|
+
if (res.status === 426) {
|
|
626
|
+
throw new UpgradeRequiredError(upgradeMessage('', res.headers.get(MIN_CLI_VERSION_HEADER)))
|
|
627
|
+
}
|
|
628
|
+
} catch (err) {
|
|
629
|
+
if (err instanceof UpgradeRequiredError) throw err
|
|
630
|
+
/* A failed probe is not fatal; the cached spec still preserves offline list/help. */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function fallbackSpec(err, cacheSpec) {
|
|
635
|
+
if (cacheSpec) {
|
|
636
|
+
process.stderr.write(`warning: API spec unreachable, using cached copy (${err.message})\n`)
|
|
637
|
+
return { spec: cacheSpec, source: 'stale-cache' }
|
|
638
|
+
}
|
|
373
639
|
const bundled = join(dirname(fileURLToPath(import.meta.url)), '..', 'openapi.json')
|
|
374
640
|
try {
|
|
375
|
-
|
|
641
|
+
const spec = validateSpec(JSON.parse(readFileSync(bundled, 'utf8')), bundled)
|
|
642
|
+
process.stderr.write(`warning: API spec unreachable, using bundled snapshot (${err.message})\n`)
|
|
643
|
+
return { spec, source: 'bundled' }
|
|
644
|
+
} catch {
|
|
645
|
+
throw err
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function resolveSpec(baseUrl) {
|
|
650
|
+
const cachePath = specCachePath()
|
|
651
|
+
const cache = readSpecCache(cachePath)
|
|
652
|
+
const cacheSpec = validCachedSpec(cache, baseUrl, cachePath)
|
|
653
|
+
if (cacheSpec && Date.now() - Number(cache.fetchedAt) < specTtlMs()) {
|
|
654
|
+
await assertCliVersionSupported(baseUrl)
|
|
655
|
+
return { spec: cacheSpec, source: 'cache' }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const url = `${baseUrl.replace(/\/$/, '')}/docs/json`
|
|
659
|
+
let res
|
|
660
|
+
let text
|
|
661
|
+
try {
|
|
662
|
+
res = await fetch(url, {
|
|
663
|
+
headers: { [CLI_VERSION_HEADER]: cliVersion() },
|
|
664
|
+
signal: specSignalFromEnv(),
|
|
665
|
+
})
|
|
666
|
+
text = await res.text()
|
|
667
|
+
} catch (err) {
|
|
668
|
+
return fallbackSpec(err, cacheSpec)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (res.status === 426) throw new UpgradeRequiredError(upgradeMessage(text, res.headers.get(MIN_CLI_VERSION_HEADER)))
|
|
672
|
+
if (!res.ok) return fallbackSpec(new Error(`cannot fetch spec from ${url}: ${res.status}`), cacheSpec)
|
|
673
|
+
|
|
674
|
+
let spec
|
|
675
|
+
try {
|
|
676
|
+
spec = validateSpec(JSON.parse(text), url)
|
|
376
677
|
} catch (err) {
|
|
377
|
-
|
|
378
|
-
return { source: 'remote', url: `${baseUrl.replace(/\/$/, '')}/docs/json` }
|
|
678
|
+
return fallbackSpec(new Error(`invalid OpenAPI spec from ${url}: ${err.message}`), cacheSpec)
|
|
379
679
|
}
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
writePrivateJsonFile(cachePath, { fetchedAt: Date.now(), baseUrl, spec })
|
|
683
|
+
} catch {
|
|
684
|
+
/* cache is best-effort */
|
|
685
|
+
}
|
|
686
|
+
return { spec, source: 'live' }
|
|
380
687
|
}
|
|
381
688
|
|
|
382
689
|
function timeoutMs(env = process.env) {
|
|
@@ -388,6 +695,37 @@ function signalFromEnv(env = process.env) {
|
|
|
388
695
|
return AbortSignal.timeout(timeoutMs(env))
|
|
389
696
|
}
|
|
390
697
|
|
|
698
|
+
function isLoopbackHostname(hostname) {
|
|
699
|
+
const h = hostname.toLowerCase()
|
|
700
|
+
return h === 'localhost' || h === '::1' || h === '[::1]' || /^127\./.test(h)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function assertSafeAuthenticatedUrl(rawUrl) {
|
|
704
|
+
const url = new URL(rawUrl)
|
|
705
|
+
if (url.username || url.password) {
|
|
706
|
+
throw new Error('base URL must not include credentials')
|
|
707
|
+
}
|
|
708
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) {
|
|
709
|
+
throw new Error('refusing to send API key to a non-HTTPS base URL')
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function redactUrl(rawUrl) {
|
|
714
|
+
try {
|
|
715
|
+
const url = new URL(rawUrl)
|
|
716
|
+
if (url.username) url.username = '[REDACTED]'
|
|
717
|
+
if (url.password) url.password = '[REDACTED]'
|
|
718
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
719
|
+
if (/token|secret|password|api[-_]?key|authorization|code/i.test(key)) {
|
|
720
|
+
url.searchParams.set(key, '[REDACTED]')
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return url.toString()
|
|
724
|
+
} catch {
|
|
725
|
+
return rawUrl
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
391
729
|
function resolveBaseUrl(argv, config, env = process.env) {
|
|
392
730
|
const globalOptions = { 'base-url': { type: 'string' } }
|
|
393
731
|
try {
|
|
@@ -397,12 +735,127 @@ function resolveBaseUrl(argv, config, env = process.env) {
|
|
|
397
735
|
allowPositionals: true,
|
|
398
736
|
strict: false,
|
|
399
737
|
})
|
|
400
|
-
return values['base-url'] || env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
|
|
738
|
+
return (typeof values['base-url'] === 'string' && values['base-url']) || env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
|
|
401
739
|
} catch {
|
|
402
740
|
return env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
|
|
403
741
|
}
|
|
404
742
|
}
|
|
405
743
|
|
|
744
|
+
function stripLeadingGlobalOptions(argv) {
|
|
745
|
+
for (let i = 0; i < argv.length; i++) {
|
|
746
|
+
const arg = argv[i]
|
|
747
|
+
if (arg === '--base-url') {
|
|
748
|
+
i += 1
|
|
749
|
+
continue
|
|
750
|
+
}
|
|
751
|
+
if (arg.startsWith('--base-url=')) continue
|
|
752
|
+
return argv.slice(i)
|
|
753
|
+
}
|
|
754
|
+
return []
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function parseJsonOutputFlag(args) {
|
|
758
|
+
const { values } = parseArgs({
|
|
759
|
+
args,
|
|
760
|
+
options: { json: { type: 'boolean' }, 'base-url': { type: 'string' } },
|
|
761
|
+
allowPositionals: false,
|
|
762
|
+
})
|
|
763
|
+
return Boolean(values.json)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function schemaMeta(schema = {}) {
|
|
767
|
+
return {
|
|
768
|
+
type: schema.type || 'string',
|
|
769
|
+
...(schema.format ? { format: schema.format } : {}),
|
|
770
|
+
...(schema.default !== undefined ? { default: schema.default } : {}),
|
|
771
|
+
...(schema.enum ? { enum: schema.enum } : {}),
|
|
772
|
+
...(schema.description ? { description: schema.description } : {}),
|
|
773
|
+
...(schema.items ? { items: schemaMeta(schema.items) } : {}),
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Machine-readable view of a command, for agents (list --json / help --json).
|
|
778
|
+
export function opToJson(op) {
|
|
779
|
+
return {
|
|
780
|
+
command: op.id,
|
|
781
|
+
method: op.method,
|
|
782
|
+
path: op.path,
|
|
783
|
+
summary: op.summary,
|
|
784
|
+
tags: op.tags,
|
|
785
|
+
params: op.params
|
|
786
|
+
.filter((p) => p.in === 'path' || p.in === 'query' || p.in === 'header')
|
|
787
|
+
.map((p) => ({
|
|
788
|
+
flag: `--${p.name}`,
|
|
789
|
+
in: p.in,
|
|
790
|
+
required: Boolean(p.required),
|
|
791
|
+
...(p.description && !p.schema?.description ? { description: p.description } : {}),
|
|
792
|
+
...schemaMeta(p.schema),
|
|
793
|
+
array: p.schema?.type === 'array',
|
|
794
|
+
})),
|
|
795
|
+
body: op.bodyFields.length
|
|
796
|
+
? {
|
|
797
|
+
flags: op.bodyFields.map((f) => ({
|
|
798
|
+
flag: `--${f.flag}`,
|
|
799
|
+
field: f.name,
|
|
800
|
+
required: op.bodyRequired.includes(f.name),
|
|
801
|
+
...schemaMeta(f.schema),
|
|
802
|
+
})),
|
|
803
|
+
raw: "--json '<json>'",
|
|
804
|
+
}
|
|
805
|
+
: null,
|
|
806
|
+
...(op.unsupported ? { unsupported: op.unsupported } : {}),
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// One-shot primer so an LLM/agent with no Wevion knowledge can use the CLI.
|
|
811
|
+
function printAgent() {
|
|
812
|
+
const { name = '@wevion/cli', version = '?' } = pkgInfo()
|
|
813
|
+
process.stdout.write(`# ${name} — agent guide (v${version})
|
|
814
|
+
|
|
815
|
+
Command-line access to the Wevion advertising API. Everything you need is here;
|
|
816
|
+
no prior Wevion knowledge required.
|
|
817
|
+
|
|
818
|
+
## Model
|
|
819
|
+
- Every command maps to ONE Wevion REST endpoint. The command id is the
|
|
820
|
+
kebab-case operationId, e.g. \`get-api-v1-ad-accounts\` -> GET /api/v1/ad-accounts.
|
|
821
|
+
- Commands are derived LIVE from the API's OpenAPI spec, so they always match
|
|
822
|
+
the current API.
|
|
823
|
+
|
|
824
|
+
## Auth
|
|
825
|
+
- Set the API key: \`WEVION_API_KEY=<key>\` (env) or run \`wevion login\`.
|
|
826
|
+
- Create a key in the Wevion app under Settings → API keys (permission-scoped).
|
|
827
|
+
|
|
828
|
+
## Discover (machine-readable)
|
|
829
|
+
- \`wevion list --json\` → all commands: [{command, method, path, summary, tags, params, body}]
|
|
830
|
+
- \`wevion help <command> --json\` → one command's exact params/body schema
|
|
831
|
+
|
|
832
|
+
## Invoke
|
|
833
|
+
- Path, query and supported header params are flags using the exact OpenAPI
|
|
834
|
+
name. Names can be camelCase or snake_case (\`--accountId\`, \`--ad_id\`,
|
|
835
|
+
\`--folderId\`, \`--limit\`). Get exact names from \`help <command> --json\`;
|
|
836
|
+
do not guess.
|
|
837
|
+
- Array params: repeat the flag or pass a comma-separated list.
|
|
838
|
+
- Request body: pass fields as flags, or the whole JSON via \`--json '<json>'\`.
|
|
839
|
+
|
|
840
|
+
## Output & exit codes
|
|
841
|
+
- Success: JSON on stdout, exit 0.
|
|
842
|
+
- API error: request line + JSON body on stderr, exit 1.
|
|
843
|
+
- Usage error (unknown command / missing required flag / no API key): exit 2.
|
|
844
|
+
- Upgrade required (CLI too old for the API): message on stderr, exit 3
|
|
845
|
+
→ run \`npm i -g ${name}@latest\`.
|
|
846
|
+
|
|
847
|
+
## Config
|
|
848
|
+
- Base URL: \`--base-url\` | \`WEVION_BASE_URL\` | stored login config |
|
|
849
|
+
default ${DEFAULT_BASE_URL} (staging: https://api-stage.wevion.ai).
|
|
850
|
+
|
|
851
|
+
## Typical flow
|
|
852
|
+
1. wevion agent # this guide
|
|
853
|
+
2. wevion list --json # find the command
|
|
854
|
+
3. wevion help <command> --json # get its params
|
|
855
|
+
4. wevion <command> --flag value ... # run it; parse JSON from stdout
|
|
856
|
+
`)
|
|
857
|
+
}
|
|
858
|
+
|
|
406
859
|
function printList(ops) {
|
|
407
860
|
const byTag = new Map()
|
|
408
861
|
for (const op of ops.values()) {
|
|
@@ -435,33 +888,55 @@ function printHelp(op) {
|
|
|
435
888
|
}
|
|
436
889
|
|
|
437
890
|
async function main(argv) {
|
|
438
|
-
const
|
|
891
|
+
const commandArgv = stripLeadingGlobalOptions(argv)
|
|
892
|
+
const [cmd, ...rest] = commandArgv
|
|
439
893
|
|
|
894
|
+
if (cmd === '__update-check') return refreshUpdateCache()
|
|
440
895
|
if (cmd === 'login') return login(rest)
|
|
441
896
|
if (cmd === 'logout') return logout()
|
|
897
|
+
if (cmd === 'agent') {
|
|
898
|
+
printAgent()
|
|
899
|
+
return 0
|
|
900
|
+
}
|
|
442
901
|
|
|
443
902
|
const config = readConfig()
|
|
444
903
|
const baseUrl = resolveBaseUrl(argv, config)
|
|
445
904
|
|
|
446
|
-
const
|
|
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
|
-
}))
|
|
905
|
+
const { spec, source } = await resolveSpec(baseUrl)
|
|
453
906
|
const ops = listOperations(spec)
|
|
454
907
|
|
|
455
908
|
if (!cmd || cmd === 'list' || cmd === '--help' || cmd === '-h') {
|
|
456
|
-
|
|
909
|
+
let asJson = false
|
|
910
|
+
try {
|
|
911
|
+
asJson = parseJsonOutputFlag(rest)
|
|
912
|
+
} catch (err) {
|
|
913
|
+
return fail(err.message)
|
|
914
|
+
}
|
|
915
|
+
if (asJson) {
|
|
916
|
+
process.stdout.write(`${JSON.stringify([...ops.values()].map(opToJson), null, 2)}\n`)
|
|
917
|
+
return 0
|
|
918
|
+
}
|
|
919
|
+
process.stdout.write(`Wevion CLI — ${ops.size} commands (${source} spec)\n`)
|
|
457
920
|
printList(ops)
|
|
458
921
|
process.stdout.write(`\nUsage: wevion <command> [--param value ...]\n`)
|
|
459
|
-
process.stdout.write(` wevion
|
|
922
|
+
process.stdout.write(` wevion agent # guide for LLMs/agents\n`)
|
|
923
|
+
process.stdout.write(` wevion list --json | help <command> [--json]\n`)
|
|
924
|
+
process.stdout.write(` wevion login | logout\n`)
|
|
460
925
|
return 0
|
|
461
926
|
}
|
|
462
927
|
if (cmd === 'help') {
|
|
463
928
|
const op = ops.get(rest[0])
|
|
464
929
|
if (!op) return fail(`unknown command: ${rest[0] || '(none)'}`)
|
|
930
|
+
let asJson = false
|
|
931
|
+
try {
|
|
932
|
+
asJson = parseJsonOutputFlag(rest.slice(1))
|
|
933
|
+
} catch (err) {
|
|
934
|
+
return fail(err.message)
|
|
935
|
+
}
|
|
936
|
+
if (asJson) {
|
|
937
|
+
process.stdout.write(`${JSON.stringify(opToJson(op), null, 2)}\n`)
|
|
938
|
+
return 0
|
|
939
|
+
}
|
|
465
940
|
printHelp(op)
|
|
466
941
|
return 0
|
|
467
942
|
}
|
|
@@ -469,32 +944,62 @@ async function main(argv) {
|
|
|
469
944
|
const op = ops.get(cmd)
|
|
470
945
|
if (!op) return fail(`unknown command: ${cmd} (try "wevion list")`)
|
|
471
946
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
947
|
+
let values
|
|
948
|
+
try {
|
|
949
|
+
;({ values } = parseArgs({
|
|
950
|
+
args: rest,
|
|
951
|
+
options: buildParseOptions(op),
|
|
952
|
+
allowPositionals: false,
|
|
953
|
+
}))
|
|
954
|
+
} catch (err) {
|
|
955
|
+
return fail(err.message)
|
|
956
|
+
}
|
|
477
957
|
|
|
478
958
|
const apiKey = resolveApiKey(process.env, config)
|
|
479
959
|
if (!apiKey) return fail('no API key — run "wevion login" or set WEVION_API_KEY')
|
|
480
960
|
|
|
481
|
-
|
|
961
|
+
let req
|
|
962
|
+
try {
|
|
963
|
+
req = buildRequest(op, values, values['base-url'] || baseUrl)
|
|
964
|
+
assertSafeAuthenticatedUrl(req.url)
|
|
965
|
+
} catch (err) {
|
|
966
|
+
return fail(err.message)
|
|
967
|
+
}
|
|
482
968
|
const res = await fetch(req.url, {
|
|
483
969
|
method: req.method,
|
|
484
970
|
headers: {
|
|
485
971
|
...req.headers,
|
|
486
972
|
'x-api-key': apiKey,
|
|
973
|
+
[CLI_VERSION_HEADER]: cliVersion(),
|
|
487
974
|
...(req.body ? { 'content-type': 'application/json' } : {}),
|
|
488
975
|
},
|
|
489
976
|
body: req.body,
|
|
490
977
|
signal: signalFromEnv(),
|
|
491
978
|
})
|
|
492
|
-
const
|
|
979
|
+
const bytes = Buffer.from(await res.arrayBuffer())
|
|
980
|
+
const text = bytes.toString('utf8')
|
|
981
|
+
if (res.status === 426) throw new UpgradeRequiredError(upgradeMessage(text, res.headers.get(MIN_CLI_VERSION_HEADER)))
|
|
493
982
|
if (!res.ok) {
|
|
494
|
-
process.stderr.write(`${req.method} ${req.url} -> ${res.status}\n`)
|
|
983
|
+
process.stderr.write(`${req.method} ${redactUrl(req.url)} -> ${res.status}\n`)
|
|
495
984
|
if (text) process.stderr.write(text.endsWith('\n') ? text : text + '\n')
|
|
496
985
|
return 1
|
|
497
986
|
}
|
|
987
|
+
const contentType = res.headers.get('content-type') || ''
|
|
988
|
+
if (res.status === 204 || bytes.length === 0) {
|
|
989
|
+
process.stdout.write(`${JSON.stringify({ ok: true, status: res.status })}\n`)
|
|
990
|
+
return 0
|
|
991
|
+
}
|
|
992
|
+
if (!contentType.includes('application/json')) {
|
|
993
|
+
process.stdout.write(
|
|
994
|
+
`${JSON.stringify({ ok: true, status: res.status, contentType, bodyBase64: bytes.toString('base64') })}\n`,
|
|
995
|
+
)
|
|
996
|
+
return 0
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
JSON.parse(text)
|
|
1000
|
+
} catch {
|
|
1001
|
+
throw new Error(`API returned invalid JSON for ${req.method} ${req.url}`)
|
|
1002
|
+
}
|
|
498
1003
|
process.stdout.write(text.endsWith('\n') ? text : text + '\n')
|
|
499
1004
|
return 0
|
|
500
1005
|
}
|
|
@@ -515,9 +1020,17 @@ function isExecutedAsBin() {
|
|
|
515
1020
|
|
|
516
1021
|
// Run only when executed as a bin, not when imported by selftest.
|
|
517
1022
|
if (isExecutedAsBin()) {
|
|
518
|
-
|
|
519
|
-
|
|
1023
|
+
const cliArgs = process.argv.slice(2)
|
|
1024
|
+
main(cliArgs)
|
|
1025
|
+
.then((code) => {
|
|
1026
|
+
if (cliArgs[0] !== '__update-check') notifyUpdate()
|
|
1027
|
+
process.exit(code)
|
|
1028
|
+
})
|
|
520
1029
|
.catch((err) => {
|
|
1030
|
+
if (err instanceof UpgradeRequiredError) {
|
|
1031
|
+
process.stderr.write(`\n${err.message}\n\n`)
|
|
1032
|
+
process.exit(3)
|
|
1033
|
+
}
|
|
521
1034
|
process.stderr.write(`error: ${err.message}\n`)
|
|
522
1035
|
process.exit(1)
|
|
523
1036
|
})
|