@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.
- package/dist/api/db/api.js +43 -6
- package/dist/api/db/client.js +3 -0
- package/dist/api/db/parsers.js +33 -20
- package/dist/api/db/sql-keywords.js +52 -0
- package/dist/api/file/api.js +31 -4
- package/dist/cli/handlers/db/schema.js +14 -3
- package/dist/cli/handlers/db/sql.js +290 -10
- package/dist/cli/handlers/file/cp.js +3 -2
- package/dist/cli/handlers/file/rm.js +3 -2
- package/dist/utils/colors.js +98 -0
- package/dist/utils/error.js +11 -0
- package/dist/utils/fuzzy-match.js +91 -0
- package/dist/utils/output.js +58 -5
- package/dist/utils/render.js +19 -14
- package/package.json +3 -2
package/dist/api/db/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
/**
|
package/dist/api/db/client.js
CHANGED
|
@@ -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 = {
|
package/dist/api/db/parsers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
];
|
package/dist/api/file/api.js
CHANGED
|
@@ -299,18 +299,28 @@ async function preUpload(appId, req) {
|
|
|
299
299
|
}
|
|
300
300
|
/**
|
|
301
301
|
* 调用 upload callback 拿到对象元数据。
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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 ? "✓ " : "
|
|
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 ? "
|
|
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
|
+
};
|
package/dist/utils/error.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/utils/output.js
CHANGED
|
@@ -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 (
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|
package/dist/utils/render.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|