meegle-cli 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -18,6 +18,7 @@
18
18
  - 写操作会先输出风险摘要
19
19
  - 交互终端里默认要求确认
20
20
  - 非交互环境(脚本 / AI / CI)必须显式传 `--yes`
21
+ - 批量目标超过安全阈值时,必须显式传 `--force-batch`
21
22
 
22
23
  交互终端的目标体验是:
23
24
 
@@ -455,6 +456,7 @@ meegle --profile default workitem search by-params \
455
456
  - 常用查询优先走直接参数,不强制写 JSON 文件
456
457
  - 常用写操作优先走直接参数,例如 `--name`、`--desc`、`--content`
457
458
  - 非交互环境下,写操作必须显式传 `--yes`
459
+ - 超大批量写操作必须显式传 `--force-batch`
458
460
  - 复杂请求体可使用 `--body <json>` 或 `--body-file <path>`
459
461
  - 复杂查询参数可使用 `--query-file <path>`
460
462
  - 自定义字段统一使用 `--field field_key=value`
@@ -547,6 +549,21 @@ meegle auth init \
547
549
  meegle --profile default --yes workitem update ...
548
550
  ```
549
551
 
552
+ ### 7. 批量写操作被拒绝
553
+
554
+ 如果报错类似:
555
+
556
+ - `BATCH_THRESHOLD_EXCEEDED`
557
+
558
+ 说明目标数量已经超过安全阈值。
559
+ 这类场景默认拒绝,避免一次误伤太多数据。
560
+
561
+ 如果确实要继续,显式传:
562
+
563
+ ```bash
564
+ meegle --profile default --yes --force-batch workitem freeze --ids ...
565
+ ```
566
+
550
567
  ## 命令总览
551
568
 
552
569
  - `auth`: `init`, `status`
@@ -595,3 +612,5 @@ npm test
595
612
  4. `planning_version` 这类关联字段,先查出被关联工作项的 `id`,再筛主工作项
596
613
  5. `field_value_pairs` 是写操作格式,不是搜索格式
597
614
  6. 非交互环境里,写操作必须显式传 `--yes`
615
+ 7. 对批量危险操作,超过阈值时必须显式传 `--force-batch`
616
+ 8. 遇到安全错误时,优先读取 `error.code`,不要只匹配自然语言
package/dist/cli.js CHANGED
@@ -734,6 +734,15 @@ async function buildWorkItemRemovePreview(ctx, args) {
734
734
  confirmPhrase: '确认删除',
735
735
  };
736
736
  }
737
+ function buildBatchIdsPreview(action, ids) {
738
+ return {
739
+ lines: [
740
+ `action: ${action}`,
741
+ `targetIds: ${ids.join(',')}`,
742
+ ],
743
+ targetCount: ids.length,
744
+ };
745
+ }
737
746
  function resolveGlobal(program) {
738
747
  return program.opts();
739
748
  }
@@ -997,11 +1006,14 @@ function registerWorkItem(program) {
997
1006
  .requiredOption('--ids <ids>', '工作项 ID,逗号分隔')
998
1007
  .action(async (options) => {
999
1008
  const global = resolveGlobal(program);
1009
+ const ids = parseNumberList(options.ids, 'ids');
1000
1010
  await runGuarded({
1001
1011
  id: 'workitem.freeze',
1002
1012
  authPolicy: 'PLUGIN_WITH_USER_KEY',
1003
1013
  endpoint: { method: 'PUT', path: '/open_api/work_item/freeze' },
1004
- }, global, async (ctx) => ctx.client.workItem.freeze(parseNumberList(options.ids, 'ids'), true, { auth: ctx.auth }));
1014
+ }, global, async (ctx) => ctx.client.workItem.freeze(ids, true, { auth: ctx.auth }), {
1015
+ riskPreview: async () => buildBatchIdsPreview('freeze', ids),
1016
+ });
1005
1017
  printOk(Boolean(global.json));
1006
1018
  });
1007
1019
  workItem
@@ -1010,11 +1022,14 @@ function registerWorkItem(program) {
1010
1022
  .requiredOption('--ids <ids>', '工作项 ID,逗号分隔')
1011
1023
  .action(async (options) => {
1012
1024
  const global = resolveGlobal(program);
1025
+ const ids = parseNumberList(options.ids, 'ids');
1013
1026
  await runGuarded({
1014
1027
  id: 'workitem.unfreeze',
1015
1028
  authPolicy: 'PLUGIN_WITH_USER_KEY',
1016
1029
  endpoint: { method: 'PUT', path: '/open_api/work_item/freeze' },
1017
- }, global, async (ctx) => ctx.client.workItem.freeze(parseNumberList(options.ids, 'ids'), false, { auth: ctx.auth }));
1030
+ }, global, async (ctx) => ctx.client.workItem.freeze(ids, false, { auth: ctx.auth }), {
1031
+ riskPreview: async () => buildBatchIdsPreview('unfreeze', ids),
1032
+ });
1018
1033
  printOk(Boolean(global.json));
1019
1034
  });
1020
1035
  workItem
@@ -2057,6 +2072,7 @@ export function buildCli() {
2057
2072
  .option('--compat-auth', '读接口使用兼容模式 (x-auth-mode=0),默认严格模式')
2058
2073
  .option('--json', '输出 JSON')
2059
2074
  .option('--yes', '跳过写操作确认,脚本 / AI / CI 使用')
2075
+ .option('--force-batch', '批量目标超过安全阈值时,显式确认继续执行')
2060
2076
  .addHelpText('after', `
2061
2077
  常用示例:
2062
2078
  meegle auth init --target-profile demo --plugin-id <id> --plugin-secret <secret> --default-user-key <userKey>
@@ -40,7 +40,13 @@ export function isUserTokenOnlyEndpoint(path) {
40
40
  }
41
41
  export function assertCommandSupported(meta) {
42
42
  if (meta.authPolicy === 'USER_TOKEN_REQUIRED') {
43
- throw new CliError(`命令 "${meta.id}" 依赖 user_access_token,当前 CLI 运行在 plugin-only 模式,已拒绝执行。`, 2);
43
+ throw new CliError(`命令 "${meta.id}" 依赖 user_access_token,当前 CLI 运行在 plugin-only 模式,已拒绝执行。`, {
44
+ exitCode: 2,
45
+ code: 'USER_TOKEN_REQUIRED',
46
+ details: {
47
+ command: meta.id,
48
+ },
49
+ });
44
50
  }
45
51
  }
46
52
  export function assertEndpointAllowed(endpoint) {
@@ -48,7 +54,14 @@ export function assertEndpointAllowed(endpoint) {
48
54
  return;
49
55
  }
50
56
  if (isUserTokenOnlyEndpoint(endpoint.path)) {
51
- throw new CliError(`接口 ${endpoint.method} ${endpoint.path} 在文档中标注为仅支持 user_access_token,plugin-only 模式已拒绝。`, 2);
57
+ throw new CliError(`接口 ${endpoint.method} ${endpoint.path} 在文档中标注为仅支持 user_access_token,plugin-only 模式已拒绝。`, {
58
+ exitCode: 2,
59
+ code: 'ENDPOINT_REQUIRES_USER_TOKEN',
60
+ details: {
61
+ method: endpoint.method,
62
+ path: endpoint.path,
63
+ },
64
+ });
52
65
  }
53
66
  }
54
67
  /**
@@ -1,4 +1,11 @@
1
+ export interface CliErrorOptions {
2
+ exitCode?: number;
3
+ code?: string;
4
+ details?: Record<string, unknown>;
5
+ }
1
6
  export declare class CliError extends Error {
2
7
  readonly exitCode: number;
3
- constructor(message: string, exitCode?: number);
8
+ readonly code: string;
9
+ readonly details?: Record<string, unknown>;
10
+ constructor(message: string, exitCodeOrOptions?: number | CliErrorOptions);
4
11
  }
@@ -1,9 +1,16 @@
1
1
  export class CliError extends Error {
2
2
  exitCode;
3
- constructor(message, exitCode = 1) {
3
+ code;
4
+ details;
5
+ constructor(message, exitCodeOrOptions = 1) {
4
6
  super(message);
5
7
  this.name = 'CliError';
6
- this.exitCode = exitCode;
8
+ const options = typeof exitCodeOrOptions === 'number'
9
+ ? { exitCode: exitCodeOrOptions }
10
+ : exitCodeOrOptions;
11
+ this.exitCode = options.exitCode ?? 1;
12
+ this.code = options.code ?? 'CLI_ERROR';
13
+ this.details = options.details;
7
14
  Object.setPrototypeOf(this, CliError.prototype);
8
15
  }
9
16
  }
@@ -6,6 +6,7 @@ export interface RuntimeOptions {
6
6
  userKey?: string;
7
7
  compatAuth?: boolean;
8
8
  yes?: boolean;
9
+ forceBatch?: boolean;
9
10
  }
10
11
  export interface GuardContext {
11
12
  profileName: string;
@@ -17,6 +18,7 @@ export interface GuardContext {
17
18
  export interface RiskPreview {
18
19
  lines?: string[];
19
20
  confirmPhrase?: string;
21
+ targetCount?: number;
20
22
  }
21
23
  export interface GuardHooks {
22
24
  riskPreview?: (ctx: GuardContext) => Promise<RiskPreview | undefined>;
@@ -3,6 +3,8 @@ import { CliError } from './cli-error.js';
3
3
  import { assertCommandSupported, assertEndpointAllowed, getCommandRiskLevel, } from './auth-policy.js';
4
4
  import { getProfile } from './config-store.js';
5
5
  import { createClient } from './client-factory.js';
6
+ const BATCH_WARNING_THRESHOLD = 20;
7
+ const BATCH_FORCE_THRESHOLD = 100;
6
8
  function resolveUserKey(runtimeUserKey, defaultUserKey) {
7
9
  if (runtimeUserKey?.trim()) {
8
10
  return runtimeUserKey.trim();
@@ -64,6 +66,42 @@ async function confirmWriteRisk(meta, runtime, profileName, preview) {
64
66
  return;
65
67
  }
66
68
  const summary = [...buildRiskSummary(meta, profileName, riskLevel), ...(preview?.lines ?? [])];
69
+ const targetCount = preview?.targetCount;
70
+ if (typeof targetCount === 'number') {
71
+ summary.push(`targetCount: ${targetCount}`);
72
+ if (targetCount > BATCH_WARNING_THRESHOLD) {
73
+ summary.push(`批量目标超过 ${BATCH_WARNING_THRESHOLD} 条,请再次确认范围。`);
74
+ }
75
+ if (targetCount > BATCH_FORCE_THRESHOLD && !runtime.forceBatch) {
76
+ throw new CliError(`批量目标达到 ${targetCount} 条,已超过安全阈值 ${BATCH_FORCE_THRESHOLD}。如确认继续,请显式传 --force-batch。`, {
77
+ exitCode: 2,
78
+ code: 'BATCH_THRESHOLD_EXCEEDED',
79
+ details: {
80
+ target_count: targetCount,
81
+ threshold: BATCH_FORCE_THRESHOLD,
82
+ command: meta.id,
83
+ },
84
+ });
85
+ }
86
+ }
87
+ if (!isInteractiveShell()) {
88
+ if (runtime.yes) {
89
+ process.stderr.write('即将执行写操作:\n');
90
+ for (const line of summary) {
91
+ process.stderr.write(`- ${line}\n`);
92
+ }
93
+ return;
94
+ }
95
+ throw new CliError('非交互环境下,写操作需要显式传 --yes。', {
96
+ exitCode: 2,
97
+ code: 'WRITE_CONFIRMATION_REQUIRED',
98
+ details: {
99
+ command: meta.id,
100
+ risk_level: riskLevel,
101
+ risk_summary: summary,
102
+ },
103
+ });
104
+ }
67
105
  process.stderr.write('即将执行写操作:\n');
68
106
  for (const line of summary) {
69
107
  process.stderr.write(`- ${line}\n`);
@@ -71,25 +109,32 @@ async function confirmWriteRisk(meta, runtime, profileName, preview) {
71
109
  if (runtime.yes) {
72
110
  return;
73
111
  }
74
- if (!isInteractiveShell()) {
75
- throw new CliError('非交互环境下,写操作需要显式传 --yes。', 2);
76
- }
77
112
  const rl = createInterface({
78
113
  input: process.stdin,
79
114
  output: process.stderr,
80
115
  });
81
116
  try {
117
+ process.stderr.write('即将执行写操作:\n');
118
+ for (const line of summary) {
119
+ process.stderr.write(`- ${line}\n`);
120
+ }
82
121
  if (riskLevel === 'DANGEROUS') {
83
122
  const confirmPhrase = preview?.confirmPhrase ?? '确认执行';
84
123
  const answer = (await rl.question(`危险操作。请输入“${confirmPhrase}”确认继续执行: `)).trim();
85
124
  if (answer !== confirmPhrase) {
86
- throw new CliError('已取消执行。', 2);
125
+ throw new CliError('已取消执行。', {
126
+ exitCode: 2,
127
+ code: 'OPERATION_CANCELLED',
128
+ });
87
129
  }
88
130
  return;
89
131
  }
90
132
  const answer = (await rl.question('确认继续执行写操作?[y/N] ')).trim().toLowerCase();
91
133
  if (answer !== 'y' && answer !== 'yes') {
92
- throw new CliError('已取消执行。', 2);
134
+ throw new CliError('已取消执行。', {
135
+ exitCode: 2,
136
+ code: 'OPERATION_CANCELLED',
137
+ });
93
138
  }
94
139
  }
95
140
  finally {
@@ -2,51 +2,122 @@ import { CommanderError } from 'commander';
2
2
  import { ErrorCodes, MeegoError } from 'meeglesdk';
3
3
  import { CliError } from './cli-error.js';
4
4
  const TOKEN_TYPE_WRONG = 1000052755;
5
- function print(message) {
5
+ function isJsonMode() {
6
+ return process.argv.includes('--json');
7
+ }
8
+ function printJson(payload) {
9
+ process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
10
+ }
11
+ function printText(message) {
6
12
  process.stderr.write(`${message}\n`);
7
13
  }
14
+ /**
15
+ * 让 Agent 能稳定识别错误类型,比只输出自然语言更容易做重试和纠偏。
16
+ */
17
+ function printStructuredError(payload) {
18
+ if (isJsonMode()) {
19
+ printJson({
20
+ ok: false,
21
+ error: {
22
+ code: payload.code,
23
+ message: payload.message,
24
+ ...(payload.details ?? {}),
25
+ },
26
+ });
27
+ return;
28
+ }
29
+ printText(`[${payload.code}] ${payload.message}`);
30
+ if (payload.details?.log_id) {
31
+ printText(`log_id: ${String(payload.details.log_id)}`);
32
+ }
33
+ }
8
34
  function handleMeegoError(error) {
9
35
  switch (error.errCode) {
10
36
  case TOKEN_TYPE_WRONG:
11
- print('接口仅支持 user_access_token;当前 CLI 为 plugin-only 模式,命令已拒绝。');
37
+ printStructuredError({
38
+ code: 'USER_TOKEN_REQUIRED',
39
+ message: '接口仅支持 user_access_token;当前 CLI 为 plugin-only 模式,命令已拒绝。',
40
+ details: {
41
+ err_code: error.errCode,
42
+ log_id: error.logId,
43
+ },
44
+ });
12
45
  return 2;
13
46
  case ErrorCodes.PLUGIN_TOKEN_MUST_HAVE_USER_KEY:
14
- print('缺少 userKey。请传 --user-key 或在 auth init 中配置 defaultUserKey。');
47
+ printStructuredError({
48
+ code: 'USER_KEY_REQUIRED',
49
+ message: '缺少 userKey。请传 --user-key 或在 auth init 中配置 defaultUserKey。',
50
+ details: {
51
+ err_code: error.errCode,
52
+ log_id: error.logId,
53
+ },
54
+ });
15
55
  return 2;
16
56
  case ErrorCodes.X_USER_KEY_WRONG:
17
- print('X-User-Key 无效。请检查 --user-key 是否正确。');
57
+ printStructuredError({
58
+ code: 'INVALID_USER_KEY',
59
+ message: 'X-User-Key 无效。请检查 --user-key 是否正确。',
60
+ details: {
61
+ err_code: error.errCode,
62
+ log_id: error.logId,
63
+ },
64
+ });
18
65
  return 2;
19
66
  case ErrorCodes.CHECK_TOKEN_FAILED:
20
67
  case ErrorCodes.TOKEN_NOT_EXIST:
21
68
  case ErrorCodes.CHECK_TOKEN_PERM_FAILED:
22
- print(`鉴权失败(${error.errCode}):${error.errMsg}`);
69
+ printStructuredError({
70
+ code: 'AUTH_FAILED',
71
+ message: `鉴权失败(${error.errCode}):${error.errMsg}`,
72
+ details: {
73
+ err_code: error.errCode,
74
+ log_id: error.logId,
75
+ },
76
+ });
23
77
  return 3;
24
78
  default:
25
- print(`API 错误(${error.errCode}):${error.errMsg}`);
26
- if (error.logId) {
27
- print(`log_id: ${error.logId}`);
28
- }
79
+ printStructuredError({
80
+ code: 'API_ERROR',
81
+ message: `API 错误(${error.errCode}):${error.errMsg}`,
82
+ details: {
83
+ err_code: error.errCode,
84
+ log_id: error.logId,
85
+ },
86
+ });
29
87
  return 1;
30
88
  }
31
89
  }
32
90
  export function handleCliError(error) {
33
91
  if (error instanceof CommanderError) {
34
92
  if (error.code !== 'commander.helpDisplayed') {
35
- print(error.message);
93
+ printStructuredError({
94
+ code: 'COMMAND_ARGUMENT_ERROR',
95
+ message: error.message,
96
+ });
36
97
  }
37
98
  return error.exitCode;
38
99
  }
39
100
  if (error instanceof CliError) {
40
- print(error.message);
101
+ printStructuredError({
102
+ code: error.code,
103
+ message: error.message,
104
+ details: error.details,
105
+ });
41
106
  return error.exitCode;
42
107
  }
43
108
  if (error instanceof MeegoError) {
44
109
  return handleMeegoError(error);
45
110
  }
46
111
  if (error instanceof Error) {
47
- print(error.message);
112
+ printStructuredError({
113
+ code: 'UNEXPECTED_ERROR',
114
+ message: error.message,
115
+ });
48
116
  return 1;
49
117
  }
50
- print('未知错误');
118
+ printStructuredError({
119
+ code: 'UNKNOWN_ERROR',
120
+ message: '未知错误',
121
+ });
51
122
  return 1;
52
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meegle-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Plugin-only CLI for Feishu Project (Meegle) based on meeglesdk",
5
5
  "type": "module",
6
6
  "bin": {