@lark-apaas/miaoda-cli 0.1.1-alpha.d20f110 → 0.1.1-alpha.e70b415

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.
@@ -16,7 +16,13 @@ const client_1 = require("./client");
16
16
  * 配合调用点的 try/catch + traceHttp,让 --verbose 在错误路径上也能拿到
17
17
  * x-tt-logid 与 status,方便定位线上问题。
18
18
  */
19
- async function mapDbHttpError(err, url, ctx) {
19
+ async function mapDbHttpError(err, url, ctx,
20
+ /**
21
+ * 可选 hook:解析到响应 body 后,如果 envelope 命中业务错误并抛出 AppError,
22
+ * 调用方可以借此把 body 里的额外字段(如 multi-statement 的 partial results)
23
+ * 挂到 AppError 上。
24
+ */
25
+ onErrorBody) {
20
26
  if (err instanceof error_1.AppError)
21
27
  throw err;
22
28
  if (err instanceof http_client_1.HttpError) {
@@ -24,8 +30,17 @@ async function mapDbHttpError(err, url, ctx) {
24
30
  const statusText = err.response?.statusText ?? "";
25
31
  try {
26
32
  const body = (await err.response?.json());
27
- if (body)
28
- (0, client_1.extractData)(body); // 业务 code 命中 → 抛 AppError;不命中走兜底
33
+ if (body) {
34
+ try {
35
+ (0, client_1.extractData)(body); // 业务 code 命中 → 抛 AppError;不命中走兜底
36
+ }
37
+ catch (appErr) {
38
+ if (appErr instanceof error_1.AppError && onErrorBody) {
39
+ onErrorBody(body, appErr);
40
+ }
41
+ throw appErr;
42
+ }
43
+ }
29
44
  }
30
45
  catch (innerErr) {
31
46
  if (innerErr instanceof error_1.AppError)
@@ -36,6 +51,20 @@ async function mapDbHttpError(err, url, ctx) {
36
51
  }
37
52
  throw err;
38
53
  }
54
+ /**
55
+ * 多语句 SQL 失败时把已成功 statement 的 results 挂到 AppError.partial_results 上,
56
+ * 供上层 handler 构造 PRD 要求的 `completed` 数组。
57
+ *
58
+ * 注意参数顺序:(body, appErr) — 与 mapDbHttpError 的 onErrorBody hook 签名对齐。
59
+ */
60
+ function attachSqlPartialResults(body, appErr) {
61
+ const data = body.data;
62
+ if (!data)
63
+ return;
64
+ if (!Array.isArray(data.results) || data.results.length === 0)
65
+ return;
66
+ appErr.partial_results = data.results;
67
+ }
39
68
  // CLI 不再为 dbBranch 设默认值:
40
69
  // 用户没传 --env 就完全不携带 dbBranch query 参数,由后端 admin-inner 中间件
41
70
  // 按 workspace 多环境状态决定(多环境 → dev / 单环境 → main)。
@@ -61,12 +90,20 @@ async function execSql(opts) {
61
90
  }
62
91
  catch (err) {
63
92
  (0, client_1.traceHttp)("POST", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
64
- await mapDbHttpError(err, url, "Failed to execute SQL");
93
+ await mapDbHttpError(err, url, "Failed to execute SQL", attachSqlPartialResults);
65
94
  throw err; // 不可达
66
95
  }
67
96
  const body = (await response.json());
68
- const data = (0, client_1.extractData)(body);
69
- return data.results ?? [];
97
+ try {
98
+ const data = (0, client_1.extractData)(body);
99
+ return data.results ?? [];
100
+ }
101
+ catch (appErr) {
102
+ // HTTP 2xx 但 envelope status_code != 0 的多语句失败路径:同样挂 partial_results
103
+ if (appErr instanceof error_1.AppError)
104
+ attachSqlPartialResults(body, appErr);
105
+ throw appErr;
106
+ }
70
107
  }
71
108
  // ── db schema → InnerGetSchema ──
72
109
  /**
@@ -118,6 +118,9 @@ const BIZ_ERR_MAP = new Map(Object.entries({
118
118
  "k_dl_000003": { code: "SQL_OPERATION_FORBIDDEN" },
119
119
  // k_dl_1300002:PG 执行错误;实际 SQLSTATE 由 extractSqlstate 单独映射
120
120
  // 未匹配到 SQLSTATE 时走下面兜底 DB_API_<code>
121
+ // k_dl_1300015:SELECT 结果超过 1000 行硬拦;多行 hint 由 output.ts 的
122
+ // SERVER_ERROR_HINTS 按语义 code 兜底,这里只做 code 改名
123
+ "k_dl_1300015": { code: "RESULT_SET_TOO_LARGE" },
121
124
  }));
122
125
  /** PG SQLSTATE → CLI code(当前 dataloom 不一定透传,预留未来使用) */
123
126
  exports.SQLSTATE_MAP = {
@@ -117,11 +117,35 @@ function toDetail(t, stats) {
117
117
  // 优先用 tableStats.indexes(PRIMARY + UNIQUE + INDEX 统一视图);
118
118
  // 回退到 TableVO.indexes(仅二级索引,老后端没返回 tableStats 时使用)
119
119
  const rawIndexes = stats?.indexes ?? t.indexes ?? [];
120
+ const constraints = [];
121
+ const indexes = [];
122
+ for (const i of rawIndexes) {
123
+ const cols = i.indexColumns ?? [];
124
+ // 过滤掉空列条目(后端偶发返 indexColumns=[] 的占位项),渲染成 "UNIQUE ()" 看起来像 bug
125
+ if (cols.length === 0)
126
+ continue;
127
+ const kind = i.indexType.toLowerCase();
128
+ if (kind === "primary") {
129
+ constraints.push({ type: "PRIMARY KEY", columns: cols });
130
+ }
131
+ else if (kind === "unique") {
132
+ constraints.push({ type: "UNIQUE", columns: cols });
133
+ }
134
+ else {
135
+ // foreign / normal / 其它统一进 indexes 段;method 从 indexDef 的 USING 段解析
136
+ indexes.push({
137
+ name: i.indexName,
138
+ columns: cols,
139
+ method: parseIndexMethod(i.indexDef) ?? "btree",
140
+ });
141
+ }
142
+ }
120
143
  return {
121
144
  name: t.tableName,
122
145
  description: t.comment && t.comment.length > 0 ? t.comment : null,
123
146
  columns: (t.fields ?? []).map(toColumn),
124
- indexes: rawIndexes.map(toIndex),
147
+ constraints,
148
+ indexes,
125
149
  estimated_row_count: typeof stats?.estimatedRowCount === "number" ? stats.estimatedRowCount : null,
126
150
  size_bytes: typeof stats?.sizeBytes === "number" ? stats.sizeBytes : null,
127
151
  };
@@ -137,25 +161,14 @@ function toColumn(f) {
137
161
  comment: f.comment && f.comment.length > 0 ? f.comment : null,
138
162
  };
139
163
  }
140
- function toIndex(i) {
141
- return {
142
- name: i.indexName,
143
- type: translateIndexType(i.indexType),
144
- columns: i.indexColumns ?? [],
145
- };
146
- }
147
164
  /**
148
- * 把后端原始 IndexType(小写:primary / unique / normal / foreign)翻译为 PRD 约定的展示值。
149
- * 规则:
150
- * primary → "PRIMARY KEY"
151
- * unique → "UNIQUE"
152
- * 其它 → "INDEX"(foreign 外键通过 relationships 表达,不进 indexes;真的遇到也归 INDEX)
165
+ * PG 的 CREATE INDEX 语句里提取访问方法(btree / gin / gist / hash 等)。
166
+ * indexDef 示例: "CREATE INDEX idx_users_name ON public.users USING btree (name)"
167
+ * 解析失败返 null,由调用方兜底成 PG 默认的 "btree"。
153
168
  */
154
- function translateIndexType(raw) {
155
- const s = raw.toLowerCase();
156
- if (s === "primary")
157
- return "PRIMARY KEY";
158
- if (s === "unique")
159
- return "UNIQUE";
160
- return "INDEX";
169
+ function parseIndexMethod(indexDef) {
170
+ if (!indexDef)
171
+ return null;
172
+ const m = /USING\s+(\w+)/i.exec(indexDef);
173
+ return m ? m[1].toLowerCase() : null;
161
174
  }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SQL_KEYWORDS = void 0;
4
+ /**
5
+ * 常见 PG SQL 关键字白名单,用于 db sql 拼写错误的 did-you-mean 提示。
6
+ *
7
+ * # 选词原则
8
+ *
9
+ * PG 的关键字总数 700+(见 [pg_keyword 系统视图]),但 CLI 用户实际打错的几乎
10
+ * 都集中在核心动词 / 子句 / 类型 / 事务关键字。这里**少而精**,约 70 个:
11
+ *
12
+ * - DML 动词:SELECT / INSERT / UPDATE / DELETE / MERGE
13
+ * - 子句:FROM / WHERE / JOIN / GROUP / ORDER / HAVING / LIMIT / ...
14
+ * - 操作符词:AND / OR / NOT / IN / IS / NULL / LIKE / ...
15
+ * - DDL:CREATE / ALTER / DROP / TABLE / INDEX / VIEW / ...
16
+ * - 约束:PRIMARY / UNIQUE / FOREIGN / REFERENCES / CHECK / DEFAULT / ...
17
+ * - 事务:BEGIN / COMMIT / ROLLBACK / SAVEPOINT / TRANSACTION
18
+ * - 控制:IF / EXISTS / CASE / WHEN / THEN / ELSE / END / WITH / ...
19
+ *
20
+ * **不要往里加冷僻关键字**——候选集越大,短词越容易 false-positive 错配。
21
+ */
22
+ exports.SQL_KEYWORDS = [
23
+ // DML 动词
24
+ "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE",
25
+ // FROM / JOIN 系列
26
+ "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "USING",
27
+ // 聚合 / 排序 / 分页
28
+ "GROUP", "ORDER", "BY", "HAVING", "LIMIT", "OFFSET", "FETCH",
29
+ // 集合操作
30
+ "UNION", "INTERSECT", "EXCEPT", "DISTINCT", "ALL",
31
+ // 别名 / 关联
32
+ "AS", "ON",
33
+ // 操作符词
34
+ "AND", "OR", "NOT", "IN", "IS", "NULL", "TRUE", "FALSE",
35
+ "BETWEEN", "LIKE", "ILIKE", "SIMILAR",
36
+ // DDL
37
+ "CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME",
38
+ "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", "COLUMN", "CONSTRAINT", "SEQUENCE",
39
+ // 约束
40
+ "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CHECK", "DEFAULT",
41
+ // 写入
42
+ "VALUES", "SET", "RETURNING", "INTO",
43
+ // 事务
44
+ "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "TRANSACTION",
45
+ // 控制流 / CTE
46
+ "IF", "EXISTS", "REPLACE", "WITH", "RECURSIVE",
47
+ "CASE", "WHEN", "THEN", "ELSE", "END",
48
+ // 类型转换 / 时间提取
49
+ "CAST", "EXTRACT",
50
+ // 排序方向
51
+ "ASC", "DESC", "NULLS", "FIRST", "LAST",
52
+ ];
@@ -299,18 +299,28 @@ async function preUpload(appId, req) {
299
299
  }
300
300
  /**
301
301
  * 调用 upload callback 拿到对象元数据。
302
- * 后端把对象 VO 序列化成 JSON 字符串放在 data.metadata 字段里,这里解析出实际
303
- * filePath / file_name / download_url uploadFile 调用方使用,避免 CLI 自己拼路径。
302
+ *
303
+ * 网关 IDL metadata 字段加了 api.response.converter = "decode",正常路径下
304
+ * HTTP 响应里的 metadata 已经被网关从字符串解码成对象;这里两种形态都兼容:
305
+ * - object → 直接当 CallbackObjectVO 用(网关解码场景)
306
+ * - string → JSON.parse 出来用(后端原始形态 / 网关行为变化兜底)
307
+ *
308
+ * 解析失败 / metadata 缺失时返回空对象,由 uploadFile 用本地兜底字段填充。
304
309
  */
305
310
  async function uploadCallback(appId, req) {
306
311
  const url = `/v1/storage/app/${encodeURIComponent(appId)}/object/callback`;
307
312
  const body = await (0, client_1.doPost)(url, req, { errorContext: "upload callback" });
308
313
  const data = extractEnvelope(body);
309
- if (!data.metadata) {
314
+ const metadata = data.metadata;
315
+ if (!metadata) {
310
316
  return {};
311
317
  }
318
+ if (typeof metadata === "object") {
319
+ return metadata;
320
+ }
321
+ // string 形态兜底
312
322
  try {
313
- return JSON.parse(data.metadata);
323
+ return JSON.parse(metadata);
314
324
  }
315
325
  catch (err) {
316
326
  (0, logger_1.debug)(`upload callback metadata json parse failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -341,11 +351,15 @@ async function uploadFile(opts) {
341
351
  // 这里复制到独立 ArrayBuffer 以满足 lib.dom.d.ts 的 BodyInit 约束
342
352
  const ab = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
343
353
  const uploadStart = Date.now();
354
+ // Content-Disposition 用 attachment + filename 编码原始文件名。TOS 会把这个
355
+ // header 作为对象 metadata 存住,服务端 callback 阶段 HeadObject 读回并解析
356
+ // filename 写入 DB。我们要不传 header,服务端走兜底会把 storage key 当文件名。
344
357
  const res = await fetch(pre.uploadURL, {
345
358
  method: "PUT",
346
359
  headers: {
347
360
  "Content-Type": opts.contentType,
348
361
  "Content-Length": String(opts.fileSize),
362
+ "Content-Disposition": `attachment; filename="${sanitizeFileName(opts.fileName)}"`,
349
363
  },
350
364
  body: ab,
351
365
  });
@@ -378,6 +392,8 @@ async function uploadFile(opts) {
378
392
  ? (metadata.filePath.startsWith("/") ? metadata.filePath : "/" + metadata.filePath)
379
393
  : (opts.remotePath ?? "/" + opts.fileName);
380
394
  const result = {
395
+ // 优先取服务端 ObjectVO.name(来自 PUT 时带的 Content-Disposition),
396
+ // 与后续 file ls / file stat 返回的展示名保持一致;缺失时降级用本地 fileName。
381
397
  file_name: metadata.name ?? opts.fileName,
382
398
  path,
383
399
  size: metadata.metadata?.contentLength ?? opts.fileSize,
@@ -388,6 +404,17 @@ async function uploadFile(opts) {
388
404
  }
389
405
  return result;
390
406
  }
407
+ /**
408
+ * 把文件名清理成可安全放进 Content-Disposition `filename="..."` 的形态。
409
+ * 与 fullstack-plugin 的 sanitizeFileName 行为一致:
410
+ * 1. 去掉对 TOS / 文件系统不友好的字符 [: " \ / * ? < > | , ;]
411
+ * 2. encodeURIComponent 把非 ASCII(中文等)做百分号编码,保证 header 合法
412
+ * 3. 处理后为空时退回 "download_file" 兜底
413
+ */
414
+ function sanitizeFileName(fileName) {
415
+ const illegalChars = /[:"\\/*?<>|,;]/g;
416
+ return encodeURIComponent(fileName.replace(illegalChars, "")) || "download_file";
417
+ }
391
418
  // ── 预签下载 URL ──
392
419
  /**
393
420
  * 获取预签下载 URL。
@@ -40,6 +40,7 @@ const error_1 = require("../../../utils/error");
40
40
  const output_1 = require("../../../utils/output");
41
41
  const render_1 = require("../../../utils/render");
42
42
  const shared_1 = require("../../../cli/commands/shared");
43
+ const colors_1 = require("../../../utils/colors");
43
44
  const index_1 = require("../../../api/db/index");
44
45
  // ── schema list ──
45
46
  async function handleDbSchemaList(opts) {
@@ -127,7 +128,8 @@ function renderDetail(d, tty) {
127
128
  // 构造的伪时间戳(baseTime=2020-01-01 + OID 秒偏移),仅保排序意义、绝对值
128
129
  // 误导性强,先去掉。后续如果接 ddl_change_log 取真实时间再加回。
129
130
  const header = [
130
- ["Name", d.name],
131
+ // 表名做 cyan 强调(spec:spec 里的"命令名/表名"类强调值都走 highlight)
132
+ ["Name", tty ? colors_1.c.highlight(d.name) : d.name],
131
133
  ["Description", d.description ?? "—"],
132
134
  [
133
135
  "Columns",
@@ -150,12 +152,21 @@ function renderDetail(d, tty) {
150
152
  parts.push((0, render_1.renderKeyValue)(header, tty));
151
153
  parts.push("");
152
154
  parts.push(tty ? (0, render_1.renderAlignedTable)(colHeaders, colRows) : (0, render_1.renderTsv)(colHeaders, colRows));
155
+ // Constraints 段(PRIMARY KEY / UNIQUE):表级约束独立成段,与普通索引分离展示
156
+ if (d.constraints.length > 0) {
157
+ parts.push("");
158
+ parts.push(tty ? " Constraints:" : "Constraints:");
159
+ for (const c of d.constraints) {
160
+ const line = `${c.type} (${c.columns.join(", ")})`;
161
+ parts.push(tty ? ` ${line}` : line);
162
+ }
163
+ }
164
+ // Indexes 段:普通索引,格式 "<name> ON <col1, col2> USING <method>"
153
165
  if (d.indexes.length > 0) {
154
166
  parts.push("");
155
167
  parts.push(tty ? " Indexes:" : "Indexes:");
156
- // PRD 格式: "TYPE (col1, col2)",不展示索引名
157
168
  for (const idx of d.indexes) {
158
- const line = `${idx.type} (${idx.columns.join(", ")})`;
169
+ const line = `${idx.name} ON ${idx.columns.join(", ")} USING ${idx.method}`;
159
170
  parts.push(tty ? ` ${line}` : line);
160
171
  }
161
172
  }
@@ -44,7 +44,10 @@ const config_1 = require("../../../utils/config");
44
44
  const logger_1 = require("../../../utils/logger");
45
45
  const shared_1 = require("../../../cli/commands/shared");
46
46
  const render_1 = require("../../../utils/render");
47
+ const colors_1 = require("../../../utils/colors");
48
+ const fuzzy_match_1 = require("../../../utils/fuzzy-match");
47
49
  const index_1 = require("../../../api/db/index");
50
+ const sql_keywords_1 = require("../../../api/db/sql-keywords");
48
51
  const node_child_process_1 = require("node:child_process");
49
52
  const node_fs_1 = require("node:fs");
50
53
  const node_path_1 = __importDefault(require("node:path"));
@@ -64,7 +67,20 @@ async function handleDbSql(query, opts) {
64
67
  if (!sql.trim()) {
65
68
  throw new error_1.AppError("ARGS_INVALID", "Empty SQL (no inline query and stdin is empty)");
66
69
  }
67
- const results = await api.db.execSql({ appId, sql, dbBranch: opts.env });
70
+ let results;
71
+ try {
72
+ results = await api.db.execSql({ appId, sql, dbBranch: opts.env });
73
+ }
74
+ catch (err) {
75
+ // 错误抛出前富化 next_actions:识别"语法错误"/"表不存在"/"列不存在"
76
+ // 三类常见 PG 错误,做 fuzzy match 给 did-you-mean 提示。
77
+ // 富化失败任何环节都静默吞掉,不破坏原始错误信息。
78
+ await enrichSqlError(err, { appId, env: opts.env, sql });
79
+ // PRD 多语句失败:把服务端给的 partial_results 转成 `completed`,推断 `rolled_back`,
80
+ // 并把 pretty 模式的 `Statement N: ✓ ... / Statement K: ✗ ...` 立即打到 stderr。
81
+ enrichMultiStatementError(err, sql);
82
+ throw err;
83
+ }
68
84
  if (results.length === 0) {
69
85
  // 后端未返回任何结果,通常不会发生
70
86
  if ((0, output_1.isJsonMode)()) {
@@ -225,19 +241,19 @@ function renderSingle(raw) {
225
241
  return;
226
242
  }
227
243
  const cols = collectColumns(parsed.rows);
228
- const rows = parsed.rows.map((r) => cols.map((c) => formatCell(r[c], tty)));
244
+ const rows = parsed.rows.map((r) => cols.map((col) => formatCell(r[col], tty)));
229
245
  (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(cols, rows) : (0, render_1.renderTsv)(cols, rows));
230
246
  return;
231
247
  }
232
248
  if (parsed.kind === "dml") {
233
249
  const verb = dmlVerb(parsed.sqlType);
234
250
  (0, output_1.emit)(tty
235
- ? `✓ ${String(parsed.affectedRows)} row${parsed.affectedRows === 1 ? "" : "s"} ${verb}`
251
+ ? colors_1.c.success(`✓ ${String(parsed.affectedRows)} row${parsed.affectedRows === 1 ? "" : "s"} ${verb}`)
236
252
  : `OK ${String(parsed.affectedRows)} rows ${verb}`);
237
253
  return;
238
254
  }
239
255
  // DDL
240
- (0, output_1.emit)(tty ? "✓ DDL executed" : "OK DDL executed");
256
+ (0, output_1.emit)(tty ? colors_1.c.success("✓ DDL executed") : "OK DDL executed");
241
257
  }
242
258
  function toJson(parsed) {
243
259
  if (parsed.kind === "select") {
@@ -307,7 +323,7 @@ function renderMultiPretty(results) {
307
323
  lines.push(`Statement ${String(idx)}: SELECT (${String(n)} row${n === 1 ? "" : "s"})`);
308
324
  if (n > 0) {
309
325
  const cols = collectColumns(parsed.rows);
310
- const tbl = parsed.rows.map((r) => cols.map((c) => formatCell(r[c], tty)));
326
+ const tbl = parsed.rows.map((r) => cols.map((col) => formatCell(r[col], tty)));
311
327
  lines.push(tty ? (0, render_1.renderAlignedTable)(cols, tbl) : (0, render_1.renderTsv)(cols, tbl));
312
328
  }
313
329
  // 块间空行(最后一条不留)
@@ -318,15 +334,16 @@ function renderMultiPretty(results) {
318
334
  if (parsed.kind === "dml") {
319
335
  const verb = dmlVerb(parsed.sqlType);
320
336
  const n = parsed.affectedRows;
321
- lines.push(`Statement ${String(idx)}: ${tty ? "✓ " : ""}${String(n)} row${n === 1 ? "" : "s"} ${verb}`);
337
+ const body = `${String(n)} row${n === 1 ? "" : "s"} ${verb}`;
338
+ lines.push(`Statement ${String(idx)}: ${tty ? colors_1.c.success("✓ " + body) : body}`);
322
339
  continue;
323
340
  }
324
341
  // DDL
325
- lines.push(`Statement ${String(idx)}: ${tty ? "✓ " : ""}DDL executed`);
342
+ lines.push(`Statement ${String(idx)}: ${tty ? colors_1.c.success("✓ DDL executed") : "DDL executed"}`);
326
343
  }
327
344
  // 汇总行:所有 statement 都跑完了
328
345
  lines.push(tty
329
- ? `✓ ${String(results.length)} statements executed`
346
+ ? colors_1.c.success(`✓ ${String(results.length)} statements executed`)
330
347
  : `OK ${String(results.length)} statements executed`);
331
348
  (0, output_1.emit)(lines.join("\n"));
332
349
  }
@@ -356,12 +373,275 @@ function collectColumns(rows) {
356
373
  */
357
374
  function formatCell(v, tty) {
358
375
  if (v === null || v === undefined) {
359
- return tty ? "\u001b[2;90mNULL\u001b[0m" : "NULL";
376
+ return tty ? colors_1.c.muted("NULL") : "NULL";
360
377
  }
361
- if (typeof v === "string")
378
+ if (typeof v === "string") {
379
+ // TTY 下检测到 ISO 8601 时间字符串 → 转成相对时间("3h ago" / "2d ago" /
380
+ // "2026-03-15"),方便 _created_at / _updated_at 等列直观可读
381
+ if (tty && ISO_TIMESTAMP_RE.test(v) && !Number.isNaN(Date.parse(v))) {
382
+ return (0, render_1.formatTime)(v, tty);
383
+ }
362
384
  return v;
385
+ }
363
386
  if (typeof v === "number" || typeof v === "boolean")
364
387
  return String(v);
365
388
  // object / array → JSON
366
389
  return JSON.stringify(v);
367
390
  }
391
+ /**
392
+ * 匹配 PG / ISO 8601 形态的日期时间字符串:
393
+ * 2026-04-29
394
+ * 2026-04-29 19:07:43 (PG 默认 timestamp 形态)
395
+ * 2026-04-29T19:07:43
396
+ * 2026-04-29T19:07:43.882+08:00
397
+ * 2026-04-29T19:07:43.882Z
398
+ * 时间部分 / 毫秒 / 时区可选。
399
+ */
400
+ const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
401
+ // ──────────────────────────────────────────────────────────────────────
402
+ // db sql 错误富化:识别 PG 常见报错形态 → fuzzy match → 加 did-you-mean hint
403
+ // ──────────────────────────────────────────────────────────────────────
404
+ const SYNTAX_RE = /syntax error at or near "([^"]+)"/i;
405
+ const RELATION_RE = /relation "([^"]+)" does not exist/i;
406
+ const COLUMN_RE = /column "([^"]+)"(?: of relation "([^"]+)")? does not exist/i;
407
+ /**
408
+ * 在错误抛出前富化 next_actions(最多 1 条 hint),按 PG 报错形态分发:
409
+ *
410
+ * 表 / 列不存在路径:
411
+ * - `column "X" of relation "Y" does not exist` → 在 Y 表的列里 fuzzy
412
+ * - `column "X" does not exist`(未指定表) → 跨表所有列合集 fuzzy
413
+ * - `relation "X" does not exist` → 在所有表名里 fuzzy
414
+ *
415
+ * 语法错误路径(`syntax error at or near "X"`)三条互斥子路径:
416
+ * a. X 字面就是保留字(用户拿 keyword 当标识符,如 `FROM order`,
417
+ * 但 `order` 是 PG 保留字)→ 当作"想引用的表名",远程 schema fuzzy。
418
+ * **不要建议 keyword 自己**,那只是把用户输入大写返还,毫无价值。
419
+ * b. X 接近某 keyword(典型拼写错误,如 SELCT)→ suggest keyword
420
+ * c. X 是数字 / 标点(如 `LIMITT 1` 报错点到 `1`)→ fallback 扫整段
421
+ * SQL 找看似拼错的 keyword token
422
+ *
423
+ * 任一环节失败(pattern 不匹配 / 远程拉表失败 / 没 fuzzy 候选)都静默吞掉,
424
+ * 不破坏原始错误信息——原则:"只在我们高置信能帮上忙时才插嘴"。
425
+ */
426
+ async function enrichSqlError(err, ctx) {
427
+ if (!(err instanceof error_1.AppError))
428
+ return;
429
+ const msg = err.message;
430
+ // ── 表 / 列不存在路径 ──
431
+ // 注意:列报错 'column "X" of relation "Y"' 也含 'relation "Y"' 子串,
432
+ // 会同时命中 RELATION_RE。先 colMatch(更精确),命中后不再走 relation 分支。
433
+ const colMatch = COLUMN_RE.exec(msg);
434
+ const relMatch = colMatch ? null : RELATION_RE.exec(msg);
435
+ if (relMatch || colMatch) {
436
+ const tableMap = await loadTableMap(ctx);
437
+ if (!tableMap)
438
+ return;
439
+ if (relMatch) {
440
+ enrichRelationNotExist(err, relMatch[1], tableMap);
441
+ }
442
+ else if (colMatch) {
443
+ enrichColumnNotExist(err, colMatch, tableMap);
444
+ }
445
+ return;
446
+ }
447
+ // ── 语法错误路径 ──
448
+ const kwMatch = SYNTAX_RE.exec(msg);
449
+ if (!kwMatch)
450
+ return;
451
+ const token = kwMatch[1];
452
+ const upper = token.toUpperCase();
453
+ // 子路径 a:token 字面就是 keyword → 用户拿保留字当标识符
454
+ if (sql_keywords_1.SQL_KEYWORDS.includes(upper)) {
455
+ const tableMap = await loadTableMap(ctx);
456
+ if (!tableMap)
457
+ return;
458
+ const guess = (0, fuzzy_match_1.suggest)(token, Object.keys(tableMap));
459
+ if (guess) {
460
+ err.next_actions.push(`Did you mean table '${guess}'? '${token}' is a reserved keyword; ` +
461
+ `quote it as "${token}" if you really mean a table named '${token}'.`);
462
+ }
463
+ return;
464
+ }
465
+ // 子路径 b:token 接近 keyword(拼写错误)
466
+ const guess = (0, fuzzy_match_1.suggest)(upper, sql_keywords_1.SQL_KEYWORDS);
467
+ if (guess && guess !== upper) {
468
+ err.next_actions.push(`Check SQL keyword spelling. Did you mean "${guess}"?`);
469
+ return;
470
+ }
471
+ // 子路径 c:PG 错误位置点到了下一个 token(如 `LIMITT 1` → 报错指向 "1"),
472
+ // 扫整段 SQL 找看似拼错的 keyword
473
+ const typo = findKeywordTypo(ctx.sql);
474
+ if (typo) {
475
+ err.next_actions.push(`Check SQL keyword spelling. '${typo.input}' looks like '${typo.suggest}'.`);
476
+ }
477
+ }
478
+ /** 远程拉一次 schema 并扁平化为 `tableName → fieldName[]`。失败返 null(调用方静默不加 hint)。 */
479
+ async function loadTableMap(ctx) {
480
+ try {
481
+ const resp = await api.db.getSchema({
482
+ appId: ctx.appId,
483
+ format: "schema",
484
+ dbBranch: ctx.env,
485
+ });
486
+ return extractTableFieldMap(resp.schema);
487
+ }
488
+ catch {
489
+ return null;
490
+ }
491
+ }
492
+ function enrichRelationNotExist(err, relation, tableMap) {
493
+ const guess = (0, fuzzy_match_1.suggest)(relation, Object.keys(tableMap));
494
+ if (guess) {
495
+ err.next_actions.push(`Did you mean '${guess}'? Run \`miaoda db schema list\` to see all tables.`);
496
+ }
497
+ }
498
+ function enrichColumnNotExist(err, colMatch, tableMap) {
499
+ const colName = colMatch[1];
500
+ // TS RegExpMatchArray 数字索引类型 = string,但 optional capture group `(...)?`
501
+ // 没匹配时运行时返 undefined;as cast 把这个事实告诉类型系统。
502
+ const relation = colMatch[2];
503
+ const relationCols = relation !== undefined
504
+ ? tableMap[relation]
505
+ : undefined;
506
+ if (relation !== undefined && relationCols !== undefined) {
507
+ // 指定了表 → 在该表的列里找
508
+ const guess = (0, fuzzy_match_1.suggest)(colName, relationCols);
509
+ if (guess) {
510
+ err.next_actions.push(`Did you mean column '${guess}' in table '${relation}'?`);
511
+ }
512
+ }
513
+ else {
514
+ // 没指定表(CTE / 别名 / 子查询场景)→ 在所有表的列合集里找
515
+ const allCols = Array.from(new Set(Object.values(tableMap).flat()));
516
+ const guess = (0, fuzzy_match_1.suggest)(colName, allCols);
517
+ if (guess) {
518
+ err.next_actions.push(`Did you mean '${guess}'? Run \`miaoda db schema get <table>\` to see columns.`);
519
+ }
520
+ }
521
+ }
522
+ /**
523
+ * 扫整段 SQL 找一个看似"拼错的关键字"token:纯字母 + 长度 ≥ 3 + 不是已知 keyword
524
+ * + fuzzy 命中某个 keyword(且距离 > 0)。返第一个命中的 token。
525
+ *
526
+ * 用于 `syntax error at or near "<X>"` 的 X 是数字 / 标点的兜底场景——例如
527
+ * `... LIMITT 1` 报错点到 `1`,但用户真正的拼写错误在 LIMITT。
528
+ *
529
+ * 阈值复用 suggest(),候选词越短匹配越严,避免把 `userid` / `usrname` 这种
530
+ * 用户标识符误抓为关键字 typo。
531
+ */
532
+ function findKeywordTypo(sql) {
533
+ const tokens = sql.split(/[\s,;()'"`*=<>+\-./]+/).filter((t) => /^[A-Za-z_]{3,}$/.test(t));
534
+ for (const tok of tokens) {
535
+ const upper = tok.toUpperCase();
536
+ if (sql_keywords_1.SQL_KEYWORDS.includes(upper))
537
+ continue; // 写对的关键字
538
+ const guess = (0, fuzzy_match_1.suggest)(upper, sql_keywords_1.SQL_KEYWORDS);
539
+ if (guess && guess !== upper) {
540
+ return { input: tok, suggest: guess };
541
+ }
542
+ }
543
+ return null;
544
+ }
545
+ /** 从 InnerSchemaRespVO 提取 `tableName → fieldName[]` 映射,扁平化 tables/views/mvs 三池。 */
546
+ function extractTableFieldMap(s) {
547
+ const out = {};
548
+ if (!s)
549
+ return out;
550
+ const pools = [s.tables?.data, s.views?.data, s.materializedViews?.data];
551
+ for (const pool of pools) {
552
+ if (!pool)
553
+ continue;
554
+ for (const t of pool) {
555
+ out[t.tableName] = (t.fields ?? []).map((f) => f.fieldName);
556
+ }
557
+ }
558
+ return out;
559
+ }
560
+ /**
561
+ * 多语句 SQL 失败时把服务端透传的 partial_results 包装成 PRD 期望形态:
562
+ * - 用现有 toMultiElement 把每条 SQLExecuteResult 转成 `{command, ...}` 元素,
563
+ * 与正常 multi-statement 成功路径的 data[] 结构完全一致
564
+ * - 推断 rolled_back:扫 completed 数组里 BEGIN/COMMIT/ROLLBACK 计数;失败时
565
+ * 还在 user tx 内 ⇒ 服务端 closeUserTxIfOpen 已发 ROLLBACK,标 true
566
+ * - rolled_back=true 时给 next_actions 追加一句 spec 文案
567
+ * - pretty 模式(非 JSON)把 `Statement N: ✓ ... / K: ✗ ...` 立即打到 stderr,
568
+ * 与 emitError 的 Error/hint 行连成一段 PRD 期望的 multi-statement 报告
569
+ */
570
+ function enrichMultiStatementError(err, sql) {
571
+ if (!(err instanceof error_1.AppError))
572
+ return;
573
+ const partial = err.partial_results;
574
+ if (!Array.isArray(partial) || partial.length === 0)
575
+ return;
576
+ // 把 SQLExecuteResult[] 转成 PRD 兼容的 completed 数组
577
+ const completed = partial.map((r) => toMultiElement((0, index_1.parseSqlResult)(r)));
578
+ err.completed = completed;
579
+ err.rolled_back = inferRolledBack(completed);
580
+ err.total_statements = countStatements(sql);
581
+ // pretty 模式(非 JSON)打 per-statement breakdown 到 stderr
582
+ if (!(0, output_1.isJsonMode)()) {
583
+ writeMultiStatementBreakdown(err, completed);
584
+ }
585
+ // rolled_back=true 时追加 spec hint:"Transaction rolled back; no changes persisted."
586
+ if (err.rolled_back) {
587
+ err.next_actions.push("Transaction rolled back; no changes persisted.");
588
+ }
589
+ }
590
+ /**
591
+ * 推断本次多语句执行最终是否处于"事务被回滚"状态:
592
+ * 遍历 completed[] 数组,BEGIN +1 / COMMIT|ROLLBACK -1;
593
+ * 失败时 depth > 0 → 用户事务还开着 → 服务端 closeUserTxIfOpen 发了 ROLLBACK
594
+ * 失败时 depth = 0 → 失败语句在 autocommit 模式 → 已成功的 statement 真实落库
595
+ */
596
+ function inferRolledBack(completed) {
597
+ let depth = 0;
598
+ for (const e of completed) {
599
+ if (e.command === "BEGIN")
600
+ depth++;
601
+ else if (e.command === "COMMIT" || e.command === "ROLLBACK")
602
+ depth--;
603
+ }
604
+ return depth > 0;
605
+ }
606
+ /**
607
+ * 数 SQL 里有几条独立语句:先去掉单 / 双引号字面量(防止 'a;b' 里的分号被算成
608
+ * 分隔符),再按分号 split,过滤空条。CLI 用户场景几乎都是简单 SQL,不处理
609
+ * dollar-quoted($$..$$)等高级形态——估错 ±1 不影响 hint 可读性。
610
+ */
611
+ function countStatements(sql) {
612
+ const stripped = sql
613
+ .replace(/'(?:''|[^'])*'/g, "''")
614
+ .replace(/"(?:""|[^"])*"/g, '""');
615
+ return stripped.split(/;+/).filter((s) => s.trim().length > 0).length;
616
+ }
617
+ /**
618
+ * 写 `Statement N: ✓ ... / Statement K: ✗ ...` 到 stderr,对齐 PRD spec 的
619
+ * 多语句失败 pretty 输出形态。
620
+ */
621
+ function writeMultiStatementBreakdown(err, completed) {
622
+ const tty = (0, render_1.isStdoutTty)();
623
+ const lines = [];
624
+ for (let i = 0; i < completed.length; i++) {
625
+ const e = completed[i];
626
+ const verb = e.command === "BEGIN" || e.command === "COMMIT" || e.command === "ROLLBACK"
627
+ ? e.command
628
+ : describeCompleted(e);
629
+ lines.push(`Statement ${String(i + 1)}: ${tty ? colors_1.c.success("✓ ") : "✓ "}${verb}`);
630
+ }
631
+ // 失败那条
632
+ const failedIdx = err.statement_index ?? completed.length;
633
+ lines.push(`Statement ${String(failedIdx + 1)}: ${tty ? colors_1.c.fail("✗ ") : "✗ "}${err.message}`);
634
+ process.stderr.write(lines.join("\n") + "\n\n");
635
+ }
636
+ /** completed 单条结果的人类可读描述(用于 stderr breakdown 行尾)。 */
637
+ function describeCompleted(e) {
638
+ const r = e;
639
+ if (r.command === "SELECT" && Array.isArray(r.rows)) {
640
+ return `SELECT (${String(r.rows.length)} row${r.rows.length === 1 ? "" : "s"})`;
641
+ }
642
+ if (typeof r.rows_affected === "number") {
643
+ const verb = dmlVerb(r.command);
644
+ return `${String(r.rows_affected)} row${r.rows_affected === 1 ? "" : "s"} ${verb}`;
645
+ }
646
+ return `${r.command} executed`;
647
+ }
@@ -45,6 +45,7 @@ const output_1 = require("../../../utils/output");
45
45
  const render_1 = require("../../../utils/render");
46
46
  const error_1 = require("../../../utils/error");
47
47
  const shared_1 = require("../../../cli/commands/shared");
48
+ const colors_1 = require("../../../utils/colors");
48
49
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
49
50
  /**
50
51
  * 判断 src 是本地文件还是远程引用:
@@ -147,7 +148,7 @@ async function handleUpload(appId, localRaw, remoteRaw, rename) {
147
148
  const tty = (0, render_1.isStdoutTty)();
148
149
  const lines = [];
149
150
  if (tty) {
150
- lines.push(`✓ Uploaded ${node_path_1.default.basename(localPath)} → ${result.path}`);
151
+ lines.push(colors_1.c.success(`✓ Uploaded ${node_path_1.default.basename(localPath)} → ${result.path}`));
151
152
  lines.push(` file_name: ${result.file_name}`);
152
153
  lines.push(` path: ${result.path}`);
153
154
  lines.push(` size: ${(0, render_1.formatSize)(result.size)} (${String(result.size)} bytes)`);
@@ -204,7 +205,7 @@ async function handleDownload(appId, remoteRaw, localRaw) {
204
205
  }
205
206
  const tty = (0, render_1.isStdoutTty)();
206
207
  if (tty) {
207
- (0, output_1.emit)(`✓ Downloaded ${baseName} → ${localTarget} (${(0, render_1.formatSize)(writtenBytes)})`);
208
+ (0, output_1.emit)(colors_1.c.success(`✓ Downloaded ${baseName} → ${localTarget} (${(0, render_1.formatSize)(writtenBytes)})`));
208
209
  }
209
210
  else {
210
211
  (0, output_1.emit)(`OK Downloaded ${baseName} -> ${localTarget} (${String(writtenBytes)} bytes)`);
@@ -43,6 +43,7 @@ const error_1 = require("../../../utils/error");
43
43
  const shared_1 = require("../../../cli/commands/shared");
44
44
  const index_1 = require("../../../api/file/index");
45
45
  const render_1 = require("../../../utils/render");
46
+ const colors_1 = require("../../../utils/colors");
46
47
  const node_readline_1 = __importDefault(require("node:readline"));
47
48
  const MAX_BATCH = 100;
48
49
  /**
@@ -228,9 +229,9 @@ async function handleFileRm(paths, opts) {
228
229
  const lines = [];
229
230
  for (const r of results) {
230
231
  if (r.status === "ok")
231
- lines.push(`✓ Deleted ${r.input}`);
232
+ lines.push(colors_1.c.success(`✓ Deleted ${r.input}`));
232
233
  else
233
- lines.push(`✗ ${r.input}: ${r.error.message}`);
234
+ lines.push(colors_1.c.fail(`✗ ${r.input}: ${r.error.message}`));
234
235
  }
235
236
  if (failCount === 0) {
236
237
  lines.push(`Deleted ${String(okCount)} of ${String(totalCount)} files`);
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * 终端彩色高亮的语义层封装。
4
+ *
5
+ * 三层结构(自上而下:稳定→灵活):
6
+ *
7
+ * 1. **ColorAdapter**(公共契约):6 个语义方法 string → string,业务代码
8
+ * 只接触这层,对底层库 0 感知。
9
+ * 2. **ColorFactory**(适配器签名):`(useColor: boolean) => ColorAdapter`,
10
+ * 把"是否染色"参数化,便于按需构造。一个三方库对应一个 factory 实现。
11
+ * 3. **当前底层库实现**:picocolors([picocolors npm](https://www.npmjs.com/package/picocolors),~3KB)。
12
+ * 切换到 chalk / kleur 等只需新增一个 ColorFactory 实现,并改最下方的
13
+ * `factory =` 一行赋值,业务代码与 ColorAdapter contract 0 改动。
14
+ *
15
+ * # 色谱(对齐 spec)
16
+ *
17
+ * - header 表头 / 命令名 / key 标签:bold + cyan
18
+ * - highlight 强调值(表名等):cyan
19
+ * - success ✓ Uploaded / ✓ Deleted:green
20
+ * - fail ✗ / Error::red
21
+ * - warn ⚠ Approaching quota:yellow
22
+ * - muted NULL / 辅助 hint / 次要信息:dim + gray
23
+ *
24
+ * # 染色判定(NO_COLOR / FORCE_COLOR / TTY,[no-color.org](https://no-color.org/) 业界标准)
25
+ *
26
+ * - `NO_COLOR=1` 强制关颜色(任何非空值生效)
27
+ * - `FORCE_COLOR=1` 强制开颜色(管道 / CI 场景)
28
+ * - 否则按 `process.stdout.isTTY` 判定
29
+ *
30
+ * 每次调用 `c.xxx(s)` 时按当前状态实时构造 adapter——避免模块加载时缓存的
31
+ * `process.stdout.isTTY` 与测试 / pipe 切换脱节。
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.c = void 0;
35
+ exports.shouldColorize = shouldColorize;
36
+ const picocolors_1 = require("picocolors");
37
+ // ──────────────────────────────────────────────────────────────────────
38
+ // 2. 库无关的染色判定:哪个 factory 都能复用
39
+ // ──────────────────────────────────────────────────────────────────────
40
+ /**
41
+ * 当前是否应当染色。读取的是**调用时**的状态,而非模块加载时缓存——
42
+ * 测试经常 `Object.defineProperty(process.stdout, "isTTY", ...)` 实时切换,
43
+ * 这里直接每次问 process。
44
+ *
45
+ * 优先级:FORCE_COLOR > NO_COLOR > TTY。
46
+ */
47
+ function shouldColorize() {
48
+ const env = process.env;
49
+ const noColor = env.NO_COLOR != null && env.NO_COLOR !== "";
50
+ const forceColor = env.FORCE_COLOR != null && env.FORCE_COLOR !== "";
51
+ const isTTY = process.stdout.isTTY === true;
52
+ return forceColor || (!noColor && isTTY);
53
+ }
54
+ /** picocolors 实现:当前生产用 factory。~3KB,业界 CLI 标准之一。 */
55
+ const picocolorsFactory = (useColor) => {
56
+ const x = (0, picocolors_1.createColors)(useColor);
57
+ return {
58
+ header: (s) => x.bold(x.cyan(s)),
59
+ highlight: (s) => x.cyan(s),
60
+ success: (s) => x.green(s),
61
+ fail: (s) => x.red(s),
62
+ warn: (s) => x.yellow(s),
63
+ muted: (s) => x.dim(x.gray(s)),
64
+ };
65
+ };
66
+ // ──────────────────────────────────────────────────────────────────────
67
+ // 4. 唯一切换点:换库改这一行(其它都不用动)
68
+ // ──────────────────────────────────────────────────────────────────────
69
+ //
70
+ // 切换示例:
71
+ //
72
+ // import chalk, { Chalk } from "chalk";
73
+ // const chalkFactory: ColorFactory = (useColor) => {
74
+ // const k = new Chalk({ level: useColor ? 1 : 0 });
75
+ // return {
76
+ // header: (s) => k.bold.cyan(s),
77
+ // highlight: (s) => k.cyan(s),
78
+ // success: (s) => k.green(s),
79
+ // fail: (s) => k.red(s),
80
+ // warn: (s) => k.yellow(s),
81
+ // muted: (s) => k.dim.gray(s),
82
+ // };
83
+ // };
84
+ // const factory: ColorFactory = chalkFactory; // ← 改这里
85
+ //
86
+ const factory = picocolorsFactory;
87
+ // ──────────────────────────────────────────────────────────────────────
88
+ // 5. 公共出口:业务代码 import { c } from "../utils/colors"
89
+ // ──────────────────────────────────────────────────────────────────────
90
+ /** 语义染色器:业务代码唯一接触的对象。底层库切换 0 感知。 */
91
+ exports.c = {
92
+ header: (s) => factory(shouldColorize()).header(s),
93
+ highlight: (s) => factory(shouldColorize()).highlight(s),
94
+ success: (s) => factory(shouldColorize()).success(s),
95
+ fail: (s) => factory(shouldColorize()).fail(s),
96
+ warn: (s) => factory(shouldColorize()).warn(s),
97
+ muted: (s) => factory(shouldColorize()).muted(s),
98
+ };
@@ -6,6 +6,14 @@ class AppError extends Error {
6
6
  retryable;
7
7
  next_actions;
8
8
  statement_index;
9
+ total_statements;
10
+ /**
11
+ * 多语句失败时由 api.db.execSql 透传服务端 results(已成功的 statement 原始结构)。
12
+ * 由 db sql handler 转成 PRD 友好的 `completed` 数组 + 推断 `rolled_back` 后挂回到本对象。
13
+ */
14
+ partial_results;
15
+ completed;
16
+ rolled_back;
9
17
  constructor(code, message, opts) {
10
18
  super(message);
11
19
  this.name = "AppError";
@@ -21,6 +29,9 @@ class AppError extends Error {
21
29
  retryable: this.retryable,
22
30
  next_actions: this.next_actions,
23
31
  statement_index: this.statement_index,
32
+ total_statements: this.total_statements,
33
+ completed: this.completed,
34
+ rolled_back: this.rolled_back,
24
35
  };
25
36
  }
26
37
  }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * 模糊匹配:基于 Levenshtein 编辑距离的"did-you-mean"建议器。
4
+ *
5
+ * 用于 CLI 错误路径的拼写纠错提示——当用户输入的 token(关键字 / 表名 / 列名)
6
+ * 与某个有效候选词"接近"时,给出 "Did you mean X?" 类的 hint。
7
+ *
8
+ * # 设计取舍
9
+ *
10
+ * - **算法**:Damerau-Levenshtein DP(在标准 Levenshtein 基础上把"相邻字符
11
+ * 交换"算 1 步编辑——SQL 用户最常见的拼写错误就是 FORM/FROM、SELCT/SELECT
12
+ * 这类 transposition;纯 Levenshtein 把它们算 2 步会过严,触发不到 hint)。
13
+ * 约 30 行二维 DP 实现,不引第三方库;业界 CLI 拼写纠错事实标准。
14
+ * - **大小写不敏感**:CLI 用户混用 SELECT / select 是常态,统一 lowercase 比较
15
+ * - **阈值按候选词长度自适应**:避免短词过度匹配("id" 不应该建议成 "in")
16
+ * len ≤ 4:max 1 编辑(短词严)
17
+ * len 5-8:max 2 编辑
18
+ * len ≥ 9:max 3 编辑(长词宽)
19
+ * - **同分多候选**:返第一个;调用方负责候选词列表的稳定排序
20
+ * - **找不到不强凑**:阈值外返 null,不"硬贴"无关建议
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.levenshtein = levenshtein;
24
+ exports.suggest = suggest;
25
+ /**
26
+ * 计算两个字符串的 Damerau-Levenshtein 编辑距离(不区分大小写)。
27
+ *
28
+ * 在标准 Levenshtein(增 / 删 / 替换)基础上加"相邻字符交换"作为单步编辑。
29
+ * 二维 DP 实现(transposition 需要回看 dp[i-2][j-2],所以不再用单行优化)。
30
+ * 候选词通常 < 30 字符,二维 DP 的常数空间开销可忽略。
31
+ */
32
+ function levenshtein(a, b) {
33
+ const aa = a.toLowerCase();
34
+ const bb = b.toLowerCase();
35
+ const m = aa.length;
36
+ const n = bb.length;
37
+ if (m === 0)
38
+ return n;
39
+ if (n === 0)
40
+ return m;
41
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
42
+ for (let i = 0; i <= m; i++)
43
+ dp[i][0] = i;
44
+ for (let j = 0; j <= n; j++)
45
+ dp[0][j] = j;
46
+ for (let i = 1; i <= m; i++) {
47
+ for (let j = 1; j <= n; j++) {
48
+ const cost = aa[i - 1] === bb[j - 1] ? 0 : 1;
49
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, // 删除
50
+ dp[i][j - 1] + 1, // 插入
51
+ dp[i - 1][j - 1] + cost);
52
+ // transposition: 当前字符 = 对方前一字符 && 我方前一字符 = 对方当前字符
53
+ // 例:aa="form", bb="from",i=2,j=2 时 aa[1]=o=bb[0]=f? 不命中;i=3,j=3
54
+ // 时 aa[2]=r=bb[1]=r? bb[2]=o, aa[1]=o → 命中 → dp[3][3] = dp[1][1] + 1 = 1
55
+ if (i > 1 && j > 1 && aa[i - 1] === bb[j - 2] && aa[i - 2] === bb[j - 1]) {
56
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + 1);
57
+ }
58
+ }
59
+ }
60
+ return dp[m][n];
61
+ }
62
+ /** 默认阈值:候选词越短匹配越严,越长越宽。避免 "id" 错配 "in" 这种 false positive。 */
63
+ function defaultThreshold(len) {
64
+ if (len <= 4)
65
+ return 1;
66
+ if (len <= 8)
67
+ return 2;
68
+ return 3;
69
+ }
70
+ /**
71
+ * 在候选词列表里找与 input 最相近的一个。
72
+ *
73
+ * 距离 ≤ 阈值时按"距离最小"返回;多个候选词相同距离时返"列表中最先出现"的;
74
+ * 阈值外没有命中返 null(调用方按 null 静默不加 hint,不要强凑)。
75
+ */
76
+ function suggest(input, candidates, opts) {
77
+ if (!input || candidates.length === 0)
78
+ return null;
79
+ const threshold = opts?.maxDistance ?? defaultThreshold;
80
+ let best = null;
81
+ let bestDist = Number.POSITIVE_INFINITY;
82
+ for (const cand of candidates) {
83
+ const d = levenshtein(input, cand);
84
+ const limit = threshold(cand.length);
85
+ if (d <= limit && d < bestDist) {
86
+ best = cand;
87
+ bestDist = d;
88
+ }
89
+ }
90
+ return best;
91
+ }
@@ -7,6 +7,27 @@ exports.emitOk = emitOk;
7
7
  exports.emitPaged = emitPaged;
8
8
  const config_1 = require("./config");
9
9
  const error_1 = require("./error");
10
+ const colors_1 = require("./colors");
11
+ /**
12
+ * 服务端错误码 → CLI 兜底 hint 文案。
13
+ *
14
+ * 服务端错误协议只有 `{code, msg}` 两个字段(dataloom InnerExecuteSQL 等 IDL
15
+ * 没有 hint / next_actions 通道),所以服务端给的错误本身永远没有 hint。
16
+ * 这里是 CLI 展示层为常见错误码补一份 spec 一致的 actionable 引导,按错误码
17
+ * 落到 next_actions。
18
+ *
19
+ * 仅在 next_actions 已经为空时介入——保留 handler / enrichSqlError 自己塞过的
20
+ * 具体 hint(如 did-you-mean、shared.resolveAppId 等),它们优先级更高。
21
+ */
22
+ const SERVER_ERROR_HINTS = {
23
+ // SELECT 结果集超过 1000 行硬拦:spec 多行引导。
24
+ // key 用 BIZ_ERR_MAP 映射后的语义 code(不是原始服务端 k_dl_xxx),保持
25
+ // 与 help / 文档里宣传的 RESULT_SET_TOO_LARGE 一致。
26
+ "RESULT_SET_TOO_LARGE": [
27
+ "Add `LIMIT <n>` to your SQL to narrow the result.",
28
+ "For counts, use `SELECT count(*) FROM ...`; for aggregation, use GROUP BY with filters.",
29
+ ],
30
+ };
10
31
  function isJsonMode() {
11
32
  const cfg = (0, config_1.getConfig)();
12
33
  return cfg.output === "json" || Boolean(cfg.json);
@@ -36,23 +57,55 @@ function emit(data) {
36
57
  */
37
58
  function emitError(err) {
38
59
  const info = toErrorInfo(err);
60
+ // 没人给过 next_actions 才走错误码兜底;handler 已经塞过具体 hint 时不覆盖
61
+ const hints = info.next_actions && info.next_actions.length > 0
62
+ ? info.next_actions
63
+ : (SERVER_ERROR_HINTS[info.code] ?? []);
39
64
  if (isJsonMode()) {
40
65
  const errObj = {
41
66
  code: info.code,
42
67
  message: info.message,
43
68
  };
44
- if (info.next_actions && info.next_actions.length > 0) {
45
- errObj.hint = info.next_actions.join(" ");
69
+ if (hints.length > 0) {
70
+ // JSON 输出压平成单行,更便于机器消费(脚本 / agent 拼字符串)
71
+ errObj.hint = hints.join(" ");
46
72
  }
47
73
  if (typeof info.statement_index === "number") {
48
74
  errObj.statement_index = info.statement_index;
49
75
  }
76
+ // PRD 多语句失败 envelope 额外字段:completed / rolled_back
77
+ if (Array.isArray(info.completed)) {
78
+ errObj.completed = info.completed;
79
+ }
80
+ if (typeof info.rolled_back === "boolean") {
81
+ errObj.rolled_back = info.rolled_back;
82
+ }
50
83
  process.stderr.write(JSON.stringify({ error: errObj }) + "\n");
51
84
  }
52
85
  else {
53
- process.stderr.write(`Error: ${info.message}\n`);
54
- if (info.next_actions && info.next_actions.length > 0) {
55
- process.stderr.write(` hint: ${info.next_actions.join(" ")}\n`);
86
+ // stderr 染色:picocolors 默认按 stdout.isTTY 判断;stderr 通常也是 tty,
87
+ // 这里复用 stdout 探测保持简单(stderr only-pipe 的极端场景留作后续优化)
88
+ // 多语句失败时 Error 行末尾追加 "(at statement K of N)",与 PRD spec 对齐
89
+ let errorLine = `${colors_1.c.fail("Error:")} ${info.message}`;
90
+ if (typeof info.statement_index === "number") {
91
+ const k = info.statement_index + 1;
92
+ const n = info.total_statements;
93
+ errorLine += typeof n === "number" && n > 0
94
+ ? ` (at statement ${String(k)} of ${String(n)})`
95
+ : ` (at statement ${String(k)})`;
96
+ }
97
+ process.stderr.write(errorLine + "\n");
98
+ // 多行 hint:第一行带 "hint:" 标签,后续行用 8 空格缩进对齐 " hint: " 之后的列。
99
+ // 对应 spec 期望的格式:
100
+ // Error: ...
101
+ // hint: 第一条建议
102
+ // 第二条建议
103
+ if (hints.length > 0) {
104
+ const [first, ...rest] = hints;
105
+ process.stderr.write(` ${colors_1.c.muted("hint:")} ${first}\n`);
106
+ for (const line of rest) {
107
+ process.stderr.write(` ${line}\n`);
108
+ }
56
109
  }
57
110
  }
58
111
  }
@@ -1,4 +1,14 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatSize = formatSize;
4
+ exports.formatTime = formatTime;
5
+ exports.visibleWidth = visibleWidth;
6
+ exports.renderAlignedTable = renderAlignedTable;
7
+ exports.renderTsv = renderTsv;
8
+ exports.renderKeyValue = renderKeyValue;
9
+ exports.isStdoutTty = isStdoutTty;
10
+ exports.parseDuration = parseDuration;
11
+ exports.parseSize = parseSize;
2
12
  /**
3
13
  * CLI 渲染 / 解析工具:跨域共用的格式化、表格渲染、字符串解析。
4
14
  *
@@ -9,18 +19,12 @@
9
19
  * - 终端探测:isStdoutTty
10
20
  * - 字符串解析:parseDuration / parseSize
11
21
  *
22
+ * 彩色高亮的语义层封装见 ./colors.ts。表头 / key 标签等结构性元素由本文件
23
+ * 主动调用 colors.c 染色;业务文案的染色(成功/失败 prefix 等)由 handler 自治。
24
+ *
12
25
  * JSON envelope 输出(emit / emitOk / emitPaged / emitError)见 ./output.ts。
13
26
  */
14
- Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.formatSize = formatSize;
16
- exports.formatTime = formatTime;
17
- exports.visibleWidth = visibleWidth;
18
- exports.renderAlignedTable = renderAlignedTable;
19
- exports.renderTsv = renderTsv;
20
- exports.renderKeyValue = renderKeyValue;
21
- exports.isStdoutTty = isStdoutTty;
22
- exports.parseDuration = parseDuration;
23
- exports.parseSize = parseSize;
27
+ const colors_1 = require("./colors");
24
28
  /** 将字节数格式化为人类可读(`24 KB` / `2.1 MB` / `1.5 GB`)。 */
25
29
  function formatSize(bytes) {
26
30
  if (!Number.isFinite(bytes) || bytes < 0)
@@ -109,7 +113,8 @@ function padVisibleEnd(s, targetWidth) {
109
113
  const w = visibleWidth(s);
110
114
  return w >= targetWidth ? s : s + " ".repeat(targetWidth - w);
111
115
  }
112
- /** 渲染 TTY 对齐表格(多行,列宽按最长内容;ANSI 转义不计入列宽)。 */
116
+ /** 渲染 TTY 对齐表格(多行,列宽按最长内容;ANSI 转义不计入列宽)。
117
+ * 表头按 spec 用 bold + cyan 染色;ANSI 序列由 visibleWidth 剥离不影响列宽。 */
113
118
  function renderAlignedTable(headers, rows) {
114
119
  const colWidths = headers.map((h, i) => {
115
120
  let w = visibleWidth(h);
@@ -121,7 +126,7 @@ function renderAlignedTable(headers, rows) {
121
126
  return w;
122
127
  });
123
128
  const lines = [];
124
- lines.push(headers.map((h, i) => padVisibleEnd(h, colWidths[i])).join(" ").trimEnd());
129
+ lines.push(headers.map((h, i) => colors_1.c.header(padVisibleEnd(h, colWidths[i]))).join(" ").trimEnd());
125
130
  for (const row of rows) {
126
131
  lines.push(row.map((cell, i) => padVisibleEnd(cell || "", colWidths[i])).join(" ").trimEnd());
127
132
  }
@@ -136,7 +141,7 @@ function renderTsv(headers, rows) {
136
141
  }
137
142
  return lines.join("\n");
138
143
  }
139
- /** 渲染 key-value 多行(用于 stat 等单条详情)。key 右对齐。 */
144
+ /** 渲染 key-value 多行(用于 stat 等单条详情)。key 右对齐 + bold cyan 染色。 */
140
145
  function renderKeyValue(pairs, isTty) {
141
146
  if (pairs.length === 0)
142
147
  return "";
@@ -145,7 +150,7 @@ function renderKeyValue(pairs, isTty) {
145
150
  }
146
151
  const keyWidth = Math.max(...pairs.map(([k]) => k.length));
147
152
  return pairs
148
- .map(([k, v]) => `${k.padStart(keyWidth)}: ${v}`)
153
+ .map(([k, v]) => `${colors_1.c.header(k.padStart(keyWidth))}: ${v}`)
149
154
  .join("\n");
150
155
  }
151
156
  /** 通用 isTTY 判定(stdout 是否交互终端)。Node 运行时 isTTY 为 true 或 undefined;TS 类型上 tty.WriteStream 定义为固定 true,绕开做运行时判断。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.1-alpha.d20f110",
3
+ "version": "0.1.1-alpha.e70b415",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -26,7 +26,8 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@lark-apaas/http-client": "^0.1.5",
29
- "commander": "^13.1.0"
29
+ "commander": "^13.1.0",
30
+ "picocolors": "^1.1.1"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^22.15.3",