Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <moduleName>` 的方式获取指定模块的对象列表。
Expand Down
8 changes: 5 additions & 3 deletions src/commands/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand Down
4 changes: 2 additions & 2 deletions src/commands/module-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '静默模式,不输出任何结果' });
Expand Down Expand Up @@ -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 参数:');
Expand Down
6 changes: 4 additions & 2 deletions src/commands/register-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +22,7 @@ export function addDataOptions(cmd: Command): Command {
.option('--limit <number>', '限制获取数量')
.option('--data <json>', 'JSON 数据')
.option('--params <json>', 'API 调用参数')
.option('--options <json>', 'API 调用选项')
.option('--options <json>', 'CLI 调用选项(JSON 对象,显式参数优先)')
.option('--yes', '跳过确认')
.option('--silent', '静默模式')
.option('--batch-fail-fast', '批量操作出错时停止')
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions src/utils/cli-options.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { ...(parsed as Record<string, unknown>) };
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;
}
98 changes: 98 additions & 0 deletions tests/cli-options.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});