From 0a437c8675a7f7489b6fd603cc36870bb418f2b3 Mon Sep 17 00:00:00 2001 From: Baocang Nie <16043697+baocang@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:46:50 +0800 Subject: [PATCH] + feat: apply CLI options from JSON --- docs/cli-usage.md | 15 +++++ src/commands/crud.ts | 8 ++- src/commands/module-handler.ts | 4 +- src/commands/register-modules.ts | 6 +- src/utils/cli-options.ts | 37 ++++++++++++ tests/cli-options.test.ts | 98 ++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 src/utils/cli-options.ts create mode 100644 tests/cli-options.test.ts diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 015de0d..7662f64 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -104,6 +104,21 @@ $ ZENTAO_CONFIG_FILE=~/work/zt.json zentao product > [!TIP] > 推荐优先使用简写方式。当简写方式中的模块名与命令行一级命令冲突时,必须改用原始方式调用。 +公共 CLI 选项也可以通过 `--options` 一次传入一个 JSON 对象,两种调用方式都支持。例如: + +```bash +zentao product --options='{"format":"json","page":"2","filter":["status=normal"]}' +zentao ls product --options='{"format":"json","page":"2","filter":["status=normal"]}' +``` + +同时提供独立选项时,独立选项优先。例如,下面的命令最终使用 Markdown 输出: + +```bash +zentao product --options='{"format":"json","limit":"50"}' --format=markdown +``` + +`--options` 必须是 JSON 对象;无效 JSON、数组和其他 JSON 值会返回 `E2009`。 + ### 获取禅道对象列表 支持通过 `zentao ` 的方式获取指定模块的对象列表。 diff --git a/src/commands/crud.ts b/src/commands/crud.ts index 32e8e79..bb33fcf 100644 --- a/src/commands/crud.ts +++ b/src/commands/crud.ts @@ -6,6 +6,7 @@ import { handleModuleCommand } from './module-handler.js'; import { addDataOptions } from './register-modules.js'; import type { GlobalOptions, ModuleActionOptions, ModuleName, ModuleActionName } from '../types/index.js'; import { renderError } from '../utils/render.js'; +import { applyOptionsJson } from '../utils/cli-options.js'; /** 注册 `ls` / `get` / `create` / `update` / `delete` / `do` 等通用 CRUD 入口 */ export function registerCrudCommands(program: Command): void { @@ -99,11 +100,12 @@ async function runCrudCommand( } const globalOpts = program.opts() as GlobalOptions; - const options = {...globalOpts, ...opts}; + let options = { ...globalOpts, ...opts } as ModuleActionOptions; try { + options = applyOptionsJson(options); const { client, profile } = await ensureAuth({ - insecure: globalOpts.insecure, - timeout: globalOpts.timeout, + insecure: options.insecure, + timeout: options.timeout, }); const firstArg = args[0]; diff --git a/src/commands/module-handler.ts b/src/commands/module-handler.ts index 349cc19..6ea4eb6 100644 --- a/src/commands/module-handler.ts +++ b/src/commands/module-handler.ts @@ -302,7 +302,7 @@ export function showModuleHelp(mod: ModuleDefinition): void { } commonOpts.push( { name: 'params', placeholder: 'json', description: 'API 调用参数(JSON 对象),可替代单独的 --key=value 传参' }, - { name: 'options', placeholder: 'json', description: 'CLI 调用选项(JSON 对象),可替代单独的公共选项' }, + { name: 'options', placeholder: 'json', description: 'CLI 调用选项(JSON 对象);显式公共选项优先' }, ); if (deleteAction) commonOpts.push({ name: 'yes', description: '跳过确认提示,适用于 delete 操作' }); commonOpts.push({ name: 'silent', description: '静默模式,不输出任何结果' }); @@ -356,7 +356,7 @@ export function showModuleActionHelp(mod: ModuleDefinition, action: ModuleAction apiParams.push({ name: 'data', placeholder: 'json', description: '请求数据(完整 JSON 对象),可替代以上逐个字段传参' }); } apiParams.push({ name: 'params', placeholder: 'json', description: 'API 调用参数(JSON 对象),可替代以上逐个 --key=value 传参' }); - apiParams.push({ name: 'options', placeholder: 'json', description: 'CLI 调用选项(JSON 对象),可替代以下公共选项' }); + apiParams.push({ name: 'options', placeholder: 'json', description: 'CLI 调用选项(JSON 对象);显式公共选项优先' }); if (apiParams.length > 0) { console.log('\nAPI 参数:'); diff --git a/src/commands/register-modules.ts b/src/commands/register-modules.ts index fa6574d..49b2299 100644 --- a/src/commands/register-modules.ts +++ b/src/commands/register-modules.ts @@ -6,6 +6,7 @@ import { handleModuleCommand, showModuleActionHelp, showModuleHelp, showModulePr import { ZentaoError } from '../errors.js'; import type { GlobalOptions, ModuleActionName, ModuleActionOptions, ModuleActionType } from '../types/index.js'; import { renderError } from '../utils/render.js'; +import { applyOptionsJson } from '../utils/cli-options.js'; /** 为命令挂载数据查询、分页、过滤及父子上下文等通用选项 */ export function addDataOptions(cmd: Command): Command { @@ -21,7 +22,7 @@ export function addDataOptions(cmd: Command): Command { .option('--limit ', '限制获取数量') .option('--data ', 'JSON 数据') .option('--params ', 'API 调用参数') - .option('--options ', 'API 调用选项') + .option('--options ', 'CLI 调用选项(JSON 对象,显式参数优先)') .option('--yes', '跳过确认') .option('--silent', '静默模式') .option('--batch-fail-fast', '批量操作出错时停止') @@ -77,8 +78,9 @@ export function registerModuleCommands(program: Command): void { cmd.action(async (args: string[], opts: ModuleActionOptions) => { const globalOpts = program.opts() as GlobalOptions; - const options = {...globalOpts, ...opts}; + let options = { ...globalOpts, ...opts } as ModuleActionOptions; try { + options = applyOptionsJson(options); const showRequestedHelp = (candidate?: string): void => { if (!candidate) { showModuleHelp(mod); diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts new file mode 100644 index 0000000..0662bac --- /dev/null +++ b/src/utils/cli-options.ts @@ -0,0 +1,37 @@ +import { ZentaoError } from '../errors.js'; +import type { ModuleActionOptions } from '../types/index.js'; + +/** + * Merge the JSON object supplied through `--options` with parsed CLI options. + * Explicit CLI values win; Commander's empty array defaults do not. + */ +export function applyOptionsJson(options: ModuleActionOptions): ModuleActionOptions { + if (options.options === undefined) return options; + + let parsed: unknown; + try { + parsed = JSON.parse(options.options); + } catch { + throw new ZentaoError('E2009', { + option: '--options', + reason: '必须是有效的 JSON 对象', + }); + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ZentaoError('E2009', { + option: '--options', + reason: '必须是 JSON 对象', + }); + } + + const merged: Record = { ...(parsed as Record) }; + for (const [key, value] of Object.entries(options)) { + if (key === 'options' || value === undefined) continue; + if (Array.isArray(value) && value.length === 0) continue; + merged[key] = value; + } + delete merged.options; + + return merged as ModuleActionOptions; +} diff --git a/tests/cli-options.test.ts b/tests/cli-options.test.ts new file mode 100644 index 0000000..e0d77d8 --- /dev/null +++ b/tests/cli-options.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { applyOptionsJson } from '../src/utils/cli-options.js'; +import { runCliWithoutAuth } from './helpers.js'; + +describe('applyOptionsJson', () => { + test('uses options JSON as the base and lets explicit CLI values win', () => { + const result = applyOptionsJson({ + options: JSON.stringify({ format: 'json', limit: '50', page: '2' }), + format: 'raw', + limit: '10', + }); + + expect(result).toEqual({ format: 'raw', limit: '10', page: '2' }); + }); + + test('does not let Commander empty array defaults replace JSON values', () => { + const result = applyOptionsJson({ + options: JSON.stringify({ filter: ['status=active'], search: ['keyword'] }), + filter: [], + search: [], + }); + + expect(result.filter).toEqual(['status=active']); + expect(result.search).toEqual(['keyword']); + }); + + test('lets non-empty repeated CLI options replace JSON values', () => { + const result = applyOptionsJson({ + options: JSON.stringify({ filter: ['status=active'], search: ['old'] }), + filter: ['status=closed'], + search: ['new'], + }); + + expect(result.filter).toEqual(['status=closed']); + expect(result.search).toEqual(['new']); + }); + + test('preserves boolean flags supplied only through options JSON', () => { + const result = applyOptionsJson({ + options: JSON.stringify({ insecure: true, silent: true, yes: true, all: true }), + filter: [], + search: [], + }); + + expect(result).toMatchObject({ insecure: true, silent: true, yes: true, all: true }); + }); + + test('does not pass a nested options key to command execution', () => { + const result = applyOptionsJson({ + options: JSON.stringify({ options: 'nested', format: 'json' }), + }); + + expect(result).toEqual({ format: 'json' }); + }); + + test.each([ + ['invalid JSON', '{'], + ['an array', '[]'], + ['null', 'null'], + ['a scalar', 'true'], + ])('rejects %s', (_label, value) => { + expect(() => applyOptionsJson({ options: value })).toThrow('选项 --options 的值无效'); + }); +}); + +describe('--options command wiring', () => { + test('applies options JSON to a module command', async () => { + const result = await runCliWithoutAuth(['bug', '--options', '{"format":"json"}']); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: '1006' } }); + }); + + test('applies options JSON to a generic CRUD command', async () => { + const result = await runCliWithoutAuth(['get', 'bug', '1', '--options', '{"format":"json"}']); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: '1006' } }); + }); + + test('validates module command input before authentication', async () => { + const result = await runCliWithoutAuth(['bug', '--options', '[]']); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('Error(E2009)'); + expect(result.stdout).toContain('--options'); + expect(result.stdout).not.toContain('E1006'); + }); + + test('validates generic CRUD input before authentication', async () => { + const result = await runCliWithoutAuth(['get', 'bug', '1', '--options', '{']); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('Error(E2009)'); + expect(result.stdout).toContain('--options'); + expect(result.stdout).not.toContain('E1006'); + }); +});