@lark-apaas/miaoda-cli 0.1.3-alpha.2d526ac → 0.1.3-alpha.3198e3b

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.
@@ -48,8 +48,13 @@ async function mapDbHttpError(err, url, ctx, opts) {
48
48
  (0, client_1.extractData)(body); // 业务 code 命中 → 抛 AppError;不命中走兜底
49
49
  }
50
50
  catch (appErr) {
51
- if (appErr instanceof error_1.AppError && opts?.onErrorBody) {
52
- opts.onErrorBody(body, appErr);
51
+ if (appErr instanceof error_1.AppError) {
52
+ // env 上下文存在时把"env not exist"系列错误统一重写成 UNKNOWN_ENV_VALUE
53
+ // —— 用户视角 vocab 错和 state 错都是「这个 env 不能用」,文案统一更好理解。
54
+ const decorated = (0, client_1.decorateEnvError)(appErr, opts?.env);
55
+ if (opts?.onErrorBody)
56
+ opts.onErrorBody(body, decorated);
57
+ throw decorated;
53
58
  }
54
59
  throw appErr;
55
60
  }
@@ -104,6 +109,7 @@ async function execSql(opts) {
104
109
  catch (err) {
105
110
  (0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
106
111
  await mapDbHttpError(err, url, 'Failed to execute SQL', {
112
+ env: opts.dbBranch,
107
113
  onErrorBody: attachSqlPartialResults,
108
114
  // SQL 路径单独的超时文案:PG 的 statement_timeout 会回滚整条事务,所以这里
109
115
  // 明示「事务已回滚、没有改动落地」,并把 hint 引导到「简化 SQL / 加 LIMIT / 拆条」。
@@ -191,7 +197,7 @@ async function getSchema(opts) {
191
197
  }
192
198
  catch (err) {
193
199
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
194
- await mapDbHttpError(err, url, 'Failed to get schema');
200
+ await mapDbHttpError(err, url, 'Failed to get schema', { env: opts.dbBranch });
195
201
  throw err; // 不可达
196
202
  }
197
203
  const body = (await response.json());
@@ -229,7 +235,7 @@ async function importData(opts) {
229
235
  }
230
236
  catch (err) {
231
237
  (0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
232
- await mapDbHttpError(err, url, 'Failed to import data');
238
+ await mapDbHttpError(err, url, 'Failed to import data', { env: opts.dbBranch });
233
239
  throw err; // 不可达
234
240
  }
235
241
  // 后端 InnerAdminImportData 响应里 data 直接返 {tableName, recordCount, durationMs}
@@ -268,7 +274,7 @@ async function exportData(opts) {
268
274
  }
269
275
  catch (err) {
270
276
  (0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
271
- await mapDbHttpError(err, url, 'Failed to export data');
277
+ await mapDbHttpError(err, url, 'Failed to export data', { env: opts.dbBranch });
272
278
  throw err; // 不可达
273
279
  }
274
280
  // 成功路径:响应 body 通常是原始 CSV/SQL/JSON 字节,但部分错误场景下网关会返
@@ -340,7 +346,7 @@ async function listDDLChangelog(opts) {
340
346
  }
341
347
  catch (err) {
342
348
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
343
- await mapDbHttpError(err, url, 'Failed to list DDL changelog');
349
+ await mapDbHttpError(err, url, 'Failed to list DDL changelog', { env: opts.dbBranch });
344
350
  throw err; // 不可达
345
351
  }
346
352
  const body = (await response.json());
@@ -370,7 +376,7 @@ async function getAuditStatus(opts) {
370
376
  }
371
377
  catch (err) {
372
378
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
373
- await mapDbHttpError(err, url, 'Failed to get audit status');
379
+ await mapDbHttpError(err, url, 'Failed to get audit status', { env: opts.dbBranch });
374
380
  throw err; // 不可达
375
381
  }
376
382
  const respBody = (await response.json());
@@ -402,7 +408,7 @@ async function setAuditConfig(opts) {
402
408
  }
403
409
  catch (err) {
404
410
  (0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
405
- await mapDbHttpError(err, url, 'Failed to set audit config');
411
+ await mapDbHttpError(err, url, 'Failed to set audit config', { env: opts.dbBranch });
406
412
  throw err; // 不可达
407
413
  }
408
414
  const respBody = (await response.json());
@@ -430,7 +436,9 @@ async function listAuditLog(opts) {
430
436
  }
431
437
  const client = (0, http_1.getHttpClient)();
432
438
  const url = (0, client_1.buildInnerUrl)(opts.appId, '/db/audit/log', {
433
- tables: opts.tables.join(','),
439
+ // IDL `Tables list<string>` 走重复 query key(?tables=A&tables=B)—— join(',')
440
+ // 会让 Hertz/Kitex 网关绑成 ["A,B"] 单元素切片,命中单表错误分支报错文案错乱。
441
+ tables: opts.tables,
434
442
  since: opts.since,
435
443
  until: opts.until,
436
444
  limit: opts.limit !== undefined ? String(opts.limit) : undefined,
@@ -445,7 +453,7 @@ async function listAuditLog(opts) {
445
453
  }
446
454
  catch (err) {
447
455
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
448
- await mapDbHttpError(err, url, 'Failed to list audit log');
456
+ await mapDbHttpError(err, url, 'Failed to list audit log', { env: opts.dbBranch });
449
457
  throw err; // 不可达
450
458
  }
451
459
  const body = (await response.json());
@@ -522,7 +530,7 @@ async function getMigrationStatus(opts) {
522
530
  }
523
531
  catch (err) {
524
532
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
525
- await mapDbHttpError(err, url, 'Failed to get migration status');
533
+ await mapDbHttpError(err, url, 'Failed to get migration status', { env: opts.dbBranch });
526
534
  throw err; // 不可达
527
535
  }
528
536
  const body = (await response.json());
@@ -571,7 +579,7 @@ async function getRecoveryPreview(opts) {
571
579
  }
572
580
  catch (err) {
573
581
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
574
- await mapDbHttpError(err, url, 'Failed to get recovery preview');
582
+ await mapDbHttpError(err, url, 'Failed to get recovery preview', { env: opts.dbBranch });
575
583
  throw err; // 不可达
576
584
  }
577
585
  const body = (await response.json());
@@ -594,7 +602,7 @@ async function getRecoveryStatus(opts) {
594
602
  }
595
603
  catch (err) {
596
604
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
597
- await mapDbHttpError(err, url, 'Failed to get recovery status');
605
+ await mapDbHttpError(err, url, 'Failed to get recovery status', { env: opts.dbBranch });
598
606
  throw err; // 不可达
599
607
  }
600
608
  const body = (await response.json());
@@ -615,7 +623,7 @@ async function getDbQuota(opts) {
615
623
  }
616
624
  catch (err) {
617
625
  (0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
618
- await mapDbHttpError(err, url, 'Failed to get db quota');
626
+ await mapDbHttpError(err, url, 'Failed to get db quota', { env: opts.dbBranch });
619
627
  throw err; // 不可达
620
628
  }
621
629
  const respBody = (await response.json());
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SQLSTATE_MAP = void 0;
3
+ exports.SQLSTATE_MAP = exports.ENV_CTX_TYPO = void 0;
4
4
  exports.traceHttp = traceHttp;
5
5
  exports.ensureInnerSuccess = ensureInnerSuccess;
6
6
  exports.mapDataloomBizError = mapDataloomBizError;
7
+ exports.buildUnknownEnvError = buildUnknownEnvError;
8
+ exports.decorateEnvError = decorateEnvError;
7
9
  exports.extractData = extractData;
8
10
  exports.buildInnerUrl = buildInnerUrl;
9
11
  const error_1 = require("../../utils/error");
@@ -69,7 +71,8 @@ function ensureInnerSuccess(body) {
69
71
  *
70
72
  * 优先级:
71
73
  * 1. k_dl_1300002 + SQLSTATE 透传 → SQLSTATE_MAP
72
- * 2. k_dl_1600000 + "Invalid DB Branch" → MULTI_ENV_NOT_INITIALIZED
74
+ * 2. k_dl_1600000 + "Invalid DB Branch" → UNKNOWN_ENV_VALUE 占位(mapDbHttpError
75
+ * 会用用户实际 --env 值通过 decorateEnvError 重写)
73
76
  * 3. BIZ_ERR_MAP 命中 → 语义 code(可能带 hint)
74
77
  * 4. 兜底 DB_API_<code>
75
78
  *
@@ -84,7 +87,13 @@ function mapDataloomBizError(code, rawMessage) {
84
87
  }
85
88
  }
86
89
  if (code === 'k_dl_1600000' && message.startsWith('Invalid DB Branch')) {
87
- return new error_1.AppError('MULTI_ENV_NOT_INITIALIZED', '--env is not available (multi-env not initialized)', { next_actions: ['Verify the --env value matches an existing dbBranch.'] });
90
+ // 这里没有 user-facing --env 值(client 层不知道 CLI 入参),先用 server 侧返回的
91
+ // 分支名占位;mapDbHttpError 在拿到 env 上下文后会通过 decorateEnvError 重写成
92
+ // 用户传入的实际值。dataloom 错误文案历史上同时存在英文冒号和中文冒号「:」格式,
93
+ // 这里两种都接。
94
+ const m = /Invalid DB Branch[::\s]+([^\s]+)/i.exec(message);
95
+ const branch = m?.[1] ?? '<branch>';
96
+ return buildUnknownEnvError(branch);
88
97
  }
89
98
  const mapped = BIZ_ERR_MAP.get(code);
90
99
  if (mapped) {
@@ -94,6 +103,71 @@ function mapDataloomBizError(code, rawMessage) {
94
103
  }
95
104
  return new error_1.AppError(`DB_API_${code}`, message);
96
105
  }
106
+ /**
107
+ * typo / CLI vocab violation 路径用:state 未知,列双 env 让用户在 dev/online 之间选,
108
+ * 不引导 init(init 不会创建 typo 出来的分支)。
109
+ */
110
+ exports.ENV_CTX_TYPO = {
111
+ available: ['dev', 'online'],
112
+ suggestInit: false,
113
+ };
114
+ /**
115
+ * 统一构造"--env 值不可用"错误。两类入口共用:
116
+ * - CLI vocab 校验(_env.ts validateEnv):用户传 dev/main/online 以外值
117
+ * - backend state 错(decorateEnvError):vocab 合法但 db 实际状态对不上
118
+ *
119
+ * `available` 跟 `suggestInit` 由调用方按错误码归类传入;user-facing 不展示 `main`
120
+ * 内部别名,统一用 `online`。
121
+ */
122
+ function buildUnknownEnvError(env, ctx = exports.ENV_CTX_TYPO) {
123
+ const hints = [
124
+ `--env must match an existing database branch. Available: ${ctx.available.join(', ')}.`,
125
+ ];
126
+ if (ctx.suggestInit) {
127
+ hints.push(`Run \`miaoda db migration init\` before using '${env}'.`);
128
+ }
129
+ return new error_1.AppError('UNKNOWN_ENV_VALUE', `Unknown --env value '${env}'`, {
130
+ next_actions: hints,
131
+ });
132
+ }
133
+ /**
134
+ * 把 backend env 相关错误统一重写成 UNKNOWN_ENV_VALUE 文案,按错误码做 state 推断:
135
+ *
136
+ * - DB_API_k_dl_1300033(非专家应用)→ 单 env (online),不建议 init
137
+ * - DB_API_k_dl_1300039(专家未 init)→ 单 env (online),建议 init
138
+ * - DB_API_k_dl_000007 + "db branch not found"(pg connection 兜底)→ 单 env,建议 init
139
+ * - UNKNOWN_ENV_VALUE(mapDataloomBizError 对 k_dl_1600000 的占位)→ typo path
140
+ *
141
+ * env 未传时不改写:避免把 backend 兜底文案(如 "Multi-env is not initialized")
142
+ * 改成 `Unknown --env value ''` 这种空字符串错误。
143
+ */
144
+ function decorateEnvError(err, env) {
145
+ if (env === undefined || env === '')
146
+ return err;
147
+ const ctx = classifyEnvError(err);
148
+ if (!ctx)
149
+ return err;
150
+ return buildUnknownEnvError(env, ctx);
151
+ }
152
+ function classifyEnvError(err) {
153
+ if (err.code === 'DB_API_k_dl_1300033') {
154
+ // 非专家应用:init 也会被同一 guard 拒掉,建议 init 是误导
155
+ return { available: ['online'], suggestInit: false };
156
+ }
157
+ if (err.code === 'DB_API_k_dl_1300039') {
158
+ // 专家未 init:init 会创建 dev 分支
159
+ return { available: ['online'], suggestInit: true };
160
+ }
161
+ if (err.code === 'DB_API_k_dl_000007' && /db branch not found/i.test(err.message)) {
162
+ // pg connection 层 ErrParamsInvalid:大概率 pre-init;建议 init(非专家会被 init 路径拦)
163
+ return { available: ['online'], suggestInit: true };
164
+ }
165
+ if (err.code === 'UNKNOWN_ENV_VALUE') {
166
+ // mapDataloomBizError 对 k_dl_1600000 的占位:typo path,state 未知,列双 env
167
+ return exports.ENV_CTX_TYPO;
168
+ }
169
+ return null;
170
+ }
97
171
  /**
98
172
  * 剥掉 PG 透传错误的 "ERROR:" 前缀:CLI 输出本身就会前缀 "Error:",
99
173
  * 不去掉就会变成 `Error: ERROR: relation ...` 双重前缀,PRD 不要这种冗余。
@@ -201,12 +275,25 @@ function buildInnerUrl(appId, path, query) {
201
275
  let url = `/v1/dataloom/app/${encodeURIComponent(appId)}${normalized}`;
202
276
  if (query) {
203
277
  const usp = new URLSearchParams();
204
- for (const [k, v] of Object.entries(query)) {
205
- if (v === undefined || v === '')
278
+ for (const [k, rawV] of Object.entries(query)) {
279
+ if (rawV === undefined)
280
+ continue;
281
+ // 数组值 → 重复 key 编码(?k=v1&k=v2)。IDL 里 list<string> 类型经
282
+ // Hertz/Kitex HTTP→Thrift 网关绑定 slice 字段需要重复 key;如果改成
283
+ // join(',') 单串,后端会拿到 ["v1,v2"] 单元素切片而非 ["v1","v2"]。
284
+ if (typeof rawV !== 'string') {
285
+ for (const item of rawV) {
286
+ if (item === '')
287
+ continue;
288
+ usp.append(k, item);
289
+ }
290
+ continue;
291
+ }
292
+ if (rawV === '')
206
293
  continue;
207
294
  // dbBranch 兼容用户视角的 `online` 别名 → 后端实际 dbBranch 名为 `main`,
208
295
  // 这里集中归一,避免每个 API 函数各自处理。其他值(dev / 自定义分支)原样透传。
209
- const norm = k === 'dbBranch' && v === 'online' ? 'main' : v;
296
+ const norm = k === 'dbBranch' && rawV === 'online' ? 'main' : rawV;
210
297
  usp.append(k, norm);
211
298
  }
212
299
  const qs = usp.toString();
@@ -4,6 +4,7 @@ exports.registerDbCommands = registerDbCommands;
4
4
  const error_1 = require("../../../utils/error");
5
5
  const index_1 = require("../../../cli/handlers/db/index");
6
6
  const shared_1 = require("../../../cli/commands/shared");
7
+ const _env_1 = require("../../../cli/handlers/db/_env");
7
8
  function parsePositiveInt(raw) {
8
9
  const n = Number(raw);
9
10
  if (!Number.isInteger(n) || n < 1) {
@@ -19,6 +20,13 @@ function registerDbCommands(program) {
19
20
  // --env 注册在 db 父级,spec 把它列入 db --help 的 Global Flags;
20
21
  // leaf 命令仍各自接收 --env 值(commander 解析时父级 option 自动适用于子命令)
21
22
  .option('--env <name>', '指定目标环境(dev / online,仅专家模式应用支持)');
23
+ // 任意 leaf action 执行前先做 --env vocab 校验:用户拼错('onlin'/'prod' 等)立即报
24
+ // UNKNOWN_ENV_VALUE 避免后端无意义被打到。preAction hook 拿父级 dbCmd 的 --env 值
25
+ // —— commander 把父级 option 自动 propagate 给子命令的 optsWithGlobals。
26
+ dbCmd.hook('preAction', () => {
27
+ const env = dbCmd.opts().env;
28
+ (0, _env_1.validateEnv)(typeof env === 'string' ? env : undefined);
29
+ });
22
30
  dbCmd.action(() => {
23
31
  dbCmd.outputHelp();
24
32
  });
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ // db 域内不可逆 / destructive 操作的统一确认门。
3
+ //
4
+ // 设计要点:
5
+ // 1. --yes:放行(CI / 脚本显式确认场景)
6
+ // 2. 非 TTY 且无 --yes:抛 DESTRUCTIVE_REQUIRES_CONFIRM,不要默认放行。
7
+ // 非 TTY 通常是 CI / agent / 管道场景,无人能交互回答 y/N,必须靠 --yes 显式确认。
8
+ // 3. TTY 且无 --yes:交互式 y/N(prompt 写 stderr,避免污染 --json 模式 stdout)
9
+ //
10
+ // 反例(已修复,见这次 commit):把 !isJsonMode() 拿来做确认门 —— --json 只改输出
11
+ // 格式,跟"用户已确认"不是同一语义。CI / agent 几乎总会带 --json 解析输出,等价于
12
+ // 把不可逆操作的确认变成了空门,跟 file rm 的模型也不一致。
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.confirmDestructive = confirmDestructive;
18
+ exports.assertDestructiveAllowedInTty = assertDestructiveAllowedInTty;
19
+ exports.askYesNo = askYesNo;
20
+ const node_readline_1 = __importDefault(require("node:readline"));
21
+ const render_1 = require("../../../utils/render");
22
+ const error_1 = require("../../../utils/error");
23
+ const DESTRUCTIVE_REQUIRES_CONFIRM_MSG = 'This operation is destructive. Rerun with --yes to confirm.';
24
+ /**
25
+ * 完整的"yes/tty/交互"决策门。直接用于无需 fetch 预览的简单确认场景(如 migration init)。
26
+ *
27
+ * 返回值:
28
+ * - true → 已确认,可执行
29
+ * - false → TTY 下用户输了 n,调用方应渲染 Aborted 并退出
30
+ *
31
+ * 异常:
32
+ * - DESTRUCTIVE_REQUIRES_CONFIRM:非 TTY 且无 --yes
33
+ */
34
+ async function confirmDestructive(prompt, yes) {
35
+ if (yes === true)
36
+ return true;
37
+ if (!(0, render_1.isStdoutTty)()) {
38
+ throw new error_1.AppError('DESTRUCTIVE_REQUIRES_CONFIRM', DESTRUCTIVE_REQUIRES_CONFIRM_MSG);
39
+ }
40
+ return askYesNo(prompt);
41
+ }
42
+ /**
43
+ * 给"需要在 confirm 之前先 fetch 预览"的场景(migration apply / recovery apply 可选优化)用:
44
+ * 在非 TTY 无 --yes 时提前抛错,避免无意义触发后端 dry-run RPC。
45
+ *
46
+ * 调用后 TTY 路径仍需自行调 askYesNo / confirmDestructive 完成交互。
47
+ */
48
+ function assertDestructiveAllowedInTty(yes) {
49
+ if (yes === true)
50
+ return;
51
+ if (!(0, render_1.isStdoutTty)()) {
52
+ throw new error_1.AppError('DESTRUCTIVE_REQUIRES_CONFIRM', DESTRUCTIVE_REQUIRES_CONFIRM_MSG);
53
+ }
54
+ }
55
+ /**
56
+ * 交互式 y/N。输入读 stdin,prompt 写 stderr —— stderr 避免污染 --json 模式 stdout 的 envelope。
57
+ * 仅 'y'(忽略大小写)算确认;其余一律视为否决。
58
+ */
59
+ async function askYesNo(prompt) {
60
+ const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
61
+ return new Promise((resolve) => {
62
+ rl.question(prompt, (answer) => {
63
+ rl.close();
64
+ resolve(answer.trim().toLowerCase() === 'y');
65
+ });
66
+ });
67
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ // db 域内 --env 参数的 CLI vocab 校验。
3
+ //
4
+ // 用户视角的 vocab:
5
+ // - dev → 多环境沙箱分支
6
+ // - online → 生产分支(向后端发请求时映射成 main,client.ts/api.ts 已处理)
7
+ // - main → online 的别名,兼容历史脚本
8
+ //
9
+ // 配合 client.ts::buildUnknownEnvError,把 vocab 错(这里)跟 backend state 错
10
+ // (client.ts 处理)统一成同一类 UNKNOWN_ENV_VALUE,避免用户在两种失败之间困惑。
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.validateEnv = validateEnv;
13
+ const client_1 = require("../../../api/db/client");
14
+ const VOCAB = ['dev', 'main', 'online'];
15
+ const VOCAB_SET = new Set(VOCAB);
16
+ /**
17
+ * CLI vocab 校验。空值(未传 --env)放行,由后端按 workspace 默认 branch 处理。
18
+ * 不在 vocab 内 → 抛 UNKNOWN_ENV_VALUE,preAction hook 兜底,避免无意义触发 RPC。
19
+ */
20
+ function validateEnv(env) {
21
+ if (env === undefined || env === '')
22
+ return;
23
+ if (VOCAB_SET.has(env))
24
+ return;
25
+ throw (0, client_1.buildUnknownEnvError)(env);
26
+ }
@@ -45,6 +45,7 @@ const output_1 = require("../../../utils/output");
45
45
  const render_1 = require("../../../utils/render");
46
46
  const time_1 = require("../../../utils/time");
47
47
  const _operator_1 = require("../../../cli/handlers/db/_operator");
48
+ const index_1 = require("../../../api/db/index");
48
49
  const VALID_RETENTION = new Set(['7d', '30d', '180d', '360d', 'forever']);
49
50
  async function handleDbAuditStatus(table, opts) {
50
51
  const appId = (0, shared_1.resolveAppId)(opts);
@@ -200,43 +201,43 @@ async function handleDbAuditList(tables, opts) {
200
201
  const since = normalizeTime(opts.since, '--since');
201
202
  const until = normalizeTime(opts.until, '--until');
202
203
  // 多表场景:dataloom 对"任一表不存在"是 fail-fast(typo 应立即可见,符合 PRD 单表语义),
203
- // 但 PRD 多表语义是"其他表继续返回,底部汇总跳过情况"。CLI 在这里做闭环:把不存在的表
204
- // 从入参里剥掉重试,最终跟服务端 skipped(audit 未启用)合并展示。单表场景仍 fail-fast。
204
+ // 但 PRD 多表语义是"其他表继续返回,底部汇总跳过情况"。CLI 在这里前置校验:用 getSchema
205
+ // tableNames 过滤模式(dataloom 端内存过滤,不存在的表静默忽略,不报错)拿到存在的
206
+ // 表集合,本地 diff 出缺失表,再用过滤后的表去查 listAuditLog。单表场景跳过预校验。
205
207
  const isMulti = tables.length > 1;
206
- let result;
208
+ let queryTables = tables;
207
209
  const localMissing = [];
208
- let curTables = [...tables];
209
- for (;;) {
210
- try {
211
- result = await api.db.listAuditLog({
212
- appId,
213
- tables: curTables,
214
- since,
215
- until,
216
- limit,
217
- cursor: opts.cursor,
218
- dbBranch: opts.env,
219
- });
220
- break;
210
+ if (isMulti) {
211
+ const schemaResp = await api.db.getSchema({
212
+ appId,
213
+ tableNames: tables.join(','),
214
+ dbBranch: opts.env,
215
+ });
216
+ const existing = new Set((0, index_1.flattenSchemaList)(schemaResp.schema).map((t) => t.name));
217
+ for (const t of tables) {
218
+ if (!existing.has(t))
219
+ localMissing.push(t);
221
220
  }
222
- catch (err) {
223
- if (isMulti && err instanceof error_1.AppError && err.code === 'DB_API_k_dl_000005') {
224
- const missing = extractMissingTable(err.message);
225
- if (missing !== null && curTables.includes(missing)) {
226
- localMissing.push(missing);
227
- curTables = curTables.filter((t) => t !== missing);
228
- if (curTables.length === 0) {
229
- // 全部表都不存在 → 抛 TABLE_NOT_FOUND,列出所有缺失表
230
- throw new error_1.AppError('TABLE_NOT_FOUND', `None of the requested tables exist: ${localMissing.join(', ')}`, {
231
- next_actions: ['Run `miaoda db schema list` to see all tables.'],
232
- });
233
- }
234
- continue;
235
- }
236
- }
237
- throw decorateAuditListError(err, tables);
221
+ queryTables = tables.filter((t) => existing.has(t));
222
+ if (queryTables.length === 0) {
223
+ throw new error_1.AppError('TABLE_NOT_FOUND', `None of the requested tables exist: ${localMissing.join(', ')}`, { next_actions: ['Run `miaoda db schema list` to see all tables.'] });
238
224
  }
239
225
  }
226
+ let result;
227
+ try {
228
+ result = await api.db.listAuditLog({
229
+ appId,
230
+ tables: queryTables,
231
+ since,
232
+ until,
233
+ limit,
234
+ cursor: opts.cursor,
235
+ dbBranch: opts.env,
236
+ });
237
+ }
238
+ catch (err) {
239
+ throw decorateAuditListError(err, tables);
240
+ }
240
241
  const visible = result.items;
241
242
  // 服务端 skipped(audit 未启用,bare 名)+ CLI 本地探测 missing 合并;
242
243
  // localMissing 自带 `(table not found)` 后缀,formatSkippedHint 透传渲染
@@ -264,7 +265,7 @@ async function handleDbAuditList(tables, opts) {
264
265
  return;
265
266
  }
266
267
  if (visible.length === 0) {
267
- (0, output_1.emit)('No audit log entries found.');
268
+ (0, output_1.emit)('No audit events found.');
268
269
  if (skipped.length > 0) {
269
270
  process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
270
271
  }
@@ -32,14 +32,10 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
35
  Object.defineProperty(exports, "__esModule", { value: true });
39
36
  exports.handleDbMigrationInit = handleDbMigrationInit;
40
37
  exports.handleDbMigrationDiff = handleDbMigrationDiff;
41
38
  exports.handleDbMigrationApply = handleDbMigrationApply;
42
- const node_readline_1 = __importDefault(require("node:readline"));
43
39
  const api = __importStar(require("../../../api/index"));
44
40
  const shared_1 = require("../../../cli/commands/shared");
45
41
  const colors_1 = require("../../../utils/colors");
@@ -48,17 +44,16 @@ const output_1 = require("../../../utils/output");
48
44
  const poll_1 = require("../../../utils/poll");
49
45
  const render_1 = require("../../../utils/render");
50
46
  const spinner_1 = require("../../../utils/spinner");
47
+ const _destructive_1 = require("../../../cli/handlers/db/_destructive");
51
48
  async function handleDbMigrationInit(opts) {
52
49
  const appId = (0, shared_1.resolveAppId)(opts);
53
- // 不可逆操作,TTY 默认要求 y/N;--yes 跳过
54
- if (!opts.yes && !(0, output_1.isJsonMode)()) {
55
- // 文案对齐 PRD:先讲不可逆,再问是否初始化;syncData 时追加一句而不是塞进同一句话
56
- const suffix = opts.syncData ? ' (existing data will be copied to dev)' : '';
57
- const ok = await confirm(`? This action is irreversible. Initialize multi-env (dev / online)${suffix}? (y/N) `);
58
- if (!ok) {
59
- (0, output_1.emit)('Aborted.');
60
- return;
61
- }
50
+ // 不可逆操作:--yes 直接放行;非 TTY DESTRUCTIVE_REQUIRES_CONFIRM;TTY 交互确认。
51
+ // 不再用 isJsonMode() 做门 —— 见 _destructive.ts 注释。
52
+ const suffix = opts.syncData ? ' (existing data will be copied to dev)' : '';
53
+ const ok = await (0, _destructive_1.confirmDestructive)(`? This action is irreversible. Initialize multi-env (dev / online)${suffix}? (y/N) `, opts.yes);
54
+ if (!ok) {
55
+ (0, output_1.emit)('Aborted.');
56
+ return;
62
57
  }
63
58
  let result;
64
59
  try {
@@ -89,7 +84,7 @@ async function handleDbMigrationInit(opts) {
89
84
  async function handleDbMigrationDiff(opts) {
90
85
  const appId = (0, shared_1.resolveAppId)(opts);
91
86
  let result;
92
- const stopSpinner = (0, spinner_1.startSpinner)('Computing migration diff');
87
+ const stopSpinner = (0, spinner_1.startSpinner)('Previewing migration diff (dev → online)');
93
88
  try {
94
89
  result = await api.db.migrate({ appId, dryRun: true });
95
90
  }
@@ -111,10 +106,11 @@ async function handleDbMigrationDiff(opts) {
111
106
  }
112
107
  async function handleDbMigrationApply(opts) {
113
108
  const appId = (0, shared_1.resolveAppId)(opts);
114
- // TTY 下先 diff 给用户审;--yes 直接打到 online
115
- if (!opts.yes && !(0, output_1.isJsonMode)()) {
109
+ // --yes 跳过预览 + 确认;否则 TTY diff 给用户审,非 TTY 直接拒(避免无意义 dry-run RPC)
110
+ if (opts.yes !== true) {
111
+ (0, _destructive_1.assertDestructiveAllowedInTty)(opts.yes);
116
112
  let preview;
117
- const stopSpinner = (0, spinner_1.startSpinner)('Computing migration diff');
113
+ const stopSpinner = (0, spinner_1.startSpinner)('Previewing migration diff (dev → online)');
118
114
  try {
119
115
  preview = await api.db.migrate({ appId, dryRun: true });
120
116
  }
@@ -132,8 +128,10 @@ async function handleDbMigrationApply(opts) {
132
128
  ],
133
129
  });
134
130
  }
135
- renderDiff(preview);
136
- const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
131
+ // --json 模式跳过 pretty diff 渲染(污染 stdout envelope),但仍要求 confirm
132
+ if (!(0, output_1.isJsonMode)())
133
+ renderDiff(preview);
134
+ const ok = await (0, _destructive_1.askYesNo)(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
137
135
  if (!ok) {
138
136
  (0, output_1.emit)('Aborted.');
139
137
  return;
@@ -154,7 +152,7 @@ async function handleDbMigrationApply(opts) {
154
152
  const taskId = result.taskId;
155
153
  const final = await (0, poll_1.pollUntilDone)({
156
154
  label: 'migration apply',
157
- spinnerLabel: 'Applying migration to online',
155
+ spinnerLabel: 'Applying migration (dev online)',
158
156
  intervalMs: 1000,
159
157
  fetch: () => api.db.getMigrationStatus({ appId, taskId }),
160
158
  isDone: (cur) => {
@@ -234,12 +232,3 @@ function decorateMigrationError(err) {
234
232
  return err;
235
233
  }
236
234
  }
237
- async function confirm(prompt) {
238
- const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
239
- return new Promise((resolve) => {
240
- rl.question(prompt, (answer) => {
241
- rl.close();
242
- resolve(answer.trim().toLowerCase() === 'y');
243
- });
244
- });
245
- }
@@ -32,13 +32,10 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
35
  Object.defineProperty(exports, "__esModule", { value: true });
39
36
  exports.handleDbRecoveryDiff = handleDbRecoveryDiff;
40
37
  exports.handleDbRecoveryApply = handleDbRecoveryApply;
41
- const node_readline_1 = __importDefault(require("node:readline"));
38
+ exports.formatRecoveryTargetForDisplay = formatRecoveryTargetForDisplay;
42
39
  const api = __importStar(require("../../../api/index"));
43
40
  const shared_1 = require("../../../cli/commands/shared");
44
41
  const colors_1 = require("../../../utils/colors");
@@ -47,6 +44,7 @@ const output_1 = require("../../../utils/output");
47
44
  const poll_1 = require("../../../utils/poll");
48
45
  const render_1 = require("../../../utils/render");
49
46
  const time_1 = require("../../../utils/time");
47
+ const _destructive_1 = require("../../../cli/handlers/db/_destructive");
50
48
  // ── recovery diff ──
51
49
  //
52
50
  // PITR preview 是异步任务:dataloom 立即返 previewRequestId,CLI 自己 poll 直到 success/failed。
@@ -55,7 +53,7 @@ const time_1 = require("../../../utils/time");
55
53
  async function handleDbRecoveryDiff(target, opts) {
56
54
  const appId = (0, shared_1.resolveAppId)(opts);
57
55
  const ts = normalizeTimestamp(target);
58
- const preview = await runRecoveryPreview(appId, ts);
56
+ const preview = await runRecoveryPreview(appId, ts, target);
59
57
  renderDiff(ts, preview);
60
58
  }
61
59
  async function handleDbRecoveryApply(target, opts) {
@@ -64,7 +62,7 @@ async function handleDbRecoveryApply(target, opts) {
64
62
  // PRD 要求 apply 输出包含 N tables affected / Ms elapsed。
65
63
  // tables_affected 从 preview 拿;elapsed 用 CLI 端墙钟(dataloom apply 是异步
66
64
  // 触发,server-side elapsed=0;下面 poll 真终态后用本地计时器算 elapsed)。
67
- const preview = await runRecoveryPreview(appId, ts);
65
+ const preview = await runRecoveryPreview(appId, ts, target);
68
66
  const tablesAffected = preview.tablesAffected ?? preview.changes?.length ?? 0;
69
67
  // 0 changes 短路:目标时间点与当前一致,apply 没意义。pretty 模式渲染 preview
70
68
  // 的 "No changes — database is already at this state." 后直接退;--json 模式
@@ -83,9 +81,14 @@ async function handleDbRecoveryApply(target, opts) {
83
81
  }
84
82
  return;
85
83
  }
86
- if (!opts.yes && !(0, output_1.isJsonMode)()) {
87
- renderDiff(ts, preview);
88
- const ok = await confirm(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
84
+ // --yes 跳过;非 TTY DESTRUCTIVE_REQUIRES_CONFIRM;TTY 渲染 diff(pretty)+ 交互确认。
85
+ // 不再用 isJsonMode() 做门 —— 见 _destructive.ts 注释。
86
+ if (opts.yes !== true) {
87
+ (0, _destructive_1.assertDestructiveAllowedInTty)(opts.yes);
88
+ // --json 模式跳过 pretty diff 渲染避免污染 stdout envelope;TTY pretty 模式渲染
89
+ if (!(0, output_1.isJsonMode)())
90
+ renderDiff(ts, preview);
91
+ const ok = await (0, _destructive_1.askYesNo)(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
89
92
  if (!ok) {
90
93
  (0, output_1.emit)('Aborted.');
91
94
  return;
@@ -102,7 +105,7 @@ async function handleDbRecoveryApply(target, opts) {
102
105
  // 立即返 status="restore_triggered",CLI 必须 poll GET /db/recovery/status 拿
103
106
  // Redis 缓存的 Resetting/Ready/Failed,否则用户会被误导以为已恢复完。
104
107
  const startedAt = Date.now();
105
- await waitRecoveryDone(appId, ts);
108
+ await waitRecoveryDone(appId, ts, target);
106
109
  const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
107
110
  if ((0, output_1.isJsonMode)()) {
108
111
  // PRD:{"status": "restored", "target": "...", "tables_affected": N, "elapsed_seconds": M}
@@ -129,11 +132,11 @@ async function handleDbRecoveryApply(target, opts) {
129
132
  * 不设固定超时:BranchRestore 实际时长依库大小变化(前端文案示例 1 分钟,大库可能
130
133
  * 几分钟),由 pollUntilDone 内置 60 分钟上限兜底,远超正常场景。
131
134
  */
132
- async function waitRecoveryDone(appId, target) {
135
+ async function waitRecoveryDone(appId, target, rawTarget) {
133
136
  try {
134
137
  return await (0, poll_1.pollUntilDone)({
135
138
  label: 'recovery apply',
136
- spinnerLabel: 'Restoring database to target time',
139
+ spinnerLabel: `Restoring database (target: ${formatRecoveryTargetForDisplay(rawTarget)})`,
137
140
  intervalMs: 2000,
138
141
  fetch: () => api.db.getRecoveryStatus({ appId }),
139
142
  isDone: (cur) => {
@@ -160,7 +163,7 @@ async function waitRecoveryDone(appId, target) {
160
163
  * 补 hint。failed 状态下 dataloom 已把 errorMessage 翻好,CLI 包成 k_dl_1300036
161
164
  * 让 decorateRecoveryError 命中窗口超限 hint。
162
165
  */
163
- async function runRecoveryPreview(appId, ts) {
166
+ async function runRecoveryPreview(appId, ts, rawTarget) {
164
167
  let triggered;
165
168
  try {
166
169
  triggered = await api.db.recover({ appId, target: ts, dryRun: true });
@@ -175,7 +178,7 @@ async function runRecoveryPreview(appId, ts) {
175
178
  try {
176
179
  return await (0, poll_1.pollUntilDone)({
177
180
  label: 'recovery preview',
178
- spinnerLabel: 'Computing recovery preview',
181
+ spinnerLabel: `Previewing recovery impact (target: ${formatRecoveryTargetForDisplay(rawTarget)})`,
179
182
  intervalMs: 1000,
180
183
  fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
181
184
  isDone: (cur) => {
@@ -199,6 +202,38 @@ async function runRecoveryPreview(appId, ts) {
199
202
  }
200
203
  }
201
204
  // ── helpers ──
205
+ /**
206
+ * 把用户传入的 recovery target 解析成本地时区的 ISO 8601 字符串,
207
+ * 如 "2026-05-20T22:32:00+08:00"。无论用户传相对值(30m / 2h / 3d)还是绝对值,
208
+ * 都统一展示成"具体几点",让用户一眼看清楚"目标恢复点是我本地几点"——
209
+ * "30m ago"这种相对描述虽然原样直观,但跟 backend dataloom 收到的实际时间点没有
210
+ * 显式对照,遇到长 poll 时间过去后会让人怀疑"算的到底是哪个点"。
211
+ *
212
+ * 不用 UTC 'Z' 后缀:用户读时间不直观;带 +/-HH:MM offset 一眼即懂时差。
213
+ * 解析失败 → 原样返回兜底,避免 spinner 渲染崩。
214
+ */
215
+ function formatRecoveryTargetForDisplay(rawInput) {
216
+ const trimmed = rawInput.trim();
217
+ let ms;
218
+ try {
219
+ ms = (0, time_1.parseTimeToMs)(trimmed);
220
+ }
221
+ catch {
222
+ return trimmed;
223
+ }
224
+ return formatIsoWithLocalOffset(ms);
225
+ }
226
+ function formatIsoWithLocalOffset(ms) {
227
+ const d = new Date(ms);
228
+ const pad = (n) => String(n).padStart(2, '0');
229
+ // getTimezoneOffset 返回本地相对 UTC 的差值(分钟)— UTC 在东八区是 -480
230
+ const offsetMinutes = -d.getTimezoneOffset();
231
+ const sign = offsetMinutes >= 0 ? '+' : '-';
232
+ const absOffset = Math.abs(offsetMinutes);
233
+ const tz = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
234
+ return (`${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
235
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${tz}`);
236
+ }
202
237
  /**
203
238
  * 把用户传入的时间统一成 ISO 8601 UTC 字符串发给 dataloom。
204
239
  *
@@ -376,12 +411,3 @@ function decorateRecoveryError(err) {
376
411
  return err;
377
412
  }
378
413
  }
379
- async function confirm(prompt) {
380
- const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
381
- return new Promise((resolve) => {
382
- rl.question(prompt, (answer) => {
383
- rl.close();
384
- resolve(answer.trim().toLowerCase() === 'y');
385
- });
386
- });
387
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.3-alpha.2d526ac",
3
+ "version": "0.1.3-alpha.3198e3b",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {