From 31fbe9f9ba60186fd7cf2e2c8ead0fe180ba0bf1 Mon Sep 17 00:00:00 2001 From: Garret Premo Date: Thu, 7 May 2026 06:22:34 -0400 Subject: [PATCH 1/3] feat: project-local CLI aliases for renaming verbose generated commands (#109) Add .apijack/aliases.json (with ~/./aliases.json fallback) for mapping typed aliases to canonical command paths. Argv is rewritten at CLI bootstrap before Commander parses; longest-prefix match wins, real command paths beat alias collisions, and unknown expansions emit a clear error without aborting the CLI. Routine engine and MCP tool resolution use canonical command names only and are unaffected. --- CLAUDE.md | 24 ++++ src/aliases.ts | 231 ++++++++++++++++++++++++++++++++ src/cli-builder.ts | 47 ++++++- tests/aliases.test.ts | 297 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 src/aliases.ts create mode 100644 tests/aliases.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 31e72b1..a83d451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,6 +243,30 @@ The `.apijack/` directory at a project root is auto-loaded when the CLI runs ins | `.apijack/plugins.ts` | Project-level plugin registrations (`default: ApijackPlugin[]` — each entry passed to `cli.use(...)`) | | `.apijack/routines/*.yaml` | Routines available via `routine run ` | | `.apijack/settings.json` | Framework defaults (see below) | +| `.apijack/aliases.json` | Project-local command aliases (see below) | + +### Command aliases + +Generated leaf-command names come from the OpenAPI `operationId` and can be verbose (e.g. `customers get-customer-order-summary`). `.apijack/aliases.json` is a flat map from a typed alias to the canonical command path: + +```json +{ + "customers list": "customers get-all-customers", + "customers summary": "customers get-customer-order-summary", + "cs": "customers get-customer-order-summary" +} +``` + +Resolution rules: + +- Argv is rewritten at CLI bootstrap, before Commander parses. `mycli cs 42 --foo bar` becomes `mycli customers get-customer-order-summary 42 --foo bar`. +- Single-token aliases (`cs`) and multi-token aliases (`customers summary`) use the same mechanism. +- Trailing positional args and flags pass through unchanged. +- Longest-prefix wins when multiple aliases could match. +- An alias that shadows a real command path emits a startup warning and the real command keeps winning. +- An expansion that doesn't resolve to a known command emits a startup error; the CLI continues without that alias applied. +- A global `~/./aliases.json` is also consulted; project-local entries override global entries on conflict. +- **Routines and MCP tool resolution use canonical names only.** Aliases are a CLI ergonomics layer; routine YAML and MCP tool names are not affected. ### Opt-in auth for custom commands and dispatchers diff --git a/src/aliases.ts b/src/aliases.ts new file mode 100644 index 0000000..8f92594 --- /dev/null +++ b/src/aliases.ts @@ -0,0 +1,231 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import type { Command } from 'commander'; + +export type AliasMap = Record; + +export interface LoadedAliases { + map: AliasMap; + errors: string[]; +} + +/** + * Load alias definitions from `/aliases.json` and `~/./aliases.json`. + * Project-local entries override global entries on conflict. + */ +export function loadAliases(configDir: string, cliName: string): LoadedAliases { + const errors: string[] = []; + const globalPath = join(homedir(), '.' + cliName, 'aliases.json'); + const projectPath = join(configDir, 'aliases.json'); + + const global = readAliasFile(globalPath, errors); + const project = projectPath === globalPath ? {} : readAliasFile(projectPath, errors); + + return { map: { ...global, ...project }, errors }; +} + +function readAliasFile(path: string, errors: string[]): AliasMap { + if (!existsSync(path)) return {}; + + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch (e) { + errors.push(`${path}: failed to read (${(e as Error).message})`); + + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + errors.push(`${path}: malformed JSON (${(e as Error).message})`); + + return {}; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + errors.push(`${path}: expected a JSON object mapping alias to canonical command`); + + return {}; + } + + const out: AliasMap = {}; + + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v !== 'string') { + errors.push(`${path}: alias "${k}" must map to a string`); + continue; + } + + const alias = k.trim(); + const expansion = v.trim(); + + if (!alias || !expansion) { + errors.push(`${path}: alias keys and values must be non-empty`); + continue; + } + + out[alias] = expansion; + } + + return out; +} + +/** + * Best-effort longest-prefix resolution of leading argv tokens through the + * alias map, without validating against the real command tree. + * + * Used for early bootstrap reads of argv[2]/argv[3] (auth-skip detection, + * plugins-check guard, dry-run heuristic) that happen before the Commander + * tree is built. The full validated rewrite happens later via `rewriteArgv`. + * + * If an alias key collides with a real command, this helper still applies the + * alias — but the late `rewriteArgv` pass will detect the shadow and skip the + * rewrite for actual execution. The early reads only care about a few + * built-in command names, where shadowing is highly unlikely in practice. + */ +export function resolveLeadingTokens(args: string[], aliases: AliasMap): string[] { + const keys = Object.keys(aliases); + + if (args.length === 0 || keys.length === 0) return args; + + const sortedAliases = keys.slice().sort((a, b) => { + const aTokens = a.split(/\s+/).length; + const bTokens = b.split(/\s+/).length; + + if (aTokens !== bTokens) return bTokens - aTokens; + + return b.length - a.length; + }); + + for (const alias of sortedAliases) { + const tokens = alias.split(/\s+/); + + if (args.length < tokens.length) continue; + + let matches = true; + + for (let i = 0; i < tokens.length; i++) { + if (args[i] !== tokens[i]) { + matches = false; + break; + } + } + + if (matches) { + const expansion = aliases[alias].split(/\s+/); + + return [...expansion, ...args.slice(tokens.length)]; + } + } + + return args; +} + +/** + * Walk the Commander program tree and collect every canonical command path + * (space-separated tokens, e.g. "customers get-all-customers"). + */ +export function collectCommandPaths(program: Command): Set { + const paths = new Set(); + + function walk(cmd: Command, prefix: string[]): void { + const here = [...prefix, cmd.name()]; + paths.add(here.join(' ')); + + for (const sub of cmd.commands) walk(sub, here); + } + + for (const sub of program.commands) walk(sub, []); + + return paths; +} + +export interface RewriteResult { + rewrittenArgs: string[]; + warnings: string[]; + errors: string[]; +} + +/** + * Apply alias rewriting to the user-supplied portion of argv (everything after + * the node binary and script path). + * + * - Aliases that match a real command path are skipped (warning emitted, real + * command keeps winning). + * - Aliases whose expansion does not resolve to a real command path are skipped + * (error emitted, CLI continues without the alias). + * - Longest-prefix wins: multi-token aliases are matched before shorter ones. + * - Trailing args (positional and flags) are appended verbatim. + */ +export function rewriteArgv( + args: string[], + aliases: AliasMap, + realPaths: Set, +): RewriteResult { + const warnings: string[] = []; + const errors: string[] = []; + const valid = new Map(); + + for (const [alias, expansion] of Object.entries(aliases)) { + if (realPaths.has(alias)) { + warnings.push( + `alias "${alias}" shadows a real command and will be ignored`, + ); + continue; + } + + if (!realPaths.has(expansion)) { + errors.push( + `alias "${alias}" → "${expansion}" does not resolve to a known command`, + ); + continue; + } + + valid.set(alias, expansion.split(/\s+/)); + } + + if (args.length === 0 || valid.size === 0) { + return { rewrittenArgs: args, warnings, errors }; + } + + const sortedAliases = [...valid.keys()].sort((a, b) => { + const aTokens = a.split(/\s+/).length; + const bTokens = b.split(/\s+/).length; + + if (aTokens !== bTokens) return bTokens - aTokens; + + return b.length - a.length; + }); + + for (const alias of sortedAliases) { + const tokens = alias.split(/\s+/); + + if (args.length < tokens.length) continue; + + let matches = true; + + for (let i = 0; i < tokens.length; i++) { + if (args[i] !== tokens[i]) { + matches = false; + break; + } + } + + if (matches) { + const expansion = valid.get(alias)!; + const tail = args.slice(tokens.length); + + return { + rewrittenArgs: [...expansion, ...tail], + warnings, + errors, + }; + } + } + + return { rewrittenArgs: args, warnings, errors }; +} diff --git a/src/cli-builder.ts b/src/cli-builder.ts index a295c89..85585e5 100644 --- a/src/cli-builder.ts +++ b/src/cli-builder.ts @@ -35,6 +35,7 @@ import { loadPreRequestHook } from './pre-request'; import type { RoutineResult } from './routine/executor'; import { executeRoutine } from './routine/executor'; import { loadRoutineFile, validateRoutine } from './routine/loader'; +import { loadAliases, collectCommandPaths, rewriteArgv, resolveLeadingTokens } from './aliases'; const coreManifest = JSON.parse( readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'), @@ -386,10 +387,23 @@ export function createCli(options: CliOptions): Cli { }, async run(): Promise { + // Load aliases once; reused both for early best-effort token resolution + // (so pre-build argv reads see through aliases) and for the late + // validated argv rewrite that runs just before parseAsync. + const { map: aliasMap, errors: aliasLoadErrors } = loadAliases(configDir, cliName); + + for (const err of aliasLoadErrors) { + process.stderr.write(`${displayName}: aliases: ${err}\n`); + } + + const effectiveArgs = resolveLeadingTokens(process.argv.slice(2), aliasMap); + const effectiveArgv2 = effectiveArgs[0]; + const effectiveArgv3 = effectiveArgs[1]; + // 0. Validate plugins (namespace, collisions, peer versions). // Skip throwing when the user invoked `plugins check` so the command // can report all issues non-destructively. - const isPluginsCheck = process.argv[2] === 'plugins' && process.argv[3] === 'check'; + const isPluginsCheck = effectiveArgv2 === 'plugins' && effectiveArgv3 === 'check'; if (!isPluginsCheck) { pluginRegistry.validateAll(consumerResolvers); @@ -466,7 +480,7 @@ export function createCli(options: CliOptions): Cli { // 4. Resolve auth let resolved = resolveAuth(cliName, configOpts); - const cmd = process.argv[2]; + const cmd = effectiveArgv2; const skipAuthCommands = new Set([ 'login', 'setup', @@ -566,7 +580,7 @@ export function createCli(options: CliOptions): Cli { // 7. Detect request-preview output modes const oIdx = process.argv.indexOf('-o'); const oVal = oIdx >= 0 ? process.argv[oIdx + 1] : undefined; - const isDryRun = process.argv.includes('--dry-run') && process.argv[2] !== 'routine'; + const isDryRun = process.argv.includes('--dry-run') && effectiveArgv2 !== 'routine'; const isCurl = oVal === 'curl'; const isCurlWithCreds = oVal === 'curl-with-creds'; const isRoutineStep = oVal === 'routine-step'; @@ -920,7 +934,32 @@ export function createCli(options: CliOptions): Cli { process.exit(0); } - // 13. Parse and execute + // 13. Apply project-local aliases (.apijack/aliases.json) by rewriting argv + // before Commander parses. Real command paths always win over aliases; + // expansions that don't resolve to a known command are skipped with an error. + // The map was already loaded at the top of run() for early best-effort + // resolution of pre-build argv reads — we reuse it here for the validated + // rewrite now that the full Commander tree exists. + if (Object.keys(aliasMap).length > 0) { + const realPaths = collectCommandPaths(program); + const { rewrittenArgs, warnings, errors } = rewriteArgv( + process.argv.slice(2), + aliasMap, + realPaths, + ); + + for (const w of warnings) { + process.stderr.write(`${displayName}: aliases: ${w}\n`); + } + + for (const e of errors) { + process.stderr.write(`${displayName}: aliases: ${e}\n`); + } + + process.argv = [...process.argv.slice(0, 2), ...rewrittenArgs]; + } + + // 14. Parse and execute await program.parseAsync(); }, }; diff --git a/tests/aliases.test.ts b/tests/aliases.test.ts new file mode 100644 index 0000000..045c894 --- /dev/null +++ b/tests/aliases.test.ts @@ -0,0 +1,297 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { Command } from 'commander'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir, homedir } from 'os'; +import { + loadAliases, + collectCommandPaths, + rewriteArgv, + resolveLeadingTokens, + type AliasMap, +} from '../src/aliases'; + +const testRoot = join(tmpdir(), 'apijack-aliases-test-' + Date.now()); + +describe('loadAliases()', () => { + const cliName = `apijack-test-${Date.now()}`; + const projectDir = join(testRoot, '.apijack'); + const globalDir = join(homedir(), '.' + cliName); + + beforeEach(() => { + mkdirSync(projectDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); + + if (existsSync(globalDir)) rmSync(globalDir, { recursive: true, force: true }); + }); + + test('returns empty map and no errors when neither file exists', () => { + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({}); + expect(result.errors).toEqual([]); + }); + + test('loads project-local aliases.json', () => { + writeFileSync( + join(projectDir, 'aliases.json'), + JSON.stringify({ cs: 'customers list' }), + ); + + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({ cs: 'customers list' }); + expect(result.errors).toEqual([]); + }); + + test('project-local entries override global entries on conflict', () => { + mkdirSync(globalDir, { recursive: true }); + writeFileSync( + join(globalDir, 'aliases.json'), + JSON.stringify({ cs: 'customers global', g: 'generate' }), + ); + writeFileSync( + join(projectDir, 'aliases.json'), + JSON.stringify({ cs: 'customers project' }), + ); + + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({ cs: 'customers project', g: 'generate' }); + expect(result.errors).toEqual([]); + }); + + test('reports malformed JSON and continues with the other file', () => { + mkdirSync(globalDir, { recursive: true }); + writeFileSync(join(globalDir, 'aliases.json'), '{ this is not json'); + writeFileSync( + join(projectDir, 'aliases.json'), + JSON.stringify({ cs: 'customers list' }), + ); + + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({ cs: 'customers list' }); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain('malformed JSON'); + }); + + test('rejects non-object root', () => { + writeFileSync(join(projectDir, 'aliases.json'), JSON.stringify(['not', 'an', 'object'])); + + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({}); + expect(result.errors[0]).toContain('expected a JSON object'); + }); + + test('skips non-string values with an error', () => { + writeFileSync( + join(projectDir, 'aliases.json'), + JSON.stringify({ ok: 'customers list', bad: 42 }), + ); + + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({ ok: 'customers list' }); + expect(result.errors[0]).toContain('alias "bad" must map to a string'); + }); + + test('skips empty alias keys or values', () => { + writeFileSync( + join(projectDir, 'aliases.json'), + JSON.stringify({ ' ': 'customers list', 'cs': ' ' }), + ); + + const result = loadAliases(projectDir, cliName); + expect(result.map).toEqual({}); + expect(result.errors.length).toBe(2); + }); + + test('does not double-read when configDir already equals the global path', () => { + mkdirSync(globalDir, { recursive: true }); + writeFileSync( + join(globalDir, 'aliases.json'), + JSON.stringify({ cs: 'customers list' }), + ); + + const result = loadAliases(globalDir, cliName); + expect(result.map).toEqual({ cs: 'customers list' }); + expect(result.errors).toEqual([]); + }); +}); + +describe('collectCommandPaths()', () => { + test('walks the Commander tree and returns dot-paths joined by spaces', () => { + const program = new Command(); + const customers = program.command('customers'); + customers.command('list'); + customers.command('get-customer-order-summary'); + program.command('config').command('switch'); + + const paths = collectCommandPaths(program); + expect(paths.has('customers')).toBe(true); + expect(paths.has('customers list')).toBe(true); + expect(paths.has('customers get-customer-order-summary')).toBe(true); + expect(paths.has('config')).toBe(true); + expect(paths.has('config switch')).toBe(true); + }); + + test('returns empty set when program has no subcommands', () => { + const program = new Command(); + expect(collectCommandPaths(program).size).toBe(0); + }); +}); + +describe('rewriteArgv()', () => { + const realPaths = new Set([ + 'customers', + 'customers list', + 'customers get-all-customers', + 'customers get-customer-order-summary', + 'config', + 'config switch', + 'generate', + ]); + + test('rewrites a single-token alias and appends trailing args', () => { + const aliases: AliasMap = { cs: 'customers get-customer-order-summary' }; + const result = rewriteArgv(['cs', '42', '--foo', 'bar'], aliases, realPaths); + + expect(result.rewrittenArgs).toEqual([ + 'customers', + 'get-customer-order-summary', + '42', + '--foo', + 'bar', + ]); + expect(result.warnings).toEqual([]); + expect(result.errors).toEqual([]); + }); + + test('rewrites a multi-token alias and appends trailing args', () => { + const aliases: AliasMap = { 'customers summary': 'customers get-customer-order-summary' }; + const result = rewriteArgv( + ['customers', 'summary', '42', '--foo', 'bar'], + aliases, + realPaths, + ); + + expect(result.rewrittenArgs).toEqual([ + 'customers', + 'get-customer-order-summary', + '42', + '--foo', + 'bar', + ]); + }); + + test('longest-prefix wins over shorter alias', () => { + // Both aliases are valid (neither shadows a real command); the multi-token + // one should beat the single-token one when both could match. + const aliases: AliasMap = { + 'orders': 'customers list', + 'orders summary': 'customers get-customer-order-summary', + }; + + const result = rewriteArgv(['orders', 'summary', '42'], aliases, realPaths); + expect(result.rewrittenArgs).toEqual([ + 'customers', + 'get-customer-order-summary', + '42', + ]); + + const result2 = rewriteArgv(['orders', '99'], aliases, realPaths); + expect(result2.rewrittenArgs).toEqual(['customers', 'list', '99']); + }); + + test('alias that shadows a real command path is skipped with a warning', () => { + const aliases: AliasMap = { customers: 'config switch' }; + const result = rewriteArgv(['customers', 'list'], aliases, realPaths); + + // Real "customers" wins — argv unchanged + expect(result.rewrittenArgs).toEqual(['customers', 'list']); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toContain('shadows a real command'); + expect(result.errors).toEqual([]); + }); + + test('alias whose expansion is unknown is skipped with an error', () => { + const aliases: AliasMap = { cs: 'customers nope' }; + const result = rewriteArgv(['cs', '42'], aliases, realPaths); + + expect(result.rewrittenArgs).toEqual(['cs', '42']); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain('does not resolve'); + }); + + test('returns args unchanged when no alias matches', () => { + const aliases: AliasMap = { cs: 'customers get-customer-order-summary' }; + const result = rewriteArgv(['config', 'switch', 'prod'], aliases, realPaths); + expect(result.rewrittenArgs).toEqual(['config', 'switch', 'prod']); + }); + + test('returns args unchanged when alias map is empty', () => { + const result = rewriteArgv(['anything'], {}, realPaths); + expect(result.rewrittenArgs).toEqual(['anything']); + }); + + test('returns args unchanged when args is empty', () => { + const result = rewriteArgv([], { cs: 'customers list' }, realPaths); + expect(result.rewrittenArgs).toEqual([]); + }); + + test('does not match a partial multi-token alias', () => { + const aliases: AliasMap = { 'customers summary': 'customers get-customer-order-summary' }; + const result = rewriteArgv(['customers', 'list'], aliases, realPaths); + expect(result.rewrittenArgs).toEqual(['customers', 'list']); + }); + + test('matches alias only when it appears as the leading tokens', () => { + const aliases: AliasMap = { cs: 'customers list' }; + // alias appears mid-args, not at the start — should not rewrite + const result = rewriteArgv(['config', 'cs'], aliases, realPaths); + expect(result.rewrittenArgs).toEqual(['config', 'cs']); + }); +}); + +describe('resolveLeadingTokens()', () => { + test('resolves a single-token alias without validation', () => { + const result = resolveLeadingTokens(['gen', '--foo'], { gen: 'generate' }); + expect(result).toEqual(['generate', '--foo']); + }); + + test('resolves a multi-token alias and prefers longest prefix', () => { + const aliases: AliasMap = { + 'r': 'routine', + 'r run': 'routine run', + }; + expect(resolveLeadingTokens(['r', 'run', 'foo'], aliases)).toEqual([ + 'routine', + 'run', + 'foo', + ]); + expect(resolveLeadingTokens(['r', 'list'], aliases)).toEqual(['routine', 'list']); + }); + + test('returns args unchanged when no alias matches', () => { + const result = resolveLeadingTokens(['plugins', 'check'], { gen: 'generate' }); + expect(result).toEqual(['plugins', 'check']); + }); + + test('returns args unchanged when alias map is empty', () => { + const result = resolveLeadingTokens(['anything'], {}); + expect(result).toEqual(['anything']); + }); + + test('returns empty when args is empty', () => { + const result = resolveLeadingTokens([], { gen: 'generate' }); + expect(result).toEqual([]); + }); + + test('makes auth-skip work for built-in command aliases (gen → generate)', () => { + // The whole point of this helper: argv[2] reads pre-Commander-build + // see through the alias. + const argv = ['gen']; + const resolved = resolveLeadingTokens(argv, { gen: 'generate' }); + const skipAuth = new Set(['generate', 'setup', 'config']); + expect(skipAuth.has(resolved[0])).toBe(true); + }); +}); From 6eb833b4a7b0e5da8a06bb4acf553c3130c6554d Mon Sep 17 00:00:00 2001 From: Garret Premo Date: Sat, 9 May 2026 12:03:10 -0400 Subject: [PATCH 2/3] docs: link Building-a-CLI, Session-Auth, and Project-Mode in README (#111) Follow-up to #111 (wiki refresh): the Documentation section in the README now surfaces the three pages just brought up to v1.13.0 so readers landing on the README can find the configuration reference, session-auth coverage (including dropBaseHeaders), and project-mode docs without hunting through the wiki sidebar. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index dd04382..c7972ad 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,11 @@ apijack supports pre-built plugins as standalone npm packages for common utiliti Full documentation is available on the [wiki](https://github.com/normalled/apijack/wiki): - [Quickstart](https://github.com/normalled/apijack/wiki/Quickstart) +- [Building a CLI](https://github.com/normalled/apijack/wiki/Building-a-CLI) - [Writing Routines](https://github.com/normalled/apijack/wiki/Writing-Routines) - [Authentication Strategies](https://github.com/normalled/apijack/wiki/Authentication-Strategies) +- [Session Auth](https://github.com/normalled/apijack/wiki/Session-Auth) +- [Project Mode](https://github.com/normalled/apijack/wiki/Project-Mode) - [CLI Command Reference](https://github.com/normalled/apijack/wiki/CLI-Command-Reference) - [Routine YAML Schema](https://github.com/normalled/apijack/wiki/Routine-YAML-Schema) From 266acc24669b2af84a5966656f228feb9d216700 Mon Sep 17 00:00:00 2001 From: Garret Premo Date: Sat, 9 May 2026 12:24:40 -0400 Subject: [PATCH 3/3] chore(release): v1.14.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 15614d8..644f1a8 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "api-client", "sdk-generator" ], - "version": "1.13.0", + "version": "1.14.0", "license": "MIT", "repository": { "type": "git",