@lark-apaas/miaoda-cli 0.1.0 → 0.1.1-alpha.07b4fd5
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 +278 -0
- package/dist/api/db/client.js +169 -0
- package/dist/api/db/index.js +16 -0
- package/dist/api/db/parsers.js +174 -0
- package/dist/api/db/sql-keywords.js +52 -0
- package/dist/api/db/types.js +10 -0
- package/dist/api/file/api.js +509 -0
- package/dist/api/file/client.js +199 -0
- package/dist/api/file/detect.js +56 -0
- package/dist/api/file/index.js +18 -0
- package/dist/api/file/parsers.js +72 -0
- package/dist/api/file/types.js +3 -0
- package/dist/api/index.js +5 -1
- package/dist/api/plugin/api.js +3 -3
- package/dist/cli/commands/db/index.js +209 -0
- package/dist/cli/commands/file/index.js +212 -0
- package/dist/cli/commands/index.js +4 -0
- package/dist/cli/commands/plugin/index.js +2 -1
- package/dist/cli/commands/shared.js +7 -8
- package/dist/cli/handlers/db/data.js +183 -0
- package/dist/cli/handlers/db/index.js +11 -0
- package/dist/cli/handlers/db/schema.js +174 -0
- package/dist/cli/handlers/db/sql.js +647 -0
- package/dist/cli/handlers/file/cp.js +221 -0
- package/dist/cli/handlers/file/index.js +13 -0
- package/dist/cli/handlers/file/ls.js +111 -0
- package/dist/cli/handlers/file/rm.js +264 -0
- package/dist/cli/handlers/file/sign.js +96 -0
- package/dist/cli/handlers/file/stat.js +97 -0
- package/dist/cli/handlers/index.js +2 -0
- package/dist/cli/help.js +188 -0
- package/dist/main.js +9 -1
- package/dist/utils/colors.js +98 -0
- package/dist/utils/error.js +14 -0
- package/dist/utils/fuzzy-match.js +91 -0
- package/dist/utils/http.js +31 -10
- package/dist/utils/index.js +3 -1
- package/dist/utils/output.js +75 -9
- package/dist/utils/render.js +192 -0
- package/package.json +4 -3
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.handleDbSql = handleDbSql;
|
|
40
|
+
const api = __importStar(require("../../../api/index"));
|
|
41
|
+
const error_1 = require("../../../utils/error");
|
|
42
|
+
const output_1 = require("../../../utils/output");
|
|
43
|
+
const config_1 = require("../../../utils/config");
|
|
44
|
+
const logger_1 = require("../../../utils/logger");
|
|
45
|
+
const shared_1 = require("../../../cli/commands/shared");
|
|
46
|
+
const render_1 = require("../../../utils/render");
|
|
47
|
+
const colors_1 = require("../../../utils/colors");
|
|
48
|
+
const fuzzy_match_1 = require("../../../utils/fuzzy-match");
|
|
49
|
+
const index_1 = require("../../../api/db/index");
|
|
50
|
+
const sql_keywords_1 = require("../../../api/db/sql-keywords");
|
|
51
|
+
const node_child_process_1 = require("node:child_process");
|
|
52
|
+
const node_fs_1 = require("node:fs");
|
|
53
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
54
|
+
/**
|
|
55
|
+
* miaoda db sql <query> — 执行任意 SQL。
|
|
56
|
+
*
|
|
57
|
+
* - query 为空时读 stdin
|
|
58
|
+
* - --env 透传给后端 admin-inner(dbBranch 参数),不传时由后端按 workspace
|
|
59
|
+
* 多环境状态兜底(多环境 → dev / 单环境 → main);后端检测到环境不存在
|
|
60
|
+
* 会返 k_dl_1600000 + "Invalid DB Branch:...",CLI 侧映射为 MULTI_ENV_NOT_INITIALIZED
|
|
61
|
+
* - 多语句行为对齐 PRD:每条 statement 一个独立结果元素,pretty 逐条 +
|
|
62
|
+
* 末尾汇总,--json 输出 data 数组。
|
|
63
|
+
*/
|
|
64
|
+
async function handleDbSql(query, opts) {
|
|
65
|
+
const appId = (0, shared_1.resolveAppId)(opts);
|
|
66
|
+
const sql = await readSql(query);
|
|
67
|
+
if (!sql.trim()) {
|
|
68
|
+
throw new error_1.AppError("ARGS_INVALID", "Empty SQL (no inline query and stdin is empty)");
|
|
69
|
+
}
|
|
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
|
+
}
|
|
84
|
+
if (results.length === 0) {
|
|
85
|
+
// 后端未返回任何结果,通常不会发生
|
|
86
|
+
if ((0, output_1.isJsonMode)()) {
|
|
87
|
+
(0, output_1.emit)({ data: [] });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
(0, output_1.emit)("✓ No results");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (results.length === 1) {
|
|
94
|
+
renderSingle(results[0]);
|
|
95
|
+
await maybeSyncAgentSchema(results);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// 多语句:每条 statement 独立结果
|
|
99
|
+
if ((0, output_1.isJsonMode)()) {
|
|
100
|
+
(0, output_1.emit)({ data: results.map((r) => toMultiElement((0, index_1.parseSqlResult)(r))) });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
renderMultiPretty(results);
|
|
104
|
+
}
|
|
105
|
+
await maybeSyncAgentSchema(results);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 当本次 SQL 包含成功执行的 DDL 时,触发 `npm run gen:db-schema`(实质执行
|
|
109
|
+
* fullstack-cli gen-db-schema → drizzle-kit introspect)同步 agent 项目的
|
|
110
|
+
* schema.ts。**弱依赖**:失败不阻塞主流程退出码。
|
|
111
|
+
*
|
|
112
|
+
* 行为细节:
|
|
113
|
+
* - 仅当 cwd/package.json 定义了 gen:db-schema 脚本时才尝试运行;非 agent
|
|
114
|
+
* 项目静默跳过
|
|
115
|
+
* - 默认静默捕获子进程 stdout/stderr,成功只打一行 "Agent schema synced",
|
|
116
|
+
* 失败时把捕获的输出回放给用户排查;--verbose 实时透传方便看进度
|
|
117
|
+
* - 加 60s 兜底超时:drizzle-kit introspect 通常 < 30s,超时直接 SIGKILL,
|
|
118
|
+
* 避免 DB 慢或网络抖动时把 CLI 挂死
|
|
119
|
+
* - 子进程的 stdout / stderr 都不会进入当前 CLI 的 stdout,--json 输出干净
|
|
120
|
+
*
|
|
121
|
+
* 调用时机:在 SQL 主输出 emit 完成之后;execSql 已经返回意味着所有
|
|
122
|
+
* statement 都执行成功,无需再校验逐条状态。
|
|
123
|
+
*/
|
|
124
|
+
/** agent runtime 中固定的项目根目录;放在此处便于以后调整。 */
|
|
125
|
+
const AGENT_PROJECT_ROOT = "/home/gem/workspace/code";
|
|
126
|
+
/** drain 子进程 pipe stream:默认静默模式下消费 chunk 防止缓冲区反压。 */
|
|
127
|
+
function drainChunk(_chunk) {
|
|
128
|
+
// 显式接 chunk 参数让 ESLint 不再当成 empty function;不做任何处理
|
|
129
|
+
}
|
|
130
|
+
async function maybeSyncAgentSchema(results) {
|
|
131
|
+
if (!hasDdl(results))
|
|
132
|
+
return;
|
|
133
|
+
const projectRoot = await resolveAgentProjectRoot();
|
|
134
|
+
if (!projectRoot) {
|
|
135
|
+
(0, logger_1.debug)("[db sql] agent project root not found or missing gen:db-schema script, skip schema sync");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const verbose = (0, config_1.getConfig)().verbose;
|
|
139
|
+
// drizzle-kit introspect 通常 < 30s;60s 兜底,超过即认为卡住
|
|
140
|
+
const TIMEOUT_MS = 60_000;
|
|
141
|
+
(0, logger_1.debug)(`[db sql] DDL detected, running \`npm run gen:db-schema\` in ${projectRoot}`);
|
|
142
|
+
try {
|
|
143
|
+
await new Promise((resolve) => {
|
|
144
|
+
const proc = (0, node_child_process_1.spawn)("npm", ["run", "gen:db-schema"], {
|
|
145
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
146
|
+
cwd: projectRoot,
|
|
147
|
+
});
|
|
148
|
+
// --verbose:实时透传到 stderr 看进度;默认完全静默丢弃。
|
|
149
|
+
// 任何路径下子进程输出都不会进入当前 CLI 的 stdout,--json 模式安全。
|
|
150
|
+
// 注:默认路径必须 attach 监听器,否则 pipe 缓冲区写满会反压子进程。
|
|
151
|
+
if (verbose) {
|
|
152
|
+
proc.stdout.pipe(process.stderr);
|
|
153
|
+
proc.stderr.pipe(process.stderr);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
proc.stdout.on("data", drainChunk);
|
|
157
|
+
proc.stderr.on("data", drainChunk);
|
|
158
|
+
}
|
|
159
|
+
let timedOut = false;
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
timedOut = true;
|
|
162
|
+
proc.kill("SIGKILL");
|
|
163
|
+
}, TIMEOUT_MS);
|
|
164
|
+
proc.on("close", (code) => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
if (timedOut) {
|
|
167
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema timed out after ${String(TIMEOUT_MS / 1000)}s, killed`);
|
|
168
|
+
}
|
|
169
|
+
else if (code !== 0 && code !== null) {
|
|
170
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema exited with code ${String(code)}`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
(0, logger_1.debug)("[db sql] gen:db-schema completed");
|
|
174
|
+
}
|
|
175
|
+
resolve();
|
|
176
|
+
});
|
|
177
|
+
proc.on("error", (err) => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema spawn error: ${err.message}`);
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema unexpected error: ${String(err)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 校验 AGENT_PROJECT_ROOT 是否是一个有效的 agent 项目(package.json 含
|
|
190
|
+
* `gen:db-schema` 脚本)。命中返回路径;缺失任一条件返 null,让调用方静默跳过。
|
|
191
|
+
*
|
|
192
|
+
* 这里不再从 process.cwd() 向上查找:agent runtime 里项目根是固定路径,
|
|
193
|
+
* 在 CLI 之外(本地手动跑 miaoda db sql)也不应触发同步。
|
|
194
|
+
*/
|
|
195
|
+
async function resolveAgentProjectRoot() {
|
|
196
|
+
try {
|
|
197
|
+
const pkgPath = node_path_1.default.join(AGENT_PROJECT_ROOT, "package.json");
|
|
198
|
+
const raw = await node_fs_1.promises.readFile(pkgPath, "utf8");
|
|
199
|
+
const pkg = JSON.parse(raw);
|
|
200
|
+
if (pkg.scripts?.["gen:db-schema"])
|
|
201
|
+
return AGENT_PROJECT_ROOT;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// 路径不存在 / 不可读 / 不是 agent 项目 → 静默跳过
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** 是否有任意一条 statement 是 DDL(CREATE / ALTER / DROP / GRANT / TRUNCATE / COMMENT 等)。 */
|
|
209
|
+
function hasDdl(results) {
|
|
210
|
+
return results.some((r) => (0, index_1.parseSqlResult)(r).kind === "ddl");
|
|
211
|
+
}
|
|
212
|
+
/** 读取 stdin 并返回完整 SQL 文本(stdin 不是 TTY 即认为被 pipe)。 */
|
|
213
|
+
async function readSql(inline) {
|
|
214
|
+
if (inline !== undefined && inline.length > 0)
|
|
215
|
+
return inline;
|
|
216
|
+
const stdin = process.stdin;
|
|
217
|
+
if (stdin.isTTY)
|
|
218
|
+
return "";
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
let data = "";
|
|
221
|
+
process.stdin.setEncoding("utf8");
|
|
222
|
+
process.stdin.on("data", (chunk) => {
|
|
223
|
+
data += chunk;
|
|
224
|
+
});
|
|
225
|
+
process.stdin.on("end", () => {
|
|
226
|
+
resolve(data);
|
|
227
|
+
});
|
|
228
|
+
process.stdin.on("error", reject);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function renderSingle(raw) {
|
|
232
|
+
const parsed = (0, index_1.parseSqlResult)(raw);
|
|
233
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
234
|
+
if ((0, output_1.isJsonMode)()) {
|
|
235
|
+
(0, output_1.emit)(toJson(parsed));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (parsed.kind === "select") {
|
|
239
|
+
if (parsed.rows.length === 0) {
|
|
240
|
+
(0, output_1.emit)("(0 rows)");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const cols = collectColumns(parsed.rows);
|
|
244
|
+
const rows = parsed.rows.map((r) => cols.map((col) => formatCell(r[col], tty)));
|
|
245
|
+
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(cols, rows) : (0, render_1.renderTsv)(cols, rows));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (parsed.kind === "dml") {
|
|
249
|
+
const verb = dmlVerb(parsed.sqlType);
|
|
250
|
+
(0, output_1.emit)(tty
|
|
251
|
+
? colors_1.c.success(`✓ ${String(parsed.affectedRows)} row${parsed.affectedRows === 1 ? "" : "s"} ${verb}`)
|
|
252
|
+
: `OK ${String(parsed.affectedRows)} rows ${verb}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// DDL
|
|
256
|
+
(0, output_1.emit)(tty ? colors_1.c.success("✓ DDL executed") : "OK DDL executed");
|
|
257
|
+
}
|
|
258
|
+
function toJson(parsed) {
|
|
259
|
+
if (parsed.kind === "select") {
|
|
260
|
+
// PRD 单 SELECT:data 直接是行数组(按 --json 字段投影裁剪)
|
|
261
|
+
return { data: projectRows(parsed.rows) };
|
|
262
|
+
}
|
|
263
|
+
if (parsed.kind === "dml") {
|
|
264
|
+
// PRD 单 DML:data = {command, rows_affected}
|
|
265
|
+
return {
|
|
266
|
+
data: {
|
|
267
|
+
command: parsed.sqlType,
|
|
268
|
+
rows_affected: parsed.affectedRows,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// PRD 单 DDL:data = {command, target?}。command 直接用后端给的细粒度
|
|
273
|
+
// (CREATE_TABLE / DROP_TABLE / ...),target 待后端给对象名后再加。
|
|
274
|
+
return { data: { command: parsed.sqlType } };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* 多语句 --json 元素:与单 DDL/DML 形状一致,但 SELECT 包成
|
|
278
|
+
* {command:"SELECT", rows:[...]}(PRD 约定,避免数组里嵌套数组造成歧义)。
|
|
279
|
+
*/
|
|
280
|
+
function toMultiElement(parsed) {
|
|
281
|
+
if (parsed.kind === "select") {
|
|
282
|
+
return { command: "SELECT", rows: projectRows(parsed.rows) };
|
|
283
|
+
}
|
|
284
|
+
if (parsed.kind === "dml") {
|
|
285
|
+
return { command: parsed.sqlType, rows_affected: parsed.affectedRows };
|
|
286
|
+
}
|
|
287
|
+
// DDL:用后端给的细粒度 command
|
|
288
|
+
return { command: parsed.sqlType };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* PRD:`--json id,name` 字段投影。--json 不带值(boolean true)等价于不裁剪。
|
|
292
|
+
* 字段不存在时按 undefined 处理(JSON.stringify 会忽略 undefined value 的 key),
|
|
293
|
+
* 这样 Agent 拿到的 row 永远只含请求过的列。
|
|
294
|
+
*/
|
|
295
|
+
function projectRows(rows) {
|
|
296
|
+
const fields = parseJsonFields();
|
|
297
|
+
if (!fields)
|
|
298
|
+
return rows;
|
|
299
|
+
return rows.map((r) => {
|
|
300
|
+
const out = {};
|
|
301
|
+
for (const f of fields) {
|
|
302
|
+
out[f] = r[f];
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/** 读取 --json [fields]:返回字段列表;boolean true 或 undefined 返回 null(不裁剪)。 */
|
|
308
|
+
function parseJsonFields() {
|
|
309
|
+
const v = (0, config_1.getConfig)().json;
|
|
310
|
+
if (typeof v !== "string" || v === "")
|
|
311
|
+
return null;
|
|
312
|
+
return v.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
313
|
+
}
|
|
314
|
+
/** 多语句 pretty:逐条 `Statement N: ...` + 末尾 `✓ N statements executed`。 */
|
|
315
|
+
function renderMultiPretty(results) {
|
|
316
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
317
|
+
const lines = [];
|
|
318
|
+
for (let i = 0; i < results.length; i++) {
|
|
319
|
+
const parsed = (0, index_1.parseSqlResult)(results[i]);
|
|
320
|
+
const idx = i + 1;
|
|
321
|
+
if (parsed.kind === "select") {
|
|
322
|
+
const n = parsed.rows.length;
|
|
323
|
+
lines.push(`Statement ${String(idx)}: SELECT (${String(n)} row${n === 1 ? "" : "s"})`);
|
|
324
|
+
if (n > 0) {
|
|
325
|
+
const cols = collectColumns(parsed.rows);
|
|
326
|
+
const tbl = parsed.rows.map((r) => cols.map((col) => formatCell(r[col], tty)));
|
|
327
|
+
lines.push(tty ? (0, render_1.renderAlignedTable)(cols, tbl) : (0, render_1.renderTsv)(cols, tbl));
|
|
328
|
+
}
|
|
329
|
+
// 块间空行(最后一条不留)
|
|
330
|
+
if (i < results.length - 1)
|
|
331
|
+
lines.push("");
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (parsed.kind === "dml") {
|
|
335
|
+
const verb = dmlVerb(parsed.sqlType);
|
|
336
|
+
const n = parsed.affectedRows;
|
|
337
|
+
const body = `${String(n)} row${n === 1 ? "" : "s"} ${verb}`;
|
|
338
|
+
lines.push(`Statement ${String(idx)}: ${tty ? colors_1.c.success("✓ " + body) : body}`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// DDL
|
|
342
|
+
lines.push(`Statement ${String(idx)}: ${tty ? colors_1.c.success("✓ DDL executed") : "DDL executed"}`);
|
|
343
|
+
}
|
|
344
|
+
// 汇总行:所有 statement 都跑完了
|
|
345
|
+
lines.push(tty
|
|
346
|
+
? colors_1.c.success(`✓ ${String(results.length)} statements executed`)
|
|
347
|
+
: `OK ${String(results.length)} statements executed`);
|
|
348
|
+
(0, output_1.emit)(lines.join("\n"));
|
|
349
|
+
}
|
|
350
|
+
function dmlVerb(type) {
|
|
351
|
+
switch (type) {
|
|
352
|
+
case "INSERT": return "inserted";
|
|
353
|
+
case "UPDATE": return "updated";
|
|
354
|
+
case "DELETE": return "deleted";
|
|
355
|
+
case "MERGE": return "merged";
|
|
356
|
+
case "DML": return "affected"; // 未识别子类的兜底
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** 从第一行收集列顺序;缺失列保留空白(兼容稀疏行)。 */
|
|
360
|
+
function collectColumns(rows) {
|
|
361
|
+
if (rows.length === 0)
|
|
362
|
+
return [];
|
|
363
|
+
return Object.keys(rows[0]);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* SQL 单元格 → 字符串:
|
|
367
|
+
* - null / undefined → "NULL"(TTY 下用 dim+gray ANSI 弱化,对齐 mysql/DuckDB;
|
|
368
|
+
* non-TTY 用裸字面量,避免被 cat / 管道吞掉样式;JSON 模式不会走到这里,
|
|
369
|
+
* 走 toJson 直接用 parsed.rows,JSON.stringify 输出 `null` 字面)
|
|
370
|
+
* - string → 原样
|
|
371
|
+
* - number / boolean → toString
|
|
372
|
+
* - object / array → JSON.stringify
|
|
373
|
+
*/
|
|
374
|
+
function formatCell(v, tty) {
|
|
375
|
+
if (v === null || v === undefined) {
|
|
376
|
+
return tty ? colors_1.c.muted("NULL") : "NULL";
|
|
377
|
+
}
|
|
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
|
+
}
|
|
384
|
+
return v;
|
|
385
|
+
}
|
|
386
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
387
|
+
return String(v);
|
|
388
|
+
// object / array → JSON
|
|
389
|
+
return JSON.stringify(v);
|
|
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
|
+
}
|