@lark-apaas/miaoda-cli 0.1.0-alpha.ec1a658 → 0.1.0-alpha.f914906
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 +65 -54
- package/dist/api/file/api.js +46 -4
- package/dist/api/file/index.js +2 -1
- package/dist/cli/commands/db/index.js +174 -35
- package/dist/cli/commands/file/index.js +159 -22
- package/dist/cli/commands/plugin/index.js +2 -1
- package/dist/cli/handlers/db/data.js +4 -1
- package/dist/cli/handlers/db/sql.js +116 -1
- package/dist/cli/handlers/file/ls.js +1 -0
- package/dist/cli/help.js +188 -0
- package/dist/main.js +9 -1
- package/package.json +1 -1
package/dist/api/db/api.js
CHANGED
|
@@ -6,7 +6,36 @@ exports.importData = importData;
|
|
|
6
6
|
exports.exportData = exportData;
|
|
7
7
|
const http_1 = require("../../utils/http");
|
|
8
8
|
const error_1 = require("../../utils/error");
|
|
9
|
+
const http_client_1 = require("@lark-apaas/http-client");
|
|
9
10
|
const client_1 = require("./client");
|
|
11
|
+
/**
|
|
12
|
+
* 把 SDK 抛出的 HttpError 统一映射成 CLI 层错误:
|
|
13
|
+
* 1. 先尝试从 response body 解 envelope,命中 dataloom 业务 code → AppError
|
|
14
|
+
* 2. 兜底返 HttpError,保留真实 status 码与上下文
|
|
15
|
+
*
|
|
16
|
+
* 配合调用点的 try/catch + traceHttp,让 --verbose 在错误路径上也能拿到
|
|
17
|
+
* x-tt-logid 与 status,方便定位线上问题。
|
|
18
|
+
*/
|
|
19
|
+
async function mapDbHttpError(err, url, ctx) {
|
|
20
|
+
if (err instanceof error_1.AppError)
|
|
21
|
+
throw err;
|
|
22
|
+
if (err instanceof http_client_1.HttpError) {
|
|
23
|
+
const status = err.response?.status ?? 0;
|
|
24
|
+
const statusText = err.response?.statusText ?? "";
|
|
25
|
+
try {
|
|
26
|
+
const body = (await err.response?.json());
|
|
27
|
+
if (body)
|
|
28
|
+
(0, client_1.extractData)(body); // 业务 code 命中 → 抛 AppError;不命中走兜底
|
|
29
|
+
}
|
|
30
|
+
catch (innerErr) {
|
|
31
|
+
if (innerErr instanceof error_1.AppError)
|
|
32
|
+
throw innerErr;
|
|
33
|
+
// body 解析失败 → 当成无 envelope 的纯 HTTP 错误处理
|
|
34
|
+
}
|
|
35
|
+
throw new error_1.HttpError(status, url, `${ctx}: ${String(status)} ${statusText}`.trim());
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
10
39
|
// CLI 不再为 dbBranch 设默认值:
|
|
11
40
|
// 用户没传 --env 就完全不携带 dbBranch query 参数,由后端 admin-inner 中间件
|
|
12
41
|
// 按 workspace 多环境状态决定(多环境 → dev / 单环境 → main)。
|
|
@@ -25,20 +54,15 @@ async function execSql(opts) {
|
|
|
25
54
|
dbBranch: opts.dbBranch,
|
|
26
55
|
});
|
|
27
56
|
const start = Date.now();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// ignore
|
|
38
|
-
}
|
|
39
|
-
if (body)
|
|
40
|
-
(0, client_1.extractData)(body);
|
|
41
|
-
throw new error_1.HttpError(response.status, url, `Failed to execute SQL: ${String(response.status)} ${response.statusText}`);
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await client.post(url, { sql: opts.sql });
|
|
60
|
+
(0, client_1.traceHttp)("POST", url, start, response);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
(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");
|
|
65
|
+
throw err; // 不可达
|
|
42
66
|
}
|
|
43
67
|
const body = (await response.json());
|
|
44
68
|
const data = (0, client_1.extractData)(body);
|
|
@@ -60,19 +84,15 @@ async function getSchema(opts) {
|
|
|
60
84
|
dbBranch: opts.dbBranch,
|
|
61
85
|
});
|
|
62
86
|
const start = Date.now();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
if (body)
|
|
74
|
-
(0, client_1.extractData)(body);
|
|
75
|
-
throw new error_1.HttpError(response.status, url, `Failed to get schema: ${String(response.status)} ${response.statusText}`);
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await client.get(url);
|
|
90
|
+
(0, client_1.traceHttp)("GET", url, start, response);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
(0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
94
|
+
await mapDbHttpError(err, url, "Failed to get schema");
|
|
95
|
+
throw err; // 不可达
|
|
76
96
|
}
|
|
77
97
|
const body = (await response.json());
|
|
78
98
|
return (0, client_1.extractData)(body);
|
|
@@ -101,19 +121,15 @@ async function importData(opts) {
|
|
|
101
121
|
reqBody.dbBranch = opts.dbBranch;
|
|
102
122
|
}
|
|
103
123
|
const start = Date.now();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
if (body)
|
|
115
|
-
(0, client_1.extractData)(body);
|
|
116
|
-
throw new error_1.HttpError(response.status, url, `Failed to import data: ${String(response.status)} ${response.statusText}`);
|
|
124
|
+
let response;
|
|
125
|
+
try {
|
|
126
|
+
response = await client.post(url, reqBody);
|
|
127
|
+
(0, client_1.traceHttp)("POST", url, start, response);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
(0, client_1.traceHttp)("POST", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
131
|
+
await mapDbHttpError(err, url, "Failed to import data");
|
|
132
|
+
throw err; // 不可达
|
|
117
133
|
}
|
|
118
134
|
// 后端 InnerAdminImportData 响应里 data 直接返 {tableName, recordCount, durationMs}
|
|
119
135
|
const body = (await response.json());
|
|
@@ -144,20 +160,15 @@ async function exportData(opts) {
|
|
|
144
160
|
});
|
|
145
161
|
// POST + 空 body:所有业务参数都在 query 里
|
|
146
162
|
const start = Date.now();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// ignore
|
|
157
|
-
}
|
|
158
|
-
if (body)
|
|
159
|
-
(0, client_1.extractData)(body);
|
|
160
|
-
throw new error_1.HttpError(response.status, url, `Failed to export data: ${String(response.status)} ${response.statusText}`);
|
|
163
|
+
let response;
|
|
164
|
+
try {
|
|
165
|
+
response = await client.request({ method: "POST", url });
|
|
166
|
+
(0, client_1.traceHttp)("POST", url, start, response);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
(0, client_1.traceHttp)("POST", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
170
|
+
await mapDbHttpError(err, url, "Failed to export data");
|
|
171
|
+
throw err; // 不可达
|
|
161
172
|
}
|
|
162
173
|
// 成功路径:响应 body 通常是原始 CSV/JSON 字节,但部分错误场景下网关会返
|
|
163
174
|
// HTTP 200 + JSON envelope(status_code != "0"),需要在这里嗅探兜底。
|
package/dist/api/file/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseTimeFilterMs = parseTimeFilterMs;
|
|
3
4
|
exports.listFiles = listFiles;
|
|
4
5
|
exports.resolveInputs = resolveInputs;
|
|
5
6
|
exports.statFile = statFile;
|
|
@@ -82,15 +83,56 @@ function buildFilterExpr(opts) {
|
|
|
82
83
|
}
|
|
83
84
|
if (opts.uploadedSince) {
|
|
84
85
|
// 后端期望毫秒 timestamp(strconv.ParseInt → time.UnixMilli)
|
|
85
|
-
const ms =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
const ms = parseTimeFilterMs(opts.uploadedSince, "--uploaded-since");
|
|
87
|
+
conds.push({ field: "createdAt", operator: "gte", value: String(ms) });
|
|
88
|
+
}
|
|
89
|
+
if (opts.uploadedUntil) {
|
|
90
|
+
const ms = parseTimeFilterMs(opts.uploadedUntil, "--uploaded-until");
|
|
91
|
+
conds.push({ field: "createdAt", operator: "lte", value: String(ms) });
|
|
89
92
|
}
|
|
90
93
|
if (conds.length === 0)
|
|
91
94
|
return undefined;
|
|
92
95
|
return { logic: "and", groups: [{ conditions: conds }] };
|
|
93
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* 解析时间过滤参数(--uploaded-since / --uploaded-until)的三种输入格式
|
|
99
|
+
* (→ ms timestamp):
|
|
100
|
+
* 1. 相对时间:"30s" / "10m" / "1h" / "2d" / "1w" → 当前时间往前推 N 单位
|
|
101
|
+
* 2. 日期: "2026-04-01" → 按当日 00:00:00 UTC
|
|
102
|
+
* 3. ISO 8601:"2026-04-01T10:00:00Z" → 严格解析(推荐用 Z 结尾标识 UTC)
|
|
103
|
+
*
|
|
104
|
+
* 三种格式都不命中时抛 AppError("ARGS_INVALID"),避免之前 Date.parse=NaN
|
|
105
|
+
* 时静默跳过过滤、用户却以为筛选生效的悄然失败问题。
|
|
106
|
+
*
|
|
107
|
+
* flagName 用于错误信息,调用方传 "--uploaded-since" 或 "--uploaded-until"。
|
|
108
|
+
*/
|
|
109
|
+
function parseTimeFilterMs(input, flagName) {
|
|
110
|
+
// 相对时间:<positive int><unit>,单位 s/m/h/d/w
|
|
111
|
+
const RELATIVE = /^(\d+)([smhdw])$/;
|
|
112
|
+
const rel = RELATIVE.exec(input);
|
|
113
|
+
if (rel) {
|
|
114
|
+
const n = parseInt(rel[1], 10);
|
|
115
|
+
if (n <= 0) {
|
|
116
|
+
throw new error_1.AppError("ARGS_INVALID", `${flagName} "${input}" 必须是正整数 + 单位(s / m / h / d / w)`);
|
|
117
|
+
}
|
|
118
|
+
const UNIT_MS = {
|
|
119
|
+
s: 1_000,
|
|
120
|
+
m: 60_000,
|
|
121
|
+
h: 3_600_000,
|
|
122
|
+
d: 86_400_000,
|
|
123
|
+
w: 604_800_000,
|
|
124
|
+
};
|
|
125
|
+
return Date.now() - n * UNIT_MS[rel[2]];
|
|
126
|
+
}
|
|
127
|
+
// 绝对时间:date / ISO 8601。Date.parse 对 "YYYY-MM-DD" 按 UTC 00:00:00 解析,
|
|
128
|
+
// 对带 Z 的 ISO 8601 也直接出 UTC ms。
|
|
129
|
+
const ms = Date.parse(input);
|
|
130
|
+
if (Number.isNaN(ms)) {
|
|
131
|
+
throw new error_1.AppError("ARGS_INVALID", `${flagName} "${input}" 格式无法识别。支持:` +
|
|
132
|
+
`相对时间(如 1h / 2d / 1w)、日期(YYYY-MM-DD)、ISO 8601(如 2026-04-01T10:00:00Z)`);
|
|
133
|
+
}
|
|
134
|
+
return ms;
|
|
135
|
+
}
|
|
94
136
|
/**
|
|
95
137
|
* 列出文件:所有过滤下推到后端 FilterExpression,纯精确匹配,无 glob。
|
|
96
138
|
*
|
package/dist/api/file/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.toAbsolutePath = exports.looksLikePath = exports.resetBucketCache = exports.getDefaultBucketId = exports.resolveInputs = exports.deleteFiles = exports.downloadFile = exports.signDownload = exports.uploadFile = exports.statFile = exports.listFiles = void 0;
|
|
3
|
+
exports.toAbsolutePath = exports.looksLikePath = exports.resetBucketCache = exports.getDefaultBucketId = exports.parseTimeFilterMs = exports.resolveInputs = exports.deleteFiles = exports.downloadFile = exports.signDownload = exports.uploadFile = exports.statFile = exports.listFiles = void 0;
|
|
4
4
|
var api_1 = require("./api");
|
|
5
5
|
Object.defineProperty(exports, "listFiles", { enumerable: true, get: function () { return api_1.listFiles; } });
|
|
6
6
|
Object.defineProperty(exports, "statFile", { enumerable: true, get: function () { return api_1.statFile; } });
|
|
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "signDownload", { enumerable: true, get: function
|
|
|
9
9
|
Object.defineProperty(exports, "downloadFile", { enumerable: true, get: function () { return api_1.downloadFile; } });
|
|
10
10
|
Object.defineProperty(exports, "deleteFiles", { enumerable: true, get: function () { return api_1.deleteFiles; } });
|
|
11
11
|
Object.defineProperty(exports, "resolveInputs", { enumerable: true, get: function () { return api_1.resolveInputs; } });
|
|
12
|
+
Object.defineProperty(exports, "parseTimeFilterMs", { enumerable: true, get: function () { return api_1.parseTimeFilterMs; } });
|
|
12
13
|
var client_1 = require("./client");
|
|
13
14
|
Object.defineProperty(exports, "getDefaultBucketId", { enumerable: true, get: function () { return client_1.getDefaultBucketId; } });
|
|
14
15
|
Object.defineProperty(exports, "resetBucketCache", { enumerable: true, get: function () { return client_1.resetBucketCache; } });
|
|
@@ -5,65 +5,204 @@ const index_1 = require("../../../cli/handlers/db/index");
|
|
|
5
5
|
function registerDbCommands(program) {
|
|
6
6
|
const dbCmd = program
|
|
7
7
|
.command("db")
|
|
8
|
-
.description("
|
|
8
|
+
.description("应用数据库(PostgreSQL)的命令行操作集合。")
|
|
9
|
+
.usage("<command> [flags]")
|
|
10
|
+
// --env 注册在 db 父级,spec 把它列入 db --help 的 Global Flags;
|
|
11
|
+
// leaf 命令仍各自接收 --env 值(commander 解析时父级 option 自动适用于子命令)
|
|
12
|
+
.option("--env <name>", "指定目标环境(dev / online,仅专家模式应用支持)");
|
|
9
13
|
dbCmd.action(() => {
|
|
10
14
|
dbCmd.outputHelp();
|
|
11
15
|
});
|
|
12
16
|
dbCmd
|
|
13
17
|
.command("sql")
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
.summary("执行任意 SQL(SELECT / DML / DDL,支持多条分号分隔)")
|
|
19
|
+
.description("执行 SQL 语句,支持 SELECT、DML、DDL 等所有标准 PostgreSQL 操作。\n" +
|
|
20
|
+
"支持通过 stdin 读取(miaoda db sql < file.sql),多条语句以分号分隔。\n" +
|
|
21
|
+
"事务控制使用 PG 原生的 BEGIN / COMMIT / ROLLBACK 语法。")
|
|
22
|
+
.usage("<query> [flags]")
|
|
23
|
+
.argument("[query]", "要执行的 SQL 语句;省略时从标准输入读取")
|
|
24
|
+
.action(async function (query) {
|
|
25
|
+
await (0, index_1.handleDbSql)(query, this.optsWithGlobals());
|
|
26
|
+
})
|
|
27
|
+
.addHelpText("after", `
|
|
28
|
+
Notes:
|
|
29
|
+
- SELECT 结果超过 1000 行直接报错(RESULT_SET_TOO_LARGE),不做隐式截断。
|
|
30
|
+
需要分页时请在 SQL 中用 LIMIT / OFFSET。
|
|
31
|
+
- 多条语句不自动包事务:失败时后续不执行、已成功的不回滚。
|
|
32
|
+
需要原子性请显式 BEGIN; ...; COMMIT;
|
|
33
|
+
- --env 仅在专家模式应用且已 migration init 后可用。
|
|
34
|
+
- NULL 值在 --json 中是 JSON 原生 null,pretty / 管道中是字面字符串 NULL。
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
$ miaoda db sql "SELECT id, name, age FROM users LIMIT 10"
|
|
38
|
+
id name age
|
|
39
|
+
1001 Alice 28
|
|
40
|
+
1002 Bob 35
|
|
41
|
+
|
|
42
|
+
$ miaoda db sql "INSERT INTO users (name, age) VALUES ('Dave', 25)"
|
|
43
|
+
✓ 1 row inserted
|
|
44
|
+
|
|
45
|
+
$ miaoda db sql < migration.sql
|
|
46
|
+
Statement 1: ✓ CREATE TABLE orders
|
|
47
|
+
Statement 2: ✓ 10 rows inserted
|
|
48
|
+
✓ 2 statements executed
|
|
49
|
+
|
|
50
|
+
# 报错:SQL 语法错误
|
|
51
|
+
$ miaoda db sql "SELCT * FROM users"
|
|
52
|
+
Error: Syntax error at or near "SELCT"
|
|
53
|
+
hint: Check SQL keyword spelling. Did you mean "SELECT"?
|
|
54
|
+
|
|
55
|
+
# 报错:结果超过 1000 行
|
|
56
|
+
$ miaoda db sql "SELECT * FROM users"
|
|
57
|
+
Error: Result set exceeds the 1000-row limit (query would return 15234 rows)
|
|
58
|
+
hint: Add \`LIMIT <n>\` to your SQL to narrow the result.
|
|
59
|
+
`);
|
|
20
60
|
// schema 二级资源分组
|
|
21
61
|
const schemaCmd = dbCmd
|
|
22
62
|
.command("schema")
|
|
23
|
-
.
|
|
63
|
+
.summary("查看数据库表结构")
|
|
64
|
+
.description("查看应用数据库的表结构信息。")
|
|
65
|
+
.usage("<command> [flags]");
|
|
24
66
|
schemaCmd.action(() => {
|
|
25
67
|
schemaCmd.outputHelp();
|
|
26
68
|
});
|
|
27
69
|
schemaCmd
|
|
28
70
|
.command("list")
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
71
|
+
.summary("列出所有表的概览(表名、行数、大小等)")
|
|
72
|
+
.description("列出当前应用所有表的概览信息:表名、描述、行数、大小、列数、最近更新时间。\n" +
|
|
73
|
+
"查看单表完整结构请用 `schema get`;查看 DDL 变更历史请用 `changelog`(P1)。")
|
|
74
|
+
.usage("[flags]")
|
|
75
|
+
.action(async function () {
|
|
76
|
+
await (0, index_1.handleDbSchemaList)(this.optsWithGlobals());
|
|
77
|
+
})
|
|
78
|
+
.addHelpText("after", `
|
|
79
|
+
Examples:
|
|
80
|
+
$ miaoda db schema list
|
|
81
|
+
name description rows size columns updated_at
|
|
82
|
+
users 用户信息 1523 2.1 MB 5 3h ago
|
|
83
|
+
orders 订单记录 8891 12.4 MB 8 2d ago
|
|
84
|
+
products 商品目录 342 512 KB 6 2026-03-15
|
|
85
|
+
|
|
86
|
+
$ miaoda db schema list --json name,rows
|
|
87
|
+
{
|
|
88
|
+
"data": [
|
|
89
|
+
{"name": "users", "rows": 1523},
|
|
90
|
+
{"name": "orders", "rows": 8891},
|
|
91
|
+
{"name": "products", "rows": 342}
|
|
92
|
+
],
|
|
93
|
+
"next_cursor": null,
|
|
94
|
+
"has_more": false
|
|
95
|
+
}
|
|
96
|
+
`);
|
|
34
97
|
schemaCmd
|
|
35
98
|
.command("get")
|
|
36
|
-
.
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
99
|
+
.summary("查看单表完整结构(列定义、索引、约束)")
|
|
100
|
+
.description("查看指定表的完整结构:列定义(含类型、可空、默认值、注释)、索引、行数、大小。\n" +
|
|
101
|
+
"默认 pretty 输出 CREATE TABLE 的 SQL 文本;--json 输出结构化字段。")
|
|
102
|
+
.usage("<table> [flags]")
|
|
103
|
+
.argument("<table>", "表名(无需带 schema 前缀)")
|
|
104
|
+
.option("--ddl", "强制输出 CREATE TABLE 建表语句(pretty 默认就是 DDL,--json 时配合此 flag 返 SQL 文本)")
|
|
105
|
+
.action(async function (table) {
|
|
106
|
+
await (0, index_1.handleDbSchemaGet)(table, this.optsWithGlobals());
|
|
107
|
+
})
|
|
108
|
+
.addHelpText("after", `
|
|
109
|
+
Examples:
|
|
110
|
+
$ miaoda db schema get users
|
|
111
|
+
CREATE TABLE users (
|
|
112
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
113
|
+
name varchar(100) NOT NULL,
|
|
114
|
+
email varchar(255),
|
|
115
|
+
age integer,
|
|
116
|
+
PRIMARY KEY (id),
|
|
117
|
+
UNIQUE (email)
|
|
118
|
+
);
|
|
119
|
+
COMMENT ON TABLE users IS '用户信息';
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
# 报错:表不存在
|
|
123
|
+
$ miaoda db schema get userss
|
|
124
|
+
Error: Table 'userss' does not exist
|
|
125
|
+
hint: Did you mean 'users'? Run \`miaoda db schema list\` to see all tables.
|
|
126
|
+
`);
|
|
43
127
|
// data 二级资源分组
|
|
44
128
|
const dataCmd = dbCmd
|
|
45
129
|
.command("data")
|
|
46
|
-
.
|
|
130
|
+
.summary("表数据导入导出")
|
|
131
|
+
.description("表数据的批量导入导出,适合数据备份、跨环境迁移、外部分析。")
|
|
132
|
+
.usage("<command> [flags]");
|
|
47
133
|
dataCmd.action(() => {
|
|
48
134
|
dataCmd.outputHelp();
|
|
49
135
|
});
|
|
50
136
|
dataCmd
|
|
51
137
|
.command("import")
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
138
|
+
.summary("从本地 CSV / JSON 文件导入数据到表")
|
|
139
|
+
.description("从本地 CSV / JSON 文件导入数据到表。整个导入是原子的——遇到任何错误(主键冲突、\n" +
|
|
140
|
+
"类型不匹配、列名不匹配等)会回滚已插入的所有行。\n" +
|
|
141
|
+
"不支持 SQL 文件,SQL 备份请用 `miaoda db sql < <文件>.sql` 回放。")
|
|
142
|
+
.usage("<file> [flags]")
|
|
143
|
+
.argument("<file>", "本地文件路径(CSV 或 JSON 格式)")
|
|
144
|
+
.option("--table <name>", "目标表名;未指定时按文件名(不含扩展名)推断(如 users.csv → users)")
|
|
145
|
+
.option("--format <fmt>", "文件格式 csv / json;未指定时按文件扩展名推断")
|
|
146
|
+
.action(async function (file) {
|
|
147
|
+
await (0, index_1.handleDbDataImport)(file, this.optsWithGlobals());
|
|
148
|
+
})
|
|
149
|
+
.addHelpText("after", `
|
|
150
|
+
Notes:
|
|
151
|
+
- 目标表必须已存在;CSV 表头 / JSON 顶层 key 必须与表字段名完全一致。
|
|
152
|
+
- 导入为原子操作:遇到错误回滚所有已插入行,不会出现部分导入的中间状态。
|
|
153
|
+
- 仅支持 .csv 和 .json 文件扩展名。
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
$ miaoda db data import users.csv
|
|
157
|
+
✓ Imported users.csv → table 'users' (1523 rows)
|
|
158
|
+
|
|
159
|
+
$ miaoda db data import data.csv --table users
|
|
160
|
+
✓ Imported data.csv → table 'users' (1523 rows)
|
|
161
|
+
|
|
162
|
+
# 报错:CSV 表头与字段不匹配
|
|
163
|
+
$ miaoda db data import users.csv
|
|
164
|
+
Error: Column 'full_name' in CSV does not match any column in table 'users'
|
|
165
|
+
hint: Expected columns: id, name, email, age. Check CSV header row.
|
|
166
|
+
|
|
167
|
+
# 报错:主键冲突(已回滚)
|
|
168
|
+
$ miaoda db data import users.csv
|
|
169
|
+
Error: Primary key conflict at row 42 (id=1001 already exists), 0 rows imported
|
|
170
|
+
hint: Deduplicate input data, or remove conflicting rows first with
|
|
171
|
+
\`miaoda db sql "DELETE FROM users WHERE id IN (...)"\`.
|
|
172
|
+
`);
|
|
59
173
|
dataCmd
|
|
60
174
|
.command("export")
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
.
|
|
175
|
+
.summary("导出表数据到本地 CSV / JSON / SQL 文件")
|
|
176
|
+
.description("把指定表的所有数据导出到本地文件,支持 CSV / JSON / SQL 三种格式。")
|
|
177
|
+
.usage("<table> [flags]")
|
|
178
|
+
.argument("<table>", "表名(无需带 schema 前缀)")
|
|
179
|
+
.option("--format <fmt>", "导出格式 csv / json / sql,默认 csv(sql 输出 INSERT 语句,可用 db sql < file.sql 回放)")
|
|
180
|
+
.option("-f, --file <path>", "输出文件路径,默认 <表名>.<格式>")
|
|
65
181
|
.option("--limit <n>", "最多导出行数(不超过 5000)")
|
|
66
|
-
.action(async (table
|
|
67
|
-
await (0, index_1.handleDbDataExport)(table,
|
|
68
|
-
})
|
|
182
|
+
.action(async function (table) {
|
|
183
|
+
await (0, index_1.handleDbDataExport)(table, this.optsWithGlobals());
|
|
184
|
+
})
|
|
185
|
+
.addHelpText("after", `
|
|
186
|
+
Notes:
|
|
187
|
+
- SQL 格式生成 INSERT 语句,可用 \`miaoda db sql < <文件>.sql\` 在其他环境回放。
|
|
188
|
+
- 文件已存在时默认报错(FILE_ALREADY_EXISTS),用 --force 覆盖或 -f 改路径。
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
$ miaoda db data export users
|
|
192
|
+
✓ Exported users → users.csv (1523 rows)
|
|
193
|
+
|
|
194
|
+
$ miaoda db data export users --format json
|
|
195
|
+
✓ Exported users → users.json (1523 rows)
|
|
196
|
+
|
|
197
|
+
$ miaoda db data export users --format sql -f users_backup.sql
|
|
198
|
+
✓ Exported users → users_backup.sql (1523 rows)
|
|
199
|
+
|
|
200
|
+
$ miaoda db data export users -f ~/Desktop/users.csv
|
|
201
|
+
✓ Exported users → ~/Desktop/users.csv (1523 rows)
|
|
202
|
+
|
|
203
|
+
# 报错:文件已存在
|
|
204
|
+
$ miaoda db data export users
|
|
205
|
+
Error: Output file 'users.csv' already exists
|
|
206
|
+
hint: Use -f to specify a different path, or --force to overwrite.
|
|
207
|
+
`);
|
|
69
208
|
}
|
|
@@ -19,57 +19,194 @@ function parsePositiveInt(raw) {
|
|
|
19
19
|
function registerFileCommands(program) {
|
|
20
20
|
const fileCmd = program
|
|
21
21
|
.command("file")
|
|
22
|
-
.description("
|
|
22
|
+
.description("应用文件存储(TOS)的命令行操作集合。操作对象是 UGC 资源(用户上传的文件、应用运行时\n" +
|
|
23
|
+
"生成的报表 / 导出文件等),不涉及代码仓库里的本地文件。")
|
|
24
|
+
.usage("<command> [flags]");
|
|
23
25
|
fileCmd.action(() => {
|
|
24
26
|
fileCmd.outputHelp();
|
|
25
27
|
});
|
|
26
28
|
fileCmd
|
|
27
29
|
.command("ls")
|
|
28
|
-
.
|
|
29
|
-
.
|
|
30
|
+
.summary("列出 / 筛选文件")
|
|
31
|
+
.description("列出当前应用存储里的文件,支持按文件名、大小、上传时间筛选,以及游标分页。\n" +
|
|
32
|
+
"默认 pretty 输出省略 download_url(列宽限制),需获取请用 --json。")
|
|
33
|
+
.usage("[query] [flags]")
|
|
34
|
+
.argument("[query]", "筛选值:以 / 开头视为路径精确匹配,否则按文件名精确匹配")
|
|
30
35
|
.option("--path <path>", "按路径精确匹配")
|
|
31
36
|
.option("--name <name>", "按文件名精确匹配")
|
|
32
37
|
.option("--type <mime>", "按 MIME 类型筛选(如 image/png)")
|
|
33
|
-
.option("--size-gt <size>", "文件大小下限(支持 B/KB/MB/GB)")
|
|
34
|
-
.option("--size-lt <size>", "文件大小上限(支持 B/KB/MB/GB)")
|
|
35
|
-
.option("--uploaded-since <time>", "
|
|
36
|
-
.option("--
|
|
38
|
+
.option("--size-gt <size>", "文件大小下限(支持 B / KB / MB / GB)")
|
|
39
|
+
.option("--size-lt <size>", "文件大小上限(支持 B / KB / MB / GB)")
|
|
40
|
+
.option("--uploaded-since <time>", "上传时间下限(晚于该时间),支持:相对时间 1h / 2d / 1w;日期 YYYY-MM-DD;ISO 8601 如 2026-04-01T10:00:00Z")
|
|
41
|
+
.option("--uploaded-until <time>", "上传时间上限(早于该时间),支持:相对时间 1h / 2d / 1w;日期 YYYY-MM-DD;ISO 8601 如 2026-04-01T10:00:00Z")
|
|
42
|
+
.option("--limit <n>", "单次返回上限(正整数,默认 50)", parsePositiveInt, 50)
|
|
37
43
|
.option("--cursor <token>", "分页游标,从上次响应的 next_cursor 取值")
|
|
38
44
|
.option("--all", "自动翻页返回全部结果")
|
|
39
45
|
.action(async (query, opts) => {
|
|
40
46
|
await (0, index_1.handleFileLs)({ ...opts, query });
|
|
41
|
-
})
|
|
47
|
+
})
|
|
48
|
+
.addHelpText("after", `
|
|
49
|
+
Examples:
|
|
50
|
+
$ miaoda file ls
|
|
51
|
+
file_name path size type uploaded_at
|
|
52
|
+
logo.png /images/brand/1858537546760216.png 24 KB image/png 3h ago
|
|
53
|
+
hero.jpg /images/1858537546760217.jpg 128 KB image/jpeg 2d ago
|
|
54
|
+
report.pdf /docs/1858537546760218.pdf 2.1 MB application/pdf 2026-04-10
|
|
55
|
+
|
|
56
|
+
$ miaoda file ls --name logo.png
|
|
57
|
+
file_name path size type uploaded_at
|
|
58
|
+
logo.png /images/brand/1858537546760216.png 24 KB image/png 3h ago
|
|
59
|
+
|
|
60
|
+
$ miaoda file ls --uploaded-since 7d --size-gt 1MB
|
|
61
|
+
...
|
|
62
|
+
`);
|
|
42
63
|
fileCmd
|
|
43
64
|
.command("stat")
|
|
44
|
-
.
|
|
45
|
-
.
|
|
65
|
+
.summary("查看单文件元数据")
|
|
66
|
+
.description("查看单文件完整元数据,含 download_url(应用内消费)。\n" +
|
|
67
|
+
"需要公网可访问的临时链接请用 `file sign` 生成 signed_url。")
|
|
68
|
+
.usage("<file> [flags]")
|
|
69
|
+
.argument("<file>", "文件的路径或文件名(自动识别)")
|
|
46
70
|
.action(async (file, opts) => {
|
|
47
71
|
await (0, index_1.handleFileStat)(file, opts);
|
|
48
|
-
})
|
|
72
|
+
})
|
|
73
|
+
.addHelpText("after", `
|
|
74
|
+
Notes:
|
|
75
|
+
- <file> 可传 path(推荐,全局唯一)或 file_name。
|
|
76
|
+
- file_name 重名时报 AMBIGUOUS_FILE_NAME,需先 \`ls --name\` 拿到 path 再调用。
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
$ miaoda file stat /images/brand/1858537546760216.png
|
|
80
|
+
file_name: logo.png
|
|
81
|
+
path: /images/brand/1858537546760216.png
|
|
82
|
+
size: 24 KB (24580 bytes)
|
|
83
|
+
type: image/png
|
|
84
|
+
uploaded_by: alice
|
|
85
|
+
uploaded_at: 2026-04-15 10:30:00
|
|
86
|
+
download_url: /spark/app/.../1858537546760216.png
|
|
87
|
+
|
|
88
|
+
# 报错:file_name 多匹配
|
|
89
|
+
$ miaoda file stat logo.png
|
|
90
|
+
Error: Multiple files match name 'logo.png' (2 found)
|
|
91
|
+
hint: Use path instead. Run \`miaoda file ls --name logo.png\` to see candidates.
|
|
92
|
+
`);
|
|
49
93
|
fileCmd
|
|
50
94
|
.command("cp")
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
|
|
95
|
+
.summary("上传或下载文件(方向由路径前缀自动判断)")
|
|
96
|
+
.description("上传或下载文件,方向由 src/dst 路径前缀自动判断:\n" +
|
|
97
|
+
" / 开头 → 远程 TOS 路径\n" +
|
|
98
|
+
" ./、~/、裸文件名 → 本地路径")
|
|
99
|
+
.usage("<src> <dst> [flags]")
|
|
100
|
+
.argument("<src>", "源:本地文件路径或远程文件路径 / 文件名")
|
|
101
|
+
.argument("<dst>", "目标:本地路径或远程路径")
|
|
54
102
|
.option("--rename <name>", "上传后在远端使用的新文件名")
|
|
55
103
|
.action(async (src, dst, opts) => {
|
|
56
104
|
await (0, index_1.handleFileCp)(src, dst, opts);
|
|
57
|
-
})
|
|
105
|
+
})
|
|
106
|
+
.addHelpText("after", `
|
|
107
|
+
Notes:
|
|
108
|
+
- 单文件上限 100 MB,超过请拆分或用 web console 上传。
|
|
109
|
+
- 同目录下允许同 file_name 并存(每次上传生成新的 path),不会因重名失败。
|
|
110
|
+
- 下载时 src 必须是完整 path(裸 file_name 会被识别为本地路径)。
|
|
111
|
+
- 上传成功返回 download_url,可写入数据库 / 应用代码作为永久引用。
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
# 上传:本地 → 远程
|
|
115
|
+
$ miaoda file cp ./logo.png /images/brand/
|
|
116
|
+
✓ Uploaded logo.png → /images/brand/1858537546760216.png
|
|
117
|
+
file_name: logo.png
|
|
118
|
+
path: /images/brand/1858537546760216.png
|
|
119
|
+
size: 24 KB (24580 bytes)
|
|
120
|
+
type: image/png
|
|
121
|
+
download_url: /spark/app/.../1858537546760216.png
|
|
122
|
+
|
|
123
|
+
# 下载:远程 → 本地
|
|
124
|
+
$ miaoda file cp /images/brand/1858537546760216.png ~/Desktop/
|
|
125
|
+
✓ Downloaded 1858537546760216.png → ~/Desktop/logo.png (24 KB)
|
|
126
|
+
|
|
127
|
+
# 上传时改名
|
|
128
|
+
$ miaoda file cp ./photo.png /images/ --rename avatar.png
|
|
129
|
+
✓ Uploaded photo.png → /images/1858537546760301.png
|
|
130
|
+
|
|
131
|
+
# 报错:本地源文件不存在
|
|
132
|
+
$ miaoda file cp ./missing.png /images/
|
|
133
|
+
Error: Local file './missing.png' does not exist
|
|
134
|
+
|
|
135
|
+
# 报错:超过上传上限
|
|
136
|
+
$ miaoda file cp ./video.mp4 /videos/
|
|
137
|
+
Error: File size 230 MB exceeds the 100 MB upload limit
|
|
138
|
+
hint: Split the file, or use the web console for large uploads.
|
|
139
|
+
`);
|
|
58
140
|
fileCmd
|
|
59
141
|
.command("rm")
|
|
60
|
-
.
|
|
142
|
+
.summary("删除一个或多个文件")
|
|
143
|
+
.description("删除一个或多个文件。<file> 可传 path(推荐)或 file_name,可混用。\n" +
|
|
144
|
+
"操作不可撤销(不进回收站),TTY 下默认会要求二次确认。")
|
|
145
|
+
.usage("[paths...] [flags]")
|
|
61
146
|
.argument("[paths...]", "要删除的文件,每项可填路径或文件名(自动识别)")
|
|
62
|
-
.option("-n, --name <name>", "
|
|
63
|
-
.option("-y, --yes", "
|
|
147
|
+
.option("-n, --name <name>", "按文件名删除(可重复指定)", (value, prev) => [...(prev ?? []), value])
|
|
148
|
+
.option("-y, --yes", "跳过交互确认;非交互场景必加")
|
|
64
149
|
.action(async (paths, opts) => {
|
|
65
150
|
await (0, index_1.handleFileRm)(paths, opts);
|
|
66
|
-
})
|
|
151
|
+
})
|
|
152
|
+
.addHelpText("after", `
|
|
153
|
+
Notes:
|
|
154
|
+
- 删除不可撤销,没有回收站。
|
|
155
|
+
- 批量场景下失败项不影响其他项;全部成功退出码 0,任意一项失败退出码 1。
|
|
156
|
+
- --json 输出每项保留输入顺序,含 status: "ok" | "error" 字段。
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
# 单文件删除(TTY 下需确认)
|
|
160
|
+
$ miaoda file rm /images/brand/1858537546760216.png
|
|
161
|
+
? Delete '/images/brand/1858537546760216.png'? (y/N) y
|
|
162
|
+
✓ Deleted /images/brand/1858537546760216.png
|
|
163
|
+
|
|
164
|
+
# 批量删除(混用 path 与 file_name)
|
|
165
|
+
$ miaoda file rm /images/A.png /images/B.png /docs/C.pdf --yes
|
|
166
|
+
✓ Deleted /images/A.png
|
|
167
|
+
✓ Deleted /images/B.png
|
|
168
|
+
✓ Deleted /docs/C.pdf
|
|
169
|
+
Deleted 3 of 3 files
|
|
170
|
+
|
|
171
|
+
# 部分失败(其他项仍会被删除)
|
|
172
|
+
$ miaoda file rm logo.png missing.png report.pdf --yes
|
|
173
|
+
✓ Deleted logo.png
|
|
174
|
+
✗ missing.png: File does not exist
|
|
175
|
+
✓ Deleted report.pdf
|
|
176
|
+
Deleted 2 of 3 files (1 failed)
|
|
177
|
+
|
|
178
|
+
# 报错:file_name 多匹配
|
|
179
|
+
$ miaoda file rm logo.png --yes
|
|
180
|
+
Error: Multiple files match name 'logo.png' (2 found)
|
|
181
|
+
hint: Use path instead. Run \`miaoda file ls --name logo.png\` to see candidates.
|
|
182
|
+
`);
|
|
67
183
|
fileCmd
|
|
68
184
|
.command("sign")
|
|
69
|
-
.
|
|
185
|
+
.summary("生成公网可访问的临时下载链接")
|
|
186
|
+
.description("生成 signed_url——公网可直接访问的临时下载链接,带过期时间。\n" +
|
|
187
|
+
"仅在需要浏览器打开 / 公网分享时使用;应用代码内引用文件请用 download_url\n" +
|
|
188
|
+
"(来自 cp 或 stat),无需 sign。")
|
|
189
|
+
.usage("<file> [flags]")
|
|
70
190
|
.argument("<file>", "文件的路径或文件名")
|
|
71
|
-
.option("--expires <duration>", "链接有效期,支持 30m / 24h / 7d
|
|
191
|
+
.option("--expires <duration>", "链接有效期,支持 30m / 24h / 7d 等单位(默认 1d,最长 30d)")
|
|
72
192
|
.action(async (file, opts) => {
|
|
73
193
|
await (0, index_1.handleFileSign)(file, opts);
|
|
74
|
-
})
|
|
194
|
+
})
|
|
195
|
+
.addHelpText("after", `
|
|
196
|
+
Notes:
|
|
197
|
+
- <file> 可传 path(推荐)或 file_name;重名报 AMBIGUOUS_FILE_NAME。
|
|
198
|
+
- signed_url 会过期,不要持久化到数据库。永久引用请用 download_url。
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
$ miaoda file sign /images/brand/1858537546760216.png
|
|
202
|
+
https://miaoda.feishu.cn/storage/.../1858537546760216.png?token=xxx&expires=86400
|
|
203
|
+
|
|
204
|
+
$ miaoda file sign /images/brand/1858537546760216.png --expires 30d
|
|
205
|
+
https://miaoda.feishu.cn/storage/.../1858537546760216.png?token=xxx&expires=2592000
|
|
206
|
+
|
|
207
|
+
# 报错:过期时间超过上限
|
|
208
|
+
$ miaoda file sign /images/brand/1858537546760216.png --expires 60d
|
|
209
|
+
Error: Expires duration '60d' exceeds the maximum of 30d
|
|
210
|
+
hint: Maximum allowed value is 30d. Use \`--expires 30d\` for the longest link.
|
|
211
|
+
`);
|
|
75
212
|
}
|
|
@@ -5,7 +5,8 @@ const index_1 = require("../../../cli/handlers/plugin/index");
|
|
|
5
5
|
function registerPluginCommands(program) {
|
|
6
6
|
const pluginCmd = program
|
|
7
7
|
.command("plugin")
|
|
8
|
-
.description("插件管理:安装/更新/移除插件包,查询 capability 实例")
|
|
8
|
+
.description("插件管理:安装/更新/移除插件包,查询 capability 实例")
|
|
9
|
+
.usage("<command> [flags]");
|
|
9
10
|
pluginCmd.action(() => {
|
|
10
11
|
pluginCmd.outputHelp();
|
|
11
12
|
});
|
|
@@ -135,12 +135,15 @@ function resolveFormat(explicit, ext, scope, fallback) {
|
|
|
135
135
|
return "csv";
|
|
136
136
|
if (raw === "json")
|
|
137
137
|
return "json";
|
|
138
|
+
// sql 仅 export 路径接受 —— import 端后端仍只支持 csv/json。
|
|
139
|
+
if (raw === "sql" && scope === "export")
|
|
140
|
+
return "sql";
|
|
138
141
|
const code = scope === "import" ? "IMPORT_FORMAT_UNSUPPORTED" : "EXPORT_FORMAT_UNSUPPORTED";
|
|
139
142
|
throw new error_1.AppError(code, `Unrecognized format '${raw || "(unspecified)"}'`, {
|
|
140
143
|
next_actions: [
|
|
141
144
|
scope === "import"
|
|
142
145
|
? "Supported formats: .csv, .json. Convert the file first, or rename with the correct extension."
|
|
143
|
-
: "Supported formats: csv, json. Pass --format csv|json.",
|
|
146
|
+
: "Supported formats: csv, json, sql. Pass --format csv|json|sql.",
|
|
144
147
|
],
|
|
145
148
|
});
|
|
146
149
|
}
|
|
@@ -32,15 +32,22 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
exports.handleDbSql = handleDbSql;
|
|
37
40
|
const api = __importStar(require("../../../api/index"));
|
|
38
41
|
const error_1 = require("../../../utils/error");
|
|
39
42
|
const output_1 = require("../../../utils/output");
|
|
40
43
|
const config_1 = require("../../../utils/config");
|
|
44
|
+
const logger_1 = require("../../../utils/logger");
|
|
41
45
|
const shared_1 = require("../../../cli/commands/shared");
|
|
42
46
|
const render_1 = require("../../../utils/render");
|
|
43
47
|
const index_1 = require("../../../api/db/index");
|
|
48
|
+
const node_child_process_1 = require("node:child_process");
|
|
49
|
+
const node_fs_1 = require("node:fs");
|
|
50
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
44
51
|
/**
|
|
45
52
|
* miaoda db sql <query> — 执行任意 SQL。
|
|
46
53
|
*
|
|
@@ -69,14 +76,122 @@ async function handleDbSql(query, opts) {
|
|
|
69
76
|
}
|
|
70
77
|
if (results.length === 1) {
|
|
71
78
|
renderSingle(results[0]);
|
|
79
|
+
await maybeSyncAgentSchema(results);
|
|
72
80
|
return;
|
|
73
81
|
}
|
|
74
82
|
// 多语句:每条 statement 独立结果
|
|
75
83
|
if ((0, output_1.isJsonMode)()) {
|
|
76
84
|
(0, output_1.emit)({ data: results.map((r) => toMultiElement((0, index_1.parseSqlResult)(r))) });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
renderMultiPretty(results);
|
|
88
|
+
}
|
|
89
|
+
await maybeSyncAgentSchema(results);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 当本次 SQL 包含成功执行的 DDL 时,触发 `npm run gen:db-schema`(实质执行
|
|
93
|
+
* fullstack-cli gen-db-schema → drizzle-kit introspect)同步 agent 项目的
|
|
94
|
+
* schema.ts。**弱依赖**:失败不阻塞主流程退出码。
|
|
95
|
+
*
|
|
96
|
+
* 行为细节:
|
|
97
|
+
* - 仅当 cwd/package.json 定义了 gen:db-schema 脚本时才尝试运行;非 agent
|
|
98
|
+
* 项目静默跳过
|
|
99
|
+
* - 默认静默捕获子进程 stdout/stderr,成功只打一行 "Agent schema synced",
|
|
100
|
+
* 失败时把捕获的输出回放给用户排查;--verbose 实时透传方便看进度
|
|
101
|
+
* - 加 60s 兜底超时:drizzle-kit introspect 通常 < 30s,超时直接 SIGKILL,
|
|
102
|
+
* 避免 DB 慢或网络抖动时把 CLI 挂死
|
|
103
|
+
* - 子进程的 stdout / stderr 都不会进入当前 CLI 的 stdout,--json 输出干净
|
|
104
|
+
*
|
|
105
|
+
* 调用时机:在 SQL 主输出 emit 完成之后;execSql 已经返回意味着所有
|
|
106
|
+
* statement 都执行成功,无需再校验逐条状态。
|
|
107
|
+
*/
|
|
108
|
+
/** agent runtime 中固定的项目根目录;放在此处便于以后调整。 */
|
|
109
|
+
const AGENT_PROJECT_ROOT = "/home/gem/workspace/code";
|
|
110
|
+
/** drain 子进程 pipe stream:默认静默模式下消费 chunk 防止缓冲区反压。 */
|
|
111
|
+
function drainChunk(_chunk) {
|
|
112
|
+
// 显式接 chunk 参数让 ESLint 不再当成 empty function;不做任何处理
|
|
113
|
+
}
|
|
114
|
+
async function maybeSyncAgentSchema(results) {
|
|
115
|
+
if (!hasDdl(results))
|
|
116
|
+
return;
|
|
117
|
+
const projectRoot = await resolveAgentProjectRoot();
|
|
118
|
+
if (!projectRoot) {
|
|
119
|
+
(0, logger_1.debug)("[db sql] agent project root not found or missing gen:db-schema script, skip schema sync");
|
|
77
120
|
return;
|
|
78
121
|
}
|
|
79
|
-
|
|
122
|
+
const verbose = (0, config_1.getConfig)().verbose;
|
|
123
|
+
// drizzle-kit introspect 通常 < 30s;60s 兜底,超过即认为卡住
|
|
124
|
+
const TIMEOUT_MS = 60_000;
|
|
125
|
+
(0, logger_1.debug)(`[db sql] DDL detected, running \`npm run gen:db-schema\` in ${projectRoot}`);
|
|
126
|
+
try {
|
|
127
|
+
await new Promise((resolve) => {
|
|
128
|
+
const proc = (0, node_child_process_1.spawn)("npm", ["run", "gen:db-schema"], {
|
|
129
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
130
|
+
cwd: projectRoot,
|
|
131
|
+
});
|
|
132
|
+
// --verbose:实时透传到 stderr 看进度;默认完全静默丢弃。
|
|
133
|
+
// 任何路径下子进程输出都不会进入当前 CLI 的 stdout,--json 模式安全。
|
|
134
|
+
// 注:默认路径必须 attach 监听器,否则 pipe 缓冲区写满会反压子进程。
|
|
135
|
+
if (verbose) {
|
|
136
|
+
proc.stdout.pipe(process.stderr);
|
|
137
|
+
proc.stderr.pipe(process.stderr);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
proc.stdout.on("data", drainChunk);
|
|
141
|
+
proc.stderr.on("data", drainChunk);
|
|
142
|
+
}
|
|
143
|
+
let timedOut = false;
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
timedOut = true;
|
|
146
|
+
proc.kill("SIGKILL");
|
|
147
|
+
}, TIMEOUT_MS);
|
|
148
|
+
proc.on("close", (code) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
if (timedOut) {
|
|
151
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema timed out after ${String(TIMEOUT_MS / 1000)}s, killed`);
|
|
152
|
+
}
|
|
153
|
+
else if (code !== 0 && code !== null) {
|
|
154
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema exited with code ${String(code)}`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
(0, logger_1.debug)("[db sql] gen:db-schema completed");
|
|
158
|
+
}
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
proc.on("error", (err) => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema spawn error: ${err.message}`);
|
|
164
|
+
resolve();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
(0, logger_1.debug)(`[db sql] gen:db-schema unexpected error: ${String(err)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 校验 AGENT_PROJECT_ROOT 是否是一个有效的 agent 项目(package.json 含
|
|
174
|
+
* `gen:db-schema` 脚本)。命中返回路径;缺失任一条件返 null,让调用方静默跳过。
|
|
175
|
+
*
|
|
176
|
+
* 这里不再从 process.cwd() 向上查找:agent runtime 里项目根是固定路径,
|
|
177
|
+
* 在 CLI 之外(本地手动跑 miaoda db sql)也不应触发同步。
|
|
178
|
+
*/
|
|
179
|
+
async function resolveAgentProjectRoot() {
|
|
180
|
+
try {
|
|
181
|
+
const pkgPath = node_path_1.default.join(AGENT_PROJECT_ROOT, "package.json");
|
|
182
|
+
const raw = await node_fs_1.promises.readFile(pkgPath, "utf8");
|
|
183
|
+
const pkg = JSON.parse(raw);
|
|
184
|
+
if (pkg.scripts?.["gen:db-schema"])
|
|
185
|
+
return AGENT_PROJECT_ROOT;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// 路径不存在 / 不可读 / 不是 agent 项目 → 静默跳过
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/** 是否有任意一条 statement 是 DDL(CREATE / ALTER / DROP / GRANT / TRUNCATE / COMMENT 等)。 */
|
|
193
|
+
function hasDdl(results) {
|
|
194
|
+
return results.some((r) => (0, index_1.parseSqlResult)(r).kind === "ddl");
|
|
80
195
|
}
|
|
81
196
|
/** 读取 stdin 并返回完整 SQL 文本(stdin 不是 TTY 即认为被 pipe)。 */
|
|
82
197
|
async function readSql(inline) {
|
package/dist/cli/help.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MiaodaHelp = void 0;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
/**
|
|
6
|
+
* MiaodaHelp 重写 commander 默认的 --help 输出,使之对齐 CLI 文档规范:
|
|
7
|
+
*
|
|
8
|
+
* 1. 描述放在最前(commander 默认是 Usage 在前、描述在后)
|
|
9
|
+
* 2. "Options:" 重命名为 "Flags:","Global Options:" 重命名为 "Global Flags:"
|
|
10
|
+
* 3. Usage 段独占一行 Heading + 缩进展示 usage 行
|
|
11
|
+
* 4. 段落顺序:描述 → Usage → Arguments → Commands → Flags → Global Flags
|
|
12
|
+
* → Notes(addHelpText) → Examples(addHelpText)
|
|
13
|
+
* (父命令不带 Examples,由 formatHelp 末尾自动追加 "Use ... --help" 提示)
|
|
14
|
+
* 5. Root 命令的 local options 视作 Global Flags(root 无 command-specific flag)
|
|
15
|
+
* 6. 子命令隐藏 -v / --version(仅 root 暴露)和自动生成的 `help` 子命令
|
|
16
|
+
*
|
|
17
|
+
* Notes / Examples 段由各命令通过 addHelpText('after', ...) 自行追加,
|
|
18
|
+
* 本类不直接生成 —— 框架与文案分层。
|
|
19
|
+
*/
|
|
20
|
+
class MiaodaHelp extends commander_1.Help {
|
|
21
|
+
// 全局默认开启:所有子命令 --help 都展示 Global Flags 段
|
|
22
|
+
showGlobalOptions = true;
|
|
23
|
+
/**
|
|
24
|
+
* 父级 --help 的 Commands 列表里展示子命令调用形态。spec 要求只展示
|
|
25
|
+
* `name <args>` 不带 `[flags]` 尾巴:
|
|
26
|
+
*
|
|
27
|
+
* - 子命令是分组(含下级 subcommand)→ 只显示 name
|
|
28
|
+
* - 子命令是 leaf 且配置了 usage() → "name <args>"(usage 末尾的 [flags] 去掉)
|
|
29
|
+
* - leaf 没配置 usage → 退回 commander 默认行为
|
|
30
|
+
*/
|
|
31
|
+
subcommandTerm(cmd) {
|
|
32
|
+
if (cmd.commands.length > 0) {
|
|
33
|
+
return cmd.name();
|
|
34
|
+
}
|
|
35
|
+
const usage = cmd.usage();
|
|
36
|
+
if (usage) {
|
|
37
|
+
// 去掉末尾的 [flags] / [options],对齐 spec 的 "name <args>" 形态
|
|
38
|
+
const argsOnly = usage.replace(/\s*\[(?:flags|options)\]\s*$/i, "").trim();
|
|
39
|
+
return argsOnly ? `${cmd.name()} ${argsOnly}` : cmd.name();
|
|
40
|
+
}
|
|
41
|
+
return super.subcommandTerm(cmd);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 父命令的 Commands 列表里:
|
|
45
|
+
* - 优先用子命令的 .summary()(短摘要,对齐 spec 父级列表用的简短描述)
|
|
46
|
+
* - 否则取 description 首行,避免多行 description 把列表撑乱
|
|
47
|
+
* 叶子命令自身 --help 仍展示完整 description。
|
|
48
|
+
*/
|
|
49
|
+
subcommandDescription(cmd) {
|
|
50
|
+
const summary = cmd.summary();
|
|
51
|
+
if (summary)
|
|
52
|
+
return summary;
|
|
53
|
+
const desc = super.subcommandDescription(cmd);
|
|
54
|
+
const idx = desc.indexOf("\n");
|
|
55
|
+
return idx === -1 ? desc : desc.slice(0, idx);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Flags 段里去掉那些已经在父命令注册过的选项(避免在 Flags 与 Global Flags
|
|
59
|
+
* 里重复展示,例如 --env 在 db 父级注册后,leaf 即使本地也注册一份也只在
|
|
60
|
+
* Global Flags 里出现一次)。
|
|
61
|
+
*/
|
|
62
|
+
visibleOptions(cmd) {
|
|
63
|
+
const opts = super.visibleOptions(cmd);
|
|
64
|
+
if (!cmd.parent)
|
|
65
|
+
return opts;
|
|
66
|
+
const parentLongs = new Set();
|
|
67
|
+
let p = cmd.parent;
|
|
68
|
+
while (p) {
|
|
69
|
+
for (const o of p.options) {
|
|
70
|
+
if (o.long)
|
|
71
|
+
parentLongs.add(o.long);
|
|
72
|
+
}
|
|
73
|
+
p = p.parent;
|
|
74
|
+
}
|
|
75
|
+
return opts.filter((o) => !o.long || !parentLongs.has(o.long));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 子命令 --help 默认会从父级继承 -v, --version;spec 只在 root 列这条。
|
|
79
|
+
* 非 root 命令把 --version 从 Global Flags 列表里过滤掉。
|
|
80
|
+
*/
|
|
81
|
+
visibleGlobalOptions(cmd) {
|
|
82
|
+
const opts = super.visibleGlobalOptions(cmd);
|
|
83
|
+
if (cmd.parent) {
|
|
84
|
+
return opts.filter((o) => o.long !== "--version" && o.short !== "-v");
|
|
85
|
+
}
|
|
86
|
+
return opts;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Root 命令默认会列 commander 自动生成的 `help [command]` 子命令;
|
|
90
|
+
* spec 不展示这一条,过滤掉。
|
|
91
|
+
*/
|
|
92
|
+
visibleCommands(cmd) {
|
|
93
|
+
return super.visibleCommands(cmd).filter((c) => c.name() !== "help");
|
|
94
|
+
}
|
|
95
|
+
formatHelp(cmd, helper) {
|
|
96
|
+
const isRoot = cmd.parent == null;
|
|
97
|
+
const termWidth = helper.padWidth(cmd, helper);
|
|
98
|
+
const helpWidth = helper.helpWidth ?? 80;
|
|
99
|
+
const formatItem = (term, description) => {
|
|
100
|
+
if (description) {
|
|
101
|
+
const padding = " ".repeat(Math.max(termWidth - term.length, 0) + 2);
|
|
102
|
+
return `${term}${padding}${description}`;
|
|
103
|
+
}
|
|
104
|
+
return term;
|
|
105
|
+
};
|
|
106
|
+
const formatList = (lines) => lines.map((l) => " " + l).join("\n");
|
|
107
|
+
void helpWidth; // 保留以备后续按宽度自动 wrap,当前直接透传 description
|
|
108
|
+
const out = [];
|
|
109
|
+
// 1. 描述
|
|
110
|
+
const desc = helper.commandDescription(cmd);
|
|
111
|
+
if (desc) {
|
|
112
|
+
out.push(desc, "");
|
|
113
|
+
}
|
|
114
|
+
// 2. Usage:独立 heading + 缩进
|
|
115
|
+
out.push("Usage:", ` ${helper.commandUsage(cmd)}`, "");
|
|
116
|
+
// 3. Commands(仅父级命令组有,spec 要求 Commands 在 Flags 前)
|
|
117
|
+
// spec 不展示 Arguments 段,参数说明放在 description 文本里
|
|
118
|
+
const subs = helper.visibleCommands(cmd).map((c) => formatItem(helper.subcommandTerm(c), helper.subcommandDescription(c)));
|
|
119
|
+
if (subs.length) {
|
|
120
|
+
out.push("Commands:", formatList(subs), "");
|
|
121
|
+
}
|
|
122
|
+
// 5. Flags(叶子命令专属 options)
|
|
123
|
+
// - Root / 父命令组:local options 都视作"会被子命令继承的 globals",渲染到 Global Flags 段
|
|
124
|
+
// - 叶子命令(无子命令):local options 渲染到 Flags 段(如 db data export 的 --format)
|
|
125
|
+
// - `-h, --help` 永远不放 Flags 段,统一放 Global Flags(spec 约定)
|
|
126
|
+
const isParent = subs.length > 0;
|
|
127
|
+
if (!isRoot && !isParent) {
|
|
128
|
+
const opts = helper.visibleOptions(cmd)
|
|
129
|
+
.filter((o) => !isHelpOption(o))
|
|
130
|
+
.map((o) => formatItem(helper.optionTerm(o), helper.optionDescription(o)));
|
|
131
|
+
if (opts.length) {
|
|
132
|
+
out.push("Flags:", formatList(opts), "");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// 6. Global Flags
|
|
136
|
+
// - Root:local options 当 globals 渲染(root 自己就是 global 来源)
|
|
137
|
+
// - 父命令组:继承自祖先的 globals + 自己的 local options(也会被子命令继承)
|
|
138
|
+
// - 叶子命令:继承自祖先的 globals + 当前的 -h, --help
|
|
139
|
+
// - 末尾确保 -h, --help 一条
|
|
140
|
+
const localOpts = helper.visibleOptions(cmd);
|
|
141
|
+
let globals = [];
|
|
142
|
+
if (isRoot) {
|
|
143
|
+
globals = localOpts;
|
|
144
|
+
}
|
|
145
|
+
else if (isParent) {
|
|
146
|
+
const inherited = helper.visibleGlobalOptions(cmd);
|
|
147
|
+
const localNonHelp = localOpts.filter((o) => !isHelpOption(o));
|
|
148
|
+
const helpOpt = localOpts.find(isHelpOption);
|
|
149
|
+
globals = [...inherited, ...localNonHelp];
|
|
150
|
+
if (helpOpt && !globals.includes(helpOpt))
|
|
151
|
+
globals.push(helpOpt);
|
|
152
|
+
}
|
|
153
|
+
else if (this.showGlobalOptions) {
|
|
154
|
+
const inherited = helper.visibleGlobalOptions(cmd);
|
|
155
|
+
const helpOpt = localOpts.find(isHelpOption);
|
|
156
|
+
globals = [...inherited];
|
|
157
|
+
if (helpOpt && !globals.includes(helpOpt))
|
|
158
|
+
globals.push(helpOpt);
|
|
159
|
+
}
|
|
160
|
+
if (globals.length) {
|
|
161
|
+
const lines = globals.map((o) => formatItem(helper.optionTerm(o), helper.optionDescription(o)));
|
|
162
|
+
out.push("Global Flags:", formatList(lines), "");
|
|
163
|
+
}
|
|
164
|
+
// 7. 父命令底部追加 "Use <cmd> <subcommand> --help" 提示,对齐 spec
|
|
165
|
+
if (subs.length > 0) {
|
|
166
|
+
const path = cmd.name() === "miaoda" ? "miaoda" : commandPath(cmd);
|
|
167
|
+
out.push(`Use "${path} <command> --help" for more information about a command.`, "");
|
|
168
|
+
}
|
|
169
|
+
// 保留末尾换行:commander 用 join('\n') 拼 addHelpText('after') 段,
|
|
170
|
+
// 这里多留一个 \n,让 Notes / Examples 段与上面段落之间空一行。
|
|
171
|
+
return out.join("\n").replace(/\n+$/, "\n");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
exports.MiaodaHelp = MiaodaHelp;
|
|
175
|
+
/** 判断是否为 -h / --help 选项。 */
|
|
176
|
+
function isHelpOption(o) {
|
|
177
|
+
return o.long === "--help" || o.short === "-h";
|
|
178
|
+
}
|
|
179
|
+
/** 拼接命令完整路径,例如 db schema -> "miaoda db schema"。 */
|
|
180
|
+
function commandPath(cmd) {
|
|
181
|
+
const names = [];
|
|
182
|
+
let cur = cmd;
|
|
183
|
+
while (cur) {
|
|
184
|
+
names.unshift(cur.name());
|
|
185
|
+
cur = cur.parent;
|
|
186
|
+
}
|
|
187
|
+
return names.join(" ");
|
|
188
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -5,15 +5,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const commander_1 = require("commander");
|
|
7
7
|
const index_1 = require("./cli/commands/index");
|
|
8
|
+
const help_1 = require("./cli/help");
|
|
8
9
|
const config_1 = require("./utils/config");
|
|
9
10
|
const log_id_1 = require("./utils/log_id");
|
|
10
11
|
const output_1 = require("./utils/output");
|
|
11
12
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
13
|
+
// MiaodaHelp 对齐 CLI 规范(描述置顶 / Flags / Global Flags / 段顺序):
|
|
14
|
+
// 在 Command.prototype 上 patch createHelp,让所有命令实例(含动态注册的
|
|
15
|
+
// 子命令)统一走 MiaodaHelp 渲染,避免在每个子命令上重复 configureHelp。
|
|
16
|
+
commander_1.Command.prototype.createHelp = function () {
|
|
17
|
+
return Object.assign(new help_1.MiaodaHelp(), this.configureHelp());
|
|
18
|
+
};
|
|
12
19
|
const program = new commander_1.Command();
|
|
13
20
|
const { version } = package_json_1.default;
|
|
14
21
|
program
|
|
15
22
|
.name("miaoda")
|
|
16
|
-
.description("
|
|
23
|
+
.description("妙搭平台 CLI,提供数据服务、文件存储、插件管理等命令行操作。")
|
|
24
|
+
.usage("<command> [flags]")
|
|
17
25
|
.version(version, "-v, --version", "显示版本号")
|
|
18
26
|
.option("--json [fields]", "JSON 输出,可选字段级选择")
|
|
19
27
|
.option("--output <format>", "输出格式(pretty|json)", "pretty")
|