@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/selftest.mjs CHANGED
@@ -1,11 +1,25 @@
1
1
  // Minimal runnable check for the spec->request mapping. Run: node selftest.mjs
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtempSync, symlinkSync } from 'node:fs'
3
+ import { mkdtempSync, statSync } from 'node:fs'
4
4
  import { readFile } from 'node:fs/promises'
5
+ import { createServer } from 'node:http'
5
6
  import { tmpdir } from 'node:os'
6
7
  import { join } from 'node:path'
7
- import { execFileSync } from 'node:child_process'
8
- import { kebab, listOperations, buildRequest, configPath, resolveApiKey } from './src/index.mjs'
8
+ import { spawn } from 'node:child_process'
9
+ import { fileURLToPath } from 'node:url'
10
+ import {
11
+ kebab,
12
+ listOperations,
13
+ buildRequest,
14
+ configPath,
15
+ resolveApiKey,
16
+ compareVersions,
17
+ updateCheckPath,
18
+ upgradeMessage,
19
+ opToJson,
20
+ isSafeVersion,
21
+ updateCheckEnv,
22
+ } from './src/index.mjs'
9
23
 
10
24
  const spec = {
11
25
  openapi: '3.0.3',
@@ -155,9 +169,10 @@ assert.deepEqual(JSON.parse(message.body), { id: 'msg_1', content: 'ciao' })
155
169
  assert.throws(() => buildRequest(ops.get('upload-file'), {}, 'x'), /request body media type is not supported/)
156
170
 
157
171
  // config path honours XDG_CONFIG_HOME, falls back to ~/.config
158
- assert.equal(configPath({ XDG_CONFIG_HOME: '/x' }), '/x/wevion/config.json')
159
- assert.equal(configPath({ HOME: '/home/u' }), '/home/u/.config/wevion/config.json')
160
- assert.equal(configPath({ APPDATA: 'C:\\Users\\u\\AppData\\Roaming' }, 'win32'), 'C:\\Users\\u\\AppData\\Roaming/Wevion/config.json')
172
+ assert.equal(configPath({ XDG_CONFIG_HOME: '/x' }, 'linux'), '/x/wevion/config.json')
173
+ assert.equal(configPath({ HOME: '/home/u' }, 'linux'), '/home/u/.config/wevion/config.json')
174
+ const appData = 'C:\\Users\\u\\AppData\\Roaming'
175
+ assert.equal(configPath({ APPDATA: appData }, 'win32'), join(appData, 'Wevion', 'config.json'))
161
176
 
162
177
  // api key precedence: env > config
163
178
  assert.equal(resolveApiKey({ WEVION_API_KEY: 'env' }, { apiKey: 'cfg' }), 'env')
@@ -170,14 +185,245 @@ assert.match(bundled.openapi, /^3\./)
170
185
  assert.ok(Object.keys(bundled.paths || {}).length > 0)
171
186
  const knownCommand = listOperations(bundled).keys().next().value
172
187
  assert.ok(knownCommand)
188
+ const knownSimpleCommand = 'get-api-v1-ad-accounts'
189
+ assert.ok(listOperations(bundled).has(knownSimpleCommand))
173
190
 
174
191
  const tmp = mkdtempSync(join(tmpdir(), 'wevion-cli-'))
175
- const symlink = join(tmp, 'wevion')
176
- symlinkSync(new URL('./src/index.mjs', import.meta.url), symlink)
177
- const help = execFileSync(process.execPath, [symlink, 'help', knownCommand, '--base-url=http://127.0.0.1:9'], {
178
- encoding: 'utf8',
179
- env: { ...process.env, WEVION_API_KEY: '' },
192
+ const cli = fileURLToPath(new URL('./src/index.mjs', import.meta.url))
193
+ const childBaseEnv = {
194
+ PATH: process.env.PATH || '',
195
+ Path: process.env.Path || '',
196
+ SystemRoot: process.env.SystemRoot || '',
197
+ WINDIR: process.env.WINDIR || '',
198
+ TEMP: process.env.TEMP || tmp,
199
+ TMP: process.env.TMP || tmp,
200
+ CI: '1',
201
+ NO_UPDATE_NOTIFIER: '1',
202
+ WEVION_API_KEY: '',
203
+ WEVION_TIMEOUT_MS: '1000',
204
+ WEVION_SPEC_TIMEOUT_MS: '500',
205
+ XDG_CONFIG_HOME: tmp,
206
+ HOME: tmp,
207
+ APPDATA: tmp,
208
+ }
209
+
210
+ function runCli(args, env = {}) {
211
+ return new Promise((resolve) => {
212
+ const child = spawn(process.execPath, [cli, ...args], {
213
+ env: { ...childBaseEnv, ...env },
214
+ stdio: ['ignore', 'pipe', 'pipe'],
215
+ })
216
+ let stdout = ''
217
+ let stderr = ''
218
+ child.stdout.setEncoding('utf8')
219
+ child.stderr.setEncoding('utf8')
220
+ child.stdout.on('data', (chunk) => {
221
+ stdout += chunk
222
+ })
223
+ child.stderr.on('data', (chunk) => {
224
+ stderr += chunk
225
+ })
226
+ child.on('error', (err) => {
227
+ resolve({ status: 1, stdout, stderr: `${stderr}${err.message}` })
228
+ })
229
+ child.on('close', (status) => {
230
+ resolve({ status, stdout, stderr })
231
+ })
232
+ })
233
+ }
234
+
235
+ const offlineHelp = await runCli(['help', knownCommand, '--base-url=http://127.0.0.1:9'])
236
+ assert.equal(offlineHelp.status, 0)
237
+ assert.match(offlineHelp.stdout, /Flags:/)
238
+ const leadingBaseUrlList = await runCli(['--base-url=http://127.0.0.1:9', 'list'])
239
+ assert.equal(leadingBaseUrlList.status, 0)
240
+ assert.match(leadingBaseUrlList.stdout, /Wevion CLI/)
241
+ const leadingBaseUrlHelpJson = await runCli(['--base-url', 'http://127.0.0.1:9', 'help', knownCommand, '--json'])
242
+ assert.equal(leadingBaseUrlHelpJson.status, 0)
243
+ assert.equal(JSON.parse(leadingBaseUrlHelpJson.stdout).command, knownCommand)
244
+
245
+ const liveSpec = {
246
+ openapi: '3.0.3',
247
+ security: [{ apiKeyAuth: [] }],
248
+ paths: {
249
+ '/api/v1/sentinel': {
250
+ get: {
251
+ operationId: 'sentinelLiveSpec',
252
+ tags: ['sentinel'],
253
+ summary: 'sentinel-live-spec',
254
+ responses: { 200: { description: 'OK' } },
255
+ },
256
+ },
257
+ '/api/v1/no-content': {
258
+ get: {
259
+ operationId: 'noContentSuccess',
260
+ tags: ['sentinel'],
261
+ summary: 'no-content-success',
262
+ responses: { 204: { description: 'No content' } },
263
+ },
264
+ },
265
+ '/api/v1/error': {
266
+ get: {
267
+ operationId: 'errorWithSecret',
268
+ tags: ['sentinel'],
269
+ summary: 'error-with-secret',
270
+ parameters: [{ name: 'api_key', in: 'query', schema: { type: 'string' } }],
271
+ responses: { 500: { description: 'Error' } },
272
+ },
273
+ },
274
+ },
275
+ }
276
+
277
+ let mode = 'live'
278
+ const hits = []
279
+ const server = createServer((req, res) => {
280
+ hits.push({ method: req.method, url: req.url, headers: req.headers })
281
+ if (req.url === '/docs/json') {
282
+ if (mode === 'upgrade') {
283
+ res.writeHead(426, {
284
+ 'content-type': 'application/json',
285
+ 'x-wevion-min-cli-version': '9.0.0',
286
+ })
287
+ res.end(JSON.stringify({ message: 'please upgrade test' }))
288
+ return
289
+ }
290
+ if (mode === 'invalid') {
291
+ res.writeHead(200, { 'content-type': 'text/html' })
292
+ res.end('<html>not openapi</html>')
293
+ return
294
+ }
295
+ res.writeHead(200, { 'content-type': 'application/json' })
296
+ if (req.method === 'HEAD') {
297
+ res.end()
298
+ return
299
+ }
300
+ res.end(JSON.stringify(liveSpec))
301
+ return
302
+ }
303
+ if (req.url === '/api/v1/no-content') {
304
+ res.writeHead(204)
305
+ res.end()
306
+ return
307
+ }
308
+ if (req.url?.startsWith('/api/v1/error')) {
309
+ res.writeHead(500, { 'content-type': 'application/json' })
310
+ res.end(JSON.stringify({ error: 'boom' }))
311
+ return
312
+ }
313
+ res.writeHead(404, { 'content-type': 'application/json' })
314
+ res.end(JSON.stringify({ error: 'not found' }))
315
+ })
316
+
317
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
318
+ try {
319
+ const baseUrl = `http://127.0.0.1:${server.address().port}`
320
+ const liveList = await runCli(['list', '--json'], { WEVION_BASE_URL: baseUrl })
321
+ assert.equal(liveList.status, 0)
322
+ assert.equal(liveList.stderr, '')
323
+ assert.ok(JSON.parse(liveList.stdout).find((op) => op.command === 'sentinel-live-spec'))
324
+ assert.equal(hits[0].url, '/docs/json')
325
+ assert.equal(hits[0].method, 'GET')
326
+ assert.equal(hits[0].headers['x-wevion-cli-version'], '1.0.0')
327
+ if (process.platform !== 'win32') {
328
+ assert.equal(statSync(join(tmp, 'wevion', 'spec-cache.json')).mode & 0o777, 0o600)
329
+ }
330
+
331
+ const cacheHitList = await runCli(['list', '--json'], { WEVION_BASE_URL: baseUrl })
332
+ assert.equal(cacheHitList.status, 0)
333
+ assert.ok(JSON.parse(cacheHitList.stdout).find((op) => op.command === 'sentinel-live-spec'))
334
+ assert.ok(hits.find((hit) => hit.method === 'HEAD' && hit.url === '/docs/json'))
335
+
336
+ mode = 'invalid'
337
+ const invalidSpecFallback = await runCli(['list', '--json'], {
338
+ WEVION_BASE_URL: baseUrl,
339
+ WEVION_SPEC_TTL_MS: '0',
340
+ })
341
+ assert.equal(invalidSpecFallback.status, 0)
342
+ assert.ok(JSON.parse(invalidSpecFallback.stdout).find((op) => op.command === 'sentinel-live-spec'))
343
+ assert.match(invalidSpecFallback.stderr, /invalid OpenAPI spec/)
344
+ mode = 'live'
345
+
346
+ const helpJson = await runCli(['help', 'sentinel-live-spec', '--json'], { WEVION_BASE_URL: baseUrl })
347
+ assert.equal(helpJson.status, 0)
348
+ assert.equal(JSON.parse(helpJson.stdout).command, 'sentinel-live-spec')
349
+
350
+ const agentGuide = await runCli(['agent'], { WEVION_BASE_URL: baseUrl })
351
+ assert.equal(agentGuide.status, 0)
352
+ assert.match(agentGuide.stdout, /agent guide/)
353
+ assert.equal(hits.filter((hit) => hit.url === '/docs/json').length, 4)
354
+
355
+ const badListFlag = await runCli(['list', '--json', '--bad'], { WEVION_BASE_URL: baseUrl })
356
+ assert.equal(badListFlag.status, 2)
357
+ const badHelpFlag = await runCli(['help', 'sentinel-live-spec', '--json', '--bad'], { WEVION_BASE_URL: baseUrl })
358
+ assert.equal(badHelpFlag.status, 2)
359
+
360
+ const usageError = await runCli(['no-content-success', '--definitely-unknown'], {
361
+ WEVION_BASE_URL: baseUrl,
362
+ WEVION_API_KEY: 'test-key',
363
+ })
364
+ assert.equal(usageError.status, 2)
365
+
366
+ const emptySuccess = await runCli(['no-content-success'], {
367
+ WEVION_BASE_URL: baseUrl,
368
+ WEVION_API_KEY: 'test-key',
369
+ })
370
+ assert.equal(emptySuccess.status, 0)
371
+ assert.deepEqual(JSON.parse(emptySuccess.stdout), { ok: true, status: 204 })
372
+
373
+ const unsafeBaseUrl = await runCli([knownSimpleCommand, '--base-url', `http://user:pass@127.0.0.1:${server.address().port}`], {
374
+ WEVION_API_KEY: 'test-key',
375
+ })
376
+ assert.equal(unsafeBaseUrl.status, 2)
377
+ assert.match(unsafeBaseUrl.stderr, /must not include credentials/)
378
+
379
+ const redactedError = await runCli(['error-with-secret', '--api_key', 'secret-value'], {
380
+ WEVION_BASE_URL: baseUrl,
381
+ WEVION_API_KEY: 'test-key',
382
+ })
383
+ assert.equal(redactedError.status, 1)
384
+ assert.match(redactedError.stderr, /api_key=%5BREDACTED%5D/)
385
+ assert.doesNotMatch(redactedError.stderr, /secret-value/)
386
+
387
+ mode = 'upgrade'
388
+ const upgrade = await runCli(['--base-url', baseUrl, 'list'], { WEVION_SPEC_TTL_MS: '0' })
389
+ assert.equal(upgrade.status, 3)
390
+ assert.equal(upgrade.stdout, '')
391
+ assert.match(upgrade.stderr, /please upgrade test/)
392
+ } finally {
393
+ await new Promise((resolve) => server.close(resolve))
394
+ }
395
+
396
+ // update notifier: version compare + cache path location
397
+ assert.ok(compareVersions('1.0.10', '1.0.2') > 0) // numeric, not lexical
398
+ assert.ok(compareVersions('1.0.2', '1.0.10') < 0)
399
+ assert.equal(compareVersions('1.0.3', '1.0.3'), 0)
400
+ assert.ok(compareVersions('1.1.0', '1.0.999') > 0)
401
+ assert.ok(compareVersions('1.0.0-beta.1', '1.0.0') < 0)
402
+ assert.ok(compareVersions('1.0.0', '1.0.0-beta.1') > 0)
403
+ assert.ok(isSafeVersion('1.0.0-beta.1'))
404
+ assert.equal(isSafeVersion('1.0.0\nbad'), false)
405
+ assert.equal(updateCheckPath({ XDG_CONFIG_HOME: '/x' }), '/x/wevion/update-check.json')
406
+ assert.deepEqual(updateCheckEnv({ HOME: '/h', WEVION_API_KEY: 'secret', HTTPS_PROXY: 'http://p' }), {
407
+ HOME: '/h',
408
+ HTTPS_PROXY: 'http://p',
180
409
  })
181
- assert.match(help, /Flags:/)
410
+
411
+ // 426 upgrade message: prefer the server-provided message, else a default
412
+ assert.equal(upgradeMessage('{"message":"upgrade now: npm i -g @wevion/cli@latest"}'), 'upgrade now: npm i -g @wevion/cli@latest')
413
+ assert.match(upgradeMessage('not json'), /no longer supported/)
414
+ assert.match(upgradeMessage('', '9.0.0'), /Minimum: 9\.0\.0/)
415
+
416
+ // opToJson: machine-readable command view for agents (list --json / help --json)
417
+ const getJson = opToJson(ops.get('get-ad-account-by-id'))
418
+ assert.equal(getJson.command, 'get-ad-account-by-id')
419
+ assert.equal(getJson.method, 'GET')
420
+ assert.deepEqual(
421
+ getJson.params.find((p) => p.flag === '--id'),
422
+ { flag: '--id', in: 'path', required: true, type: 'string', array: false },
423
+ )
424
+ assert.equal(getJson.body, null)
425
+ const postJson = opToJson(ops.get('create-campaign'))
426
+ assert.ok(postJson.body.flags.find((f) => f.field === 'name' && f.required === true))
427
+ assert.equal(postJson.body.raw, "--json '<json>'")
182
428
 
183
429
  console.log('ok')