@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.
@@ -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
- const response = await client.post(url, { sql: opts.sql });
29
- (0, client_1.traceHttp)("POST", url, start, response);
30
- if (!response.ok) {
31
- // 4xx / 5xx:尝试解析 body 取 status_code 映射业务错误
32
- let body = null;
33
- try {
34
- body = (await response.json());
35
- }
36
- catch {
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
- const response = await client.get(url);
64
- (0, client_1.traceHttp)("GET", url, start, response);
65
- if (!response.ok) {
66
- let body = null;
67
- try {
68
- body = (await response.json());
69
- }
70
- catch {
71
- // ignore
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
- const response = await client.post(url, reqBody);
105
- (0, client_1.traceHttp)("POST", url, start, response);
106
- if (!response.ok) {
107
- let body = null;
108
- try {
109
- body = (await response.json());
110
- }
111
- catch {
112
- // ignore
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
- const response = await client.request({ method: "POST", url });
148
- (0, client_1.traceHttp)("POST", url, start, response);
149
- if (!response.ok) {
150
- // 错误路径:body JSON envelope
151
- let body = null;
152
- try {
153
- body = (await response.json());
154
- }
155
- catch {
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"),需要在这里嗅探兜底。
@@ -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 = Date.parse(opts.uploadedSince);
86
- if (!Number.isNaN(ms)) {
87
- conds.push({ field: "createdAt", operator: "gte", value: String(ms) });
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
  *
@@ -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("数据库操作:执行 SQL、查看表结构、导入导出数据");
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
- .description("执行 SQL 语句")
15
- .argument("[query]", "要执行的 SQL 语句")
16
- .option("--env <env>", "目标环境(main / dev)")
17
- .action(async (query, opts) => {
18
- await (0, index_1.handleDbSql)(query, opts);
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
- .description("查看表结构");
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
- .description("列出当前应用的所有表(含行数估算、占用大小、列数)")
30
- .option("--env <env>", "目标环境(main / dev)")
31
- .action(async (opts) => {
32
- await (0, index_1.handleDbSchemaList)(opts);
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
- .description("查看单张表的字段、索引与建表语句")
37
- .argument("<table>", "表名")
38
- .option("--ddl", "输出完整建表语句而非概要")
39
- .option("--env <env>", "目标环境(main / dev)")
40
- .action(async (table, opts) => {
41
- await (0, index_1.handleDbSchemaGet)(table, opts);
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
- .description("批量导入与导出数据");
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
- .description("把本地 CSV / JSON 文件导入到表(单次最多 5000 行 / 1 MB,全部成功或全部回滚)")
53
- .argument("<file>", "本地文件路径")
54
- .option("--table <name>", "目标表名(缺省按文件名推断)")
55
- .option("--format <fmt>", "文件格式 csv / json(缺省按扩展名推断)")
56
- .action(async (file, opts) => {
57
- await (0, index_1.handleDbDataImport)(file, opts);
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
- .description("把整张表导出为 CSV / JSON 文件(单次最多 5000 行 / 1 MB)")
62
- .argument("<table>", "表名")
63
- .option("--format <fmt>", "导出格式 csv / json(默认 csv)")
64
- .option("-f, --file <path>", "输出文件路径(默认 <table>.<format>)")
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, opts) => {
67
- await (0, index_1.handleDbDataExport)(table, opts);
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
- .description("列出应用下的文件")
29
- .argument("[query]", "可选筛选值:以 / 开头视为路径(精确匹配),否则按文件名匹配")
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>", "上传时间下限(ISO 8601")
36
- .option("--limit <n>", "单次返回上限(正整数,默认 50)", parsePositiveInt, 50)
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
- .description("查看文件的元数据(不含下载链接,需要链接请用 sign)")
45
- .argument("<file>", "文件的路径或文件名")
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
- .description("上传或下载文件(按 src/dst 自动判断方向)")
52
- .argument("<src>", "源:本地文件路径 或 远程文件路径/名")
53
- .argument("<dst>", "目标:本地路径 远程路径")
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
- .description("批量删除文件(单次最多 100 个,部分失败不影响其他)")
142
+ .summary("删除一个或多个文件")
143
+ .description("删除一个或多个文件。<file> 可传 path(推荐)或 file_name,可混用。\n" +
144
+ "操作不可撤销(不进回收站),TTY 下默认会要求二次确认。")
145
+ .usage("[paths...] [flags]")
61
146
  .argument("[paths...]", "要删除的文件,每项可填路径或文件名(自动识别)")
62
- .option("-n, --name <name>", "按文件名删除(可多次指定)", (value, prev) => [...(prev ?? []), value])
63
- .option("-y, --yes", "跳过交互确认(脚本 / agent 调用必加)")
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
- .description("生成可分享的临时下载链接")
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 单位(默认 7d,最长 30d)")
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
- renderMultiPretty(results);
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) {
@@ -82,6 +82,7 @@ async function handleFileLs(opts) {
82
82
  sizeGt,
83
83
  sizeLt,
84
84
  uploadedSince: opts.uploadedSince,
85
+ uploadedUntil: opts.uploadedUntil,
85
86
  });
86
87
  if ((0, output_1.isJsonMode)()) {
87
88
  (0, output_1.emitPaged)(result.items, result.next_cursor, result.has_more);
@@ -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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.0-alpha.ec1a658",
3
+ "version": "0.1.0-alpha.f914906",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {