mpx-api 1.1.0 → 1.2.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/bin/mpx-api.js CHANGED
@@ -36,9 +36,10 @@ program
36
36
  .name('mpx-api')
37
37
  .description('Developer-first API testing, mocking, and documentation CLI')
38
38
  .version(pkg.version)
39
- .option('--json', 'Output structured JSON (machine-readable)')
40
- .option('--quiet, -q', 'Suppress non-essential output')
41
- .option('--schema', 'Output JSON schema describing all commands and flags');
39
+ .enablePositionalOptions()
40
+ .passThroughOptions()
41
+ .option('-o, --output <format>', 'Output format: json for structured JSON (machine-readable)')
42
+ .option('-q, --quiet', 'Suppress non-essential output');
42
43
 
43
44
  // Register HTTP method commands (get, post, put, patch, delete, head, options)
44
45
  registerRequestCommands(program);
@@ -62,6 +63,79 @@ registerHistoryCommand(program);
62
63
  registerLoadCommand(program);
63
64
  registerDocsCommand(program);
64
65
 
66
+ // Update subcommand
67
+ program
68
+ .command('update')
69
+ .description('Check for updates and optionally install the latest version')
70
+ .option('--check', 'Only check for updates (do not install)')
71
+ .action(async (options, cmd) => {
72
+ const { checkForUpdate, performUpdate } = await import('../src/update.js');
73
+ const chalk = (await import('chalk')).default;
74
+ const jsonMode = cmd.parent?.opts()?.output === 'json';
75
+
76
+ try {
77
+ const info = checkForUpdate();
78
+
79
+ if (jsonMode) {
80
+ const output = {
81
+ current: info.current,
82
+ latest: info.latest,
83
+ updateAvailable: info.updateAvailable,
84
+ isGlobal: info.isGlobal
85
+ };
86
+
87
+ if (!options.check && info.updateAvailable) {
88
+ try {
89
+ const result = performUpdate(info.isGlobal);
90
+ output.updated = true;
91
+ output.newVersion = result.version;
92
+ } catch (err) {
93
+ output.updated = false;
94
+ output.error = err.message;
95
+ }
96
+ }
97
+
98
+ console.log(JSON.stringify(output, null, 2));
99
+ process.exit(0);
100
+ return;
101
+ }
102
+
103
+ // Human-readable output
104
+ if (!info.updateAvailable) {
105
+ console.log('');
106
+ console.log(chalk.green.bold(`✓ mpx-api v${info.current} is up to date`));
107
+ console.log('');
108
+ process.exit(0);
109
+ return;
110
+ }
111
+
112
+ console.log('');
113
+ console.log(chalk.yellow.bold(`⬆ Update available: v${info.current} → v${info.latest}`));
114
+
115
+ if (options.check) {
116
+ console.log(chalk.gray(`Run ${chalk.cyan('mpx-api update')} to install`));
117
+ console.log('');
118
+ process.exit(0);
119
+ return;
120
+ }
121
+
122
+ console.log(chalk.gray(`Installing v${info.latest}${info.isGlobal ? ' (global)' : ''}...`));
123
+
124
+ const result = performUpdate(info.isGlobal);
125
+ console.log(chalk.green.bold(`✓ Updated to v${result.version}`));
126
+ console.log('');
127
+ process.exit(0);
128
+ } catch (err) {
129
+ if (jsonMode) {
130
+ console.log(JSON.stringify({ error: err.message, code: 'ERR_UPDATE' }, null, 2));
131
+ } else {
132
+ console.error(chalk.red.bold('\n❌ Update check failed:'), err.message);
133
+ console.error('');
134
+ }
135
+ process.exit(1);
136
+ }
137
+ });
138
+
65
139
  // MCP subcommand
66
140
  program
67
141
  .command('mcp')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mpx-api",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Developer-first API testing, mocking, and documentation CLI with AI-native features (JSON output, MCP server)",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { parse, stringify } from 'yaml';
3
3
  import { ensureLocalDir } from '../lib/config.js';
4
- import { formatSuccess, formatError, formatInfo } from '../lib/output.js';
4
+ import { formatSuccess, formatError, formatInfo, formatWarning } from '../lib/output.js';
5
5
  import { runCollection } from '../lib/collection-runner.js';
6
6
  import { formatTestResults } from '../lib/output.js';
7
7
  import { join } from 'path';
@@ -95,7 +95,8 @@ export function registerCollectionCommands(program) {
95
95
  .description('Run a collection')
96
96
  .option('-e, --env <name>', 'Environment to use')
97
97
  .option('--base-url <url>', 'Override base URL')
98
- .action(async (file, options) => {
98
+ .option('--json', 'Output results as JSON')
99
+ .action(async (file, options, command) => {
99
100
  try {
100
101
  const collectionPath = file || join('.mpx-api', 'collection.yaml');
101
102
 
@@ -123,9 +124,17 @@ export function registerCollectionCommands(program) {
123
124
 
124
125
  const results = await runCollection(collection, { env, baseUrl });
125
126
 
126
- const allPassed = formatTestResults(results);
127
+ const globalOpts = command.optsWithGlobals();
128
+ const jsonOutput = options.json || globalOpts.output === 'json';
127
129
 
128
- process.exit(allPassed ? 0 : 1);
130
+ if (jsonOutput) {
131
+ console.log(JSON.stringify(results, null, 2));
132
+ const allPassed = results.every(r => r.passed);
133
+ process.exit(allPassed ? 0 : 1);
134
+ } else {
135
+ const allPassed = formatTestResults(results);
136
+ process.exit(allPassed ? 0 : 1);
137
+ }
129
138
  } catch (err) {
130
139
  formatError(err);
131
140
  process.exit(1);
@@ -170,6 +179,4 @@ export function registerCollectionCommands(program) {
170
179
  });
171
180
  }
172
181
 
173
- function formatWarning(message) {
174
- console.log(chalk.yellow('⚠'), message);
175
- }
182
+ // formatWarning imported from output.js
@@ -17,11 +17,12 @@ export function registerRequestCommands(program) {
17
17
  .option('--no-follow', 'Do not follow redirects')
18
18
  .option('--no-verify', 'Skip SSL certificate verification')
19
19
  .option('--timeout <ms>', 'Request timeout in milliseconds', '30000')
20
+ .option('-o, --output <format>', 'Output format (json)')
20
21
  .action(async (url, options, command) => {
21
22
  try {
22
23
  // Get global options
23
24
  const globalOpts = command.optsWithGlobals();
24
- const jsonOutput = globalOpts.json || false;
25
+ const jsonOutput = options.output === 'json' || globalOpts.output === 'json';
25
26
  const quiet = options.quiet || globalOpts.quiet || false;
26
27
 
27
28
  const client = new HttpClient({
@@ -82,7 +83,7 @@ export function registerRequestCommands(program) {
82
83
  }
83
84
  } catch (err) {
84
85
  const globalOpts = command.optsWithGlobals();
85
- if (globalOpts.json) {
86
+ if (options.output === 'json' || globalOpts.output === 'json') {
86
87
  console.log(JSON.stringify({ error: err.message, code: err.code || 'ERR_REQUEST' }));
87
88
  } else {
88
89
  formatError(err);
@@ -1,3 +1,5 @@
1
+ import { getNestedValue } from './utils.js';
2
+
1
3
  // Assertion engine for testing
2
4
 
3
5
  export function runAssertions(response, assertions) {
@@ -75,27 +77,7 @@ export function runAssertions(response, assertions) {
75
77
  return results;
76
78
  }
77
79
 
78
- function getNestedValue(obj, path) {
79
- const parts = path.split('.');
80
- let current = obj;
81
-
82
- for (const part of parts) {
83
- if (current === null || current === undefined) return undefined;
84
-
85
- // Handle array indexing
86
- const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
87
- if (arrayMatch) {
88
- const [, key, index] = arrayMatch;
89
- current = current[key];
90
- if (!Array.isArray(current)) return undefined;
91
- current = current[parseInt(index)];
92
- } else {
93
- current = current[part];
94
- }
95
- }
96
-
97
- return current;
98
- }
80
+ // getNestedValue imported from utils.js
99
81
 
100
82
  function evaluateOperators(actual, expected) {
101
83
  for (const [op, value] of Object.entries(expected)) {
@@ -1,8 +1,14 @@
1
1
  import { request } from 'undici';
2
2
  import { CookieJar } from 'tough-cookie';
3
3
  import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
4
6
  import { getCookieJarPath } from './config.js';
5
7
 
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const pkgVersion = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
11
+
6
12
  export class HttpClient {
7
13
  constructor(options = {}) {
8
14
  this.followRedirects = options.followRedirects !== false;
@@ -40,7 +46,7 @@ export class HttpClient {
40
46
 
41
47
  // Set default User-Agent, allow override via options.headers
42
48
  const defaultHeaders = {
43
- 'user-agent': 'mpx-api/1.0.1',
49
+ 'user-agent': `mpx-api/${pkgVersion}`,
44
50
  };
45
51
 
46
52
  // Merge headers, with user headers taking precedence
@@ -52,7 +58,18 @@ export class HttpClient {
52
58
  body: options.body,
53
59
  };
54
60
 
55
- // Note: undici follows redirects by default, maxRedirections removed in newer versions
61
+ // Handle redirects
62
+ if (!this.followRedirects) {
63
+ requestOptions.maxRedirections = 0;
64
+ }
65
+
66
+ // Handle timeout
67
+ if (this.timeout) {
68
+ const controller = new AbortController();
69
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
70
+ requestOptions.signal = controller.signal;
71
+ requestOptions._timeoutId = timeoutId;
72
+ }
56
73
 
57
74
  // Add cookies to request
58
75
  const cookies = await this.cookieJar.getCookies(url);
@@ -72,7 +89,10 @@ export class HttpClient {
72
89
  }
73
90
 
74
91
  try {
92
+ const timeoutId = requestOptions._timeoutId;
93
+ delete requestOptions._timeoutId;
75
94
  const response = await request(url, requestOptions);
95
+ if (timeoutId) clearTimeout(timeoutId);
76
96
 
77
97
  // Store cookies from response
78
98
  const setCookieHeaders = response.headers['set-cookie'];
@@ -119,7 +139,9 @@ export class HttpClient {
119
139
  };
120
140
  } catch (err) {
121
141
  // Handle network errors gracefully
122
- if (err.code === 'ENOTFOUND') {
142
+ if (err.name === 'AbortError' || err.code === 'UND_ERR_ABORTED') {
143
+ throw new Error(`Request timeout after ${this.timeout}ms`);
144
+ } else if (err.code === 'ENOTFOUND') {
123
145
  throw new Error(`DNS lookup failed for ${url}`);
124
146
  } else if (err.code === 'ECONNREFUSED') {
125
147
  throw new Error(`Connection refused to ${url}`);
@@ -1,3 +1,5 @@
1
+ import { getNestedValue } from './utils.js';
2
+
1
3
  // Template variable interpolation
2
4
  // Supports: {{varName}}, {{env.VAR}}, {{request-name.response.body.field}}
3
5
 
@@ -39,25 +41,4 @@ export function interpolateObject(obj, context = {}) {
39
41
  return obj;
40
42
  }
41
43
 
42
- function getNestedValue(obj, path) {
43
- // Handle array indexing: users[0].name
44
- const parts = path.split('.');
45
- let current = obj;
46
-
47
- for (const part of parts) {
48
- if (!current) return undefined;
49
-
50
- // Check for array indexing
51
- const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
52
- if (arrayMatch) {
53
- const [, key, index] = arrayMatch;
54
- current = current[key];
55
- if (!Array.isArray(current)) return undefined;
56
- current = current[parseInt(index)];
57
- } else {
58
- current = current[part];
59
- }
60
- }
61
-
62
- return current;
63
- }
44
+ // getNestedValue imported from utils.js
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Get a nested value from an object using dot notation.
3
+ * Supports array indexing: users[0].name
4
+ */
5
+ export function getNestedValue(obj, path) {
6
+ const parts = path.split('.');
7
+ let current = obj;
8
+
9
+ for (const part of parts) {
10
+ if (current === null || current === undefined) return undefined;
11
+
12
+ // Handle array indexing
13
+ const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
14
+ if (arrayMatch) {
15
+ const [, key, index] = arrayMatch;
16
+ current = current[key];
17
+ if (!Array.isArray(current)) return undefined;
18
+ current = current[parseInt(index)];
19
+ } else {
20
+ current = current[part];
21
+ }
22
+ }
23
+
24
+ return current;
25
+ }
package/src/schema.js CHANGED
@@ -69,16 +69,11 @@ export function getSchema() {
69
69
  type: 'number',
70
70
  default: 30000,
71
71
  description: 'Request timeout in milliseconds'
72
- },
73
- '--json-output': {
74
- type: 'boolean',
75
- default: false,
76
- description: 'Output results as structured JSON'
77
72
  }
78
73
  },
79
74
  output: {
80
75
  json: {
81
- description: 'Structured response data when --json-output is used',
76
+ description: 'Structured response data when --output json is used',
82
77
  schema: {
83
78
  type: 'object',
84
79
  properties: {
@@ -123,9 +118,9 @@ export function getSchema() {
123
118
  1: 'Request error or 4xx/5xx status'
124
119
  },
125
120
  examples: [
126
- { command: 'mpx-api get https://api.example.com/users --json-output', description: 'GET request with JSON output' },
127
- { command: 'mpx-api post https://api.example.com/users -j \'{"name":"John"}\' --json-output', description: 'POST JSON data with structured output' },
128
- { command: 'mpx-api get https://api.example.com/data -H "Authorization: Bearer token" --json-output', description: 'GET with custom headers' }
121
+ { command: 'mpx-api get https://api.example.com/users --output json', description: 'GET request with JSON output' },
122
+ { command: 'mpx-api post https://api.example.com/users -j \'{"name":"John"}\' --output json', description: 'POST JSON data with structured output' },
123
+ { command: 'mpx-api get https://api.example.com/data -H "Authorization: Bearer token"', description: 'GET with custom headers' }
129
124
  ]
130
125
  },
131
126
  collection: {
@@ -165,7 +160,7 @@ export function getSchema() {
165
160
  flags: {
166
161
  '-e, --env': { type: 'string', description: 'Environment name to use' },
167
162
  '--base-url': { type: 'string', description: 'Override base URL' },
168
- '--json-output': { type: 'boolean', description: 'Output results as JSON' }
163
+ '--json': { type: 'boolean', description: 'Output results as JSON' }
169
164
  }
170
165
  },
171
166
  list: {
@@ -177,32 +172,24 @@ export function getSchema() {
177
172
  env: {
178
173
  description: 'Manage environments',
179
174
  subcommands: {
180
- list: {
181
- usage: 'mpx-api env list',
182
- description: 'List all environments',
183
- flags: {
184
- '--json-output': { type: 'boolean', description: 'Output as JSON' }
185
- }
175
+ init: {
176
+ usage: 'mpx-api env init',
177
+ description: 'Initialize environments directory with dev, staging, production'
186
178
  },
187
179
  set: {
188
- usage: 'mpx-api env set <name> <key> <value>',
189
- description: 'Set an environment variable',
180
+ usage: 'mpx-api env set <environment> <variable>',
181
+ description: 'Set an environment variable (KEY=value format)',
190
182
  arguments: {
191
- name: { type: 'string', required: true, description: 'Environment name' },
192
- key: { type: 'string', required: true, description: 'Variable key' },
193
- value: { type: 'string', required: true, description: 'Variable value' }
183
+ environment: { type: 'string', required: true, description: 'Environment name' },
184
+ variable: { type: 'string', required: true, description: 'Variable in KEY=value format' }
194
185
  }
195
186
  },
196
- get: {
197
- usage: 'mpx-api env get <name> <key>',
198
- description: 'Get an environment variable',
199
- flags: {
200
- '--json-output': { type: 'boolean', description: 'Output as JSON' }
187
+ list: {
188
+ usage: 'mpx-api env list [environment]',
189
+ description: 'List all environments or variables in a specific environment',
190
+ arguments: {
191
+ environment: { type: 'string', required: false, description: 'Environment name (optional)' }
201
192
  }
202
- },
203
- delete: {
204
- usage: 'mpx-api env delete <name>',
205
- description: 'Delete an environment'
206
193
  }
207
194
  }
208
195
  },
@@ -221,11 +208,11 @@ export function getSchema() {
221
208
  description: 'Run API tests',
222
209
  usage: 'mpx-api test <file> [options]',
223
210
  arguments: {
224
- file: { type: 'string', required: true, description: 'Test file to run' }
211
+ file: { type: 'string', required: false, description: 'Test file to run (default: .mpx-api/collection.yaml)' }
225
212
  },
226
213
  flags: {
227
214
  '-e, --env': { type: 'string', description: 'Environment name' },
228
- '--json-output': { type: 'boolean', description: 'Output results as JSON' }
215
+ '--json': { type: 'boolean', description: 'Output results as JSON' }
229
216
  }
230
217
  },
231
218
  history: {
@@ -233,7 +220,7 @@ export function getSchema() {
233
220
  usage: 'mpx-api history [options]',
234
221
  flags: {
235
222
  '-n, --limit': { type: 'number', default: 10, description: 'Number of entries to show' },
236
- '--json-output': { type: 'boolean', description: 'Output as JSON' }
223
+ '--clear': { type: 'boolean', description: 'Clear history' }
237
224
  }
238
225
  },
239
226
  load: {
@@ -243,9 +230,10 @@ export function getSchema() {
243
230
  url: { type: 'string', required: true, description: 'Target URL' }
244
231
  },
245
232
  flags: {
246
- '-c, --concurrency': { type: 'number', default: 10, description: 'Concurrent requests' },
247
- '-n, --requests': { type: 'number', default: 100, description: 'Total requests' },
248
- '--json-output': { type: 'boolean', description: 'Output results as JSON' }
233
+ '--rps': { type: 'number', default: 10, description: 'Requests per second' },
234
+ '-d, --duration': { type: 'number', default: 10, description: 'Test duration in seconds' },
235
+ '-H, --header': { type: 'array', description: 'Add request headers' },
236
+ '--method': { type: 'string', default: 'GET', description: 'HTTP method' }
249
237
  }
250
238
  },
251
239
  docs: {
@@ -265,15 +253,26 @@ export function getSchema() {
265
253
  examples: [
266
254
  { command: 'mpx-api mcp', description: 'Start MCP stdio server' }
267
255
  ]
256
+ },
257
+ update: {
258
+ description: 'Check for updates and optionally install the latest version',
259
+ usage: 'mpx-api update [--check] [-o json]',
260
+ flags: {
261
+ '--check': { description: 'Only check for updates (do not install)', default: false }
262
+ },
263
+ examples: [
264
+ { command: 'mpx-api update', description: 'Check and install updates' },
265
+ { command: 'mpx-api update --check', description: 'Just check for updates' },
266
+ { command: 'mpx-api update --check -o json', description: 'Check for updates (JSON output)' }
267
+ ]
268
268
  }
269
269
  },
270
270
  globalFlags: {
271
- '--json-output': {
272
- type: 'boolean',
273
- default: false,
274
- description: 'Output structured JSON for machine consumption'
271
+ '-o, --output': {
272
+ type: 'string',
273
+ description: 'Output format: "json" for structured JSON for machine consumption'
275
274
  },
276
- '--quiet': {
275
+ '-q, --quiet': {
277
276
  type: 'boolean',
278
277
  default: false,
279
278
  description: 'Suppress non-essential output'
package/src/update.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Update Command
3
+ *
4
+ * Checks npm for the latest version of mpx-api and offers to update.
5
+ */
6
+
7
+ import { execSync } from 'child_process';
8
+ import { readFileSync } from 'fs';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
15
+
16
+ /**
17
+ * Check npm registry for latest version
18
+ * @returns {object} { current, latest, updateAvailable, isGlobal }
19
+ */
20
+ export function checkForUpdate() {
21
+ const current = pkg.version;
22
+
23
+ let latest;
24
+ try {
25
+ latest = execSync('npm view mpx-api version', { encoding: 'utf8', timeout: 10000 }).trim();
26
+ } catch (err) {
27
+ throw new Error('Failed to check npm registry: ' + (err.message || 'unknown error'));
28
+ }
29
+
30
+ const updateAvailable = latest !== current && compareVersions(latest, current) > 0;
31
+
32
+ // Detect if installed globally
33
+ let isGlobal = false;
34
+ try {
35
+ const globalDir = execSync('npm root -g', { encoding: 'utf8', timeout: 5000 }).trim();
36
+ isGlobal = __dirname.startsWith(globalDir) || process.argv[1]?.includes('node_modules/.bin');
37
+ } catch {
38
+ // Can't determine, assume local
39
+ }
40
+
41
+ return { current, latest, updateAvailable, isGlobal };
42
+ }
43
+
44
+ /**
45
+ * Perform the update
46
+ * @param {boolean} isGlobal - Install globally
47
+ * @returns {object} { success, version }
48
+ */
49
+ export function performUpdate(isGlobal) {
50
+ const cmd = isGlobal ? 'npm install -g mpx-api@latest' : 'npm install mpx-api@latest';
51
+ try {
52
+ execSync(cmd, { encoding: 'utf8', timeout: 60000, stdio: 'pipe' });
53
+ // Verify
54
+ const newVersion = execSync('npm view mpx-api version', { encoding: 'utf8', timeout: 10000 }).trim();
55
+ return { success: true, version: newVersion };
56
+ } catch (err) {
57
+ throw new Error('Update failed: ' + (err.message || 'unknown error'));
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Compare semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal.
63
+ */
64
+ export function compareVersions(a, b) {
65
+ const pa = a.split('.').map(Number);
66
+ const pb = b.split('.').map(Number);
67
+ for (let i = 0; i < 3; i++) {
68
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
69
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
70
+ }
71
+ return 0;
72
+ }