@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.
Files changed (40) hide show
  1. package/dist/api/db/api.js +278 -0
  2. package/dist/api/db/client.js +169 -0
  3. package/dist/api/db/index.js +16 -0
  4. package/dist/api/db/parsers.js +174 -0
  5. package/dist/api/db/sql-keywords.js +52 -0
  6. package/dist/api/db/types.js +10 -0
  7. package/dist/api/file/api.js +509 -0
  8. package/dist/api/file/client.js +199 -0
  9. package/dist/api/file/detect.js +56 -0
  10. package/dist/api/file/index.js +18 -0
  11. package/dist/api/file/parsers.js +72 -0
  12. package/dist/api/file/types.js +3 -0
  13. package/dist/api/index.js +5 -1
  14. package/dist/api/plugin/api.js +3 -3
  15. package/dist/cli/commands/db/index.js +209 -0
  16. package/dist/cli/commands/file/index.js +212 -0
  17. package/dist/cli/commands/index.js +4 -0
  18. package/dist/cli/commands/plugin/index.js +2 -1
  19. package/dist/cli/commands/shared.js +7 -8
  20. package/dist/cli/handlers/db/data.js +183 -0
  21. package/dist/cli/handlers/db/index.js +11 -0
  22. package/dist/cli/handlers/db/schema.js +174 -0
  23. package/dist/cli/handlers/db/sql.js +647 -0
  24. package/dist/cli/handlers/file/cp.js +221 -0
  25. package/dist/cli/handlers/file/index.js +13 -0
  26. package/dist/cli/handlers/file/ls.js +111 -0
  27. package/dist/cli/handlers/file/rm.js +264 -0
  28. package/dist/cli/handlers/file/sign.js +96 -0
  29. package/dist/cli/handlers/file/stat.js +97 -0
  30. package/dist/cli/handlers/index.js +2 -0
  31. package/dist/cli/help.js +188 -0
  32. package/dist/main.js +9 -1
  33. package/dist/utils/colors.js +98 -0
  34. package/dist/utils/error.js +14 -0
  35. package/dist/utils/fuzzy-match.js +91 -0
  36. package/dist/utils/http.js +31 -10
  37. package/dist/utils/index.js +3 -1
  38. package/dist/utils/output.js +75 -9
  39. package/dist/utils/render.js +192 -0
  40. package/package.json +4 -3
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDefaultBucketId = getDefaultBucketId;
4
+ exports.resetBucketCache = resetBucketCache;
5
+ exports.ensureSuccess = ensureSuccess;
6
+ exports.doGet = doGet;
7
+ exports.doPost = doPost;
8
+ exports.doRequest = doRequest;
9
+ const http_1 = require("../../utils/http");
10
+ const error_1 = require("../../utils/error");
11
+ const logger_1 = require("../../utils/logger");
12
+ const http_client_1 = require("@lark-apaas/http-client");
13
+ /**
14
+ * 输出一条 HTTP 调试日志(仅 --verbose 模式生效)。
15
+ *
16
+ * 主要用于把后端返回的 `x-tt-logid` 透出给用户,方便拿这个 id 去 server / 网关日志里
17
+ * 直接定位本次请求的 `[MiaodaCLI.metric]` 行与上下游 trace。
18
+ */
19
+ function traceHttp(method, url, start, response, err) {
20
+ try {
21
+ const cost = Date.now() - start;
22
+ const status = response?.status ?? 0;
23
+ const logid = response?.headers?.get?.("x-tt-logid") ?? "-";
24
+ if (err !== undefined) {
25
+ const errMsg = err instanceof Error
26
+ ? err.message
27
+ : typeof err === "string"
28
+ ? err
29
+ : JSON.stringify(err);
30
+ (0, logger_1.debug)(`http ${method} ${url} ${String(status)} cost=${String(cost)}ms x-tt-logid=${logid} err=${errMsg}`);
31
+ return;
32
+ }
33
+ (0, logger_1.debug)(`http ${method} ${url} ${String(status)} cost=${String(cost)}ms x-tt-logid=${logid}`);
34
+ }
35
+ catch {
36
+ // debug 失败不应影响业务,吞掉
37
+ }
38
+ }
39
+ /** 进程内 bucket 缓存:{appId: bucketId}。不跨进程。 */
40
+ const bucketCache = new Map();
41
+ /**
42
+ * 获取默认 bucket(首次调用懒加载,进程内缓存)。
43
+ *
44
+ * 后端:GET /b/{appId}/get_published_v2
45
+ *
46
+ * 注意:这个接口属于运行端 innerapi(不是 admin 管理端),必须用
47
+ * getRuntimeHttpClient();其余 /v1/storage/app/... 六条 admin 接口走 admin client。
48
+ */
49
+ async function getDefaultBucketId(appId) {
50
+ const cached = bucketCache.get(appId);
51
+ if (cached)
52
+ return cached;
53
+ const url = `/b/${encodeURIComponent(appId)}/get_published_v2`;
54
+ const body = await doGet(url, { errorContext: `fetch default bucket for app '${appId}'` }, (0, http_1.getRuntimeHttpClient)());
55
+ ensureSuccess(body);
56
+ const bucketId = body.data?.app_runtime_extra?.bucket?.default_bucket_id;
57
+ if (!bucketId) {
58
+ throw new error_1.AppError("BUCKET_NOT_FOUND", `No default bucket for app '${appId}'`);
59
+ }
60
+ bucketCache.set(appId, bucketId);
61
+ return bucketId;
62
+ }
63
+ /** 仅测试用:重置 bucket 缓存 */
64
+ function resetBucketCache() {
65
+ bucketCache.clear();
66
+ }
67
+ /**
68
+ * 校验 file-storage inner API 响应信封。
69
+ * 成功条件:`ErrorCode == "0"` 或 `error_code == "0"`,或都未返回视为成功。
70
+ * 失败时抛 AppError(映射到 CLI 错误码由 handler 层决定)。
71
+ */
72
+ /**
73
+ * 后端 file-storage 业务错误码 → CLI 错误码映射。
74
+ *
75
+ * 约定:后端业务错误**全部**通过 HTTP 200 返回,靠 `status_code` 字段区分
76
+ * (`"0"` = 成功;`"k_img_ec_xxxxxx"` 等 = 业务错误)。CLI 这份映射表是
77
+ * **长期维护**的(后端历史码不会迁移到语义化 code),发现新 code 在此增补。
78
+ *
79
+ * 增补流程:
80
+ * 1. 观察后端响应 `{status_code: "k_img_ec_xxx", error_msg: "..."}`
81
+ * 2. 确认 code 对应的业务语义(查后端代码 / 问同事)
82
+ * 3. 在此表加一行,选一个语义化的 CLI code(参考命名:`FILE_NOT_FOUND`
83
+ * / `FILE_ALREADY_EXISTS` / `INVALID_*` / `PERMISSION_DENIED` 等)
84
+ * 4. 未映射的 code 会 fallback 到 `FILE_API_<原 code>`,不影响功能,但 Agent 不易识别
85
+ */
86
+ const BIZ_ERR_MAP = new Map(Object.entries({
87
+ k_img_ec_000034: {
88
+ code: "FILE_NOT_FOUND",
89
+ message: "File does not exist",
90
+ hint: "Run `miaoda file ls` to see available files.",
91
+ },
92
+ k_img_ec_000035: {
93
+ code: "FILE_ALREADY_EXISTS",
94
+ message: "A file at this path already exists",
95
+ hint: "Rename the target or delete the existing file first (`miaoda file rm`).",
96
+ },
97
+ }));
98
+ function ensureSuccess(body) {
99
+ // 后端 envelope 字段历史遗留多种命名:
100
+ // - ErrorCode / error_code: 部分接口
101
+ // - status_code: delete / sign / head / preUpload 等新接口
102
+ const code = body.ErrorCode ?? body.error_code ?? body.status_code ?? "0";
103
+ if (code === "0" || code === "")
104
+ return;
105
+ // 错误 message 字段同样散:ErrorMessage / error_message / error_msg / Message
106
+ const backendMsg = body.ErrorMessage ?? body.error_message ?? body.error_msg ?? body.Message ?? "unknown error";
107
+ const mapped = BIZ_ERR_MAP.get(code);
108
+ if (mapped) {
109
+ throw new error_1.AppError(mapped.code, mapped.message ?? backendMsg, {
110
+ next_actions: mapped.hint ? [mapped.hint] : undefined,
111
+ });
112
+ }
113
+ throw new error_1.AppError(`FILE_API_${code}`, `File API error [${code}]: ${backendMsg}`);
114
+ }
115
+ /** 从 HttpError 的 response 里尝试读 body,用于拿后端返的业务 ErrorCode。 */
116
+ async function extractBody(resp) {
117
+ if (!resp)
118
+ return null;
119
+ try {
120
+ return (await resp.json());
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /** 把 SDK 抛出的 HttpError 统一映射成 CLI AppError(404 / 业务 ErrorCode / 兜底)。 */
127
+ async function mapHttpError(err, opts) {
128
+ if (err instanceof error_1.AppError)
129
+ throw err;
130
+ if (err instanceof http_client_1.HttpError) {
131
+ const status = err.response?.status ?? 0;
132
+ const body = await extractBody(err.response);
133
+ // 1. 先看后端业务 ErrorCode(优先级最高)
134
+ if (body) {
135
+ const code = body.ErrorCode ?? body.error_code ?? "";
136
+ if (code && code !== "0") {
137
+ const msg = body.ErrorMessage ?? body.error_message ?? body.Message ?? err.message;
138
+ throw new error_1.AppError(`FILE_API_${code}`, `File API error [${code}]: ${msg}`);
139
+ }
140
+ }
141
+ // 2. 404 常见情况 —— 路径不存在,映射到业务语义
142
+ if (status === 404 && opts.notFoundCode) {
143
+ throw new error_1.AppError(opts.notFoundCode, opts.notFoundMessage ?? "resource not found", {
144
+ next_actions: opts.notFoundHint ? [opts.notFoundHint] : undefined,
145
+ });
146
+ }
147
+ // 3. 兜底:保留原始 status 的 HttpError(给上层看到真实状态码)
148
+ const ctx = opts.errorContext ?? "HTTP request failed";
149
+ const statusText = err.response?.statusText ?? "";
150
+ throw new error_1.HttpError(status, err.config.url ?? "", `${ctx}: ${String(status)} ${statusText}`.trim());
151
+ }
152
+ throw err;
153
+ }
154
+ /**
155
+ * 包一层 client.get + HttpError 统一映射,成功时直接返回 body 的 json。
156
+ * 默认走 admin innerapi 客户端;个别接口(如 get_published_v2)属于运行端,
157
+ * 通过第三个参数显式传入 getRuntimeHttpClient() 切换。
158
+ */
159
+ async function doGet(url, opts = {}, client = (0, http_1.getHttpClient)()) {
160
+ const start = Date.now();
161
+ try {
162
+ const response = await client.get(url);
163
+ traceHttp("GET", url, start, response);
164
+ return (await response.json());
165
+ }
166
+ catch (err) {
167
+ traceHttp("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
168
+ await mapHttpError(err, opts);
169
+ throw err; // 不可达,mapHttpError 必定 throw
170
+ }
171
+ }
172
+ /** 包一层 client.post + HttpError 统一映射。默认 admin innerapi,可显式切 runtime。 */
173
+ async function doPost(url, body, opts = {}, client = (0, http_1.getHttpClient)()) {
174
+ const start = Date.now();
175
+ try {
176
+ const response = await client.post(url, body);
177
+ traceHttp("POST", url, start, response);
178
+ return (await response.json());
179
+ }
180
+ catch (err) {
181
+ traceHttp("POST", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
182
+ await mapHttpError(err, opts);
183
+ throw err;
184
+ }
185
+ }
186
+ /** 包一层 client.request + HttpError 统一映射(DELETE 带 body 的场景)。 */
187
+ async function doRequest(cfg, opts = {}, client = (0, http_1.getHttpClient)()) {
188
+ const start = Date.now();
189
+ try {
190
+ const response = await client.request(cfg);
191
+ traceHttp(cfg.method, cfg.url, start, response);
192
+ return (await response.json());
193
+ }
194
+ catch (err) {
195
+ traceHttp(cfg.method, cfg.url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
196
+ await mapHttpError(err, opts);
197
+ throw err;
198
+ }
199
+ }
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ /**
3
+ * 判断用户输入应当作为 **path**(后端存储 key)还是 **file_name** 处理。
4
+ *
5
+ * 背景:
6
+ * - file-storage 的 `filePath` 是 bucket 内 unique 的对象存储 key,既可能是用户在
7
+ * upload 时指定的自定义路径(如 `logs/2026/data.csv`),也可能是后端 preUpload
8
+ * 自动生成的 fileKey(固定长度的 alphanumeric,可带扩展名,如 `abcdef1234567890.png`)
9
+ * - `file_name` 是上传时随文件一起登记的人类可读名字(可以含空格 / 中文 / 重复)
10
+ *
11
+ * CLI 默认让用户直接传一个字符串,不强制加 `--name`。当字符串"看起来像后端 key"时
12
+ * 走 path 语义(HEAD 直连),否则走 file_name 语义(FilterExpression `name eq`)。
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.looksLikePath = looksLikePath;
16
+ exports.toAbsolutePath = toAbsolutePath;
17
+ /**
18
+ * 后端 preUpload 生成的 fileKey 特征(实测 file-storage 响应):
19
+ * - base 段是 **16+ 位纯十进制数字**(snowflake ID,如 `1863152526384148`)
20
+ * - 可选 1~2 段扩展名(`.ext` / `.tar.gz`),每段 ≤ 10 位字母或数字
21
+ *
22
+ * 例:
23
+ * - `1863152526384148` ✓ (16 位 snowflake)
24
+ * - `1863152526384148.jpg` ✓
25
+ * - `1858533158493259.tar.gz` ✓ (2 段扩展)
26
+ * - `618a80ddb3b7e1496.jpg` ✗ (含字母,是用户起的 file_name)
27
+ * - `1234` ✗ (位数不够)
28
+ * - `报告.xlsx` / `my file.doc` ✗ (非 ASCII / 含空格)
29
+ *
30
+ * 修紧到纯数字是为了避开"用户上传时把文件命成 16+ 位 hex"的真实场景 ——
31
+ * 后端 fileKey 至今都是纯十进制,二者不重叠。
32
+ */
33
+ const BACKEND_KEY_PATTERN = /^\d{16,}(?:\.[a-zA-Z0-9]{1,10}){0,2}$/;
34
+ /**
35
+ * 输入"看起来像 path"的判断:
36
+ * 1. 以 `/` 开头 → 显式绝对路径
37
+ * 2. 含 `/`(但不以 `/` 开头)→ 命名空间形式的 key,如 `logs/2026/data.csv`
38
+ * 3. 匹配 `BACKEND_KEY_PATTERN` → 后端生成的 fileKey
39
+ * 其他情况一律视为 file_name。
40
+ */
41
+ function looksLikePath(input) {
42
+ if (!input)
43
+ return false;
44
+ if (input.startsWith("/"))
45
+ return true;
46
+ if (input.includes("/"))
47
+ return true;
48
+ return BACKEND_KEY_PATTERN.test(input);
49
+ }
50
+ /**
51
+ * 规整为带前导 `/` 的展示/HEAD 形式。
52
+ * 便于从 auto-detected 的 "abc123...xyz.txt" 回到统一的 `/abc123...xyz.txt`。
53
+ */
54
+ function toAbsolutePath(input) {
55
+ return input.startsWith("/") ? input : `/${input}`;
56
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ var api_1 = require("./api");
5
+ Object.defineProperty(exports, "listFiles", { enumerable: true, get: function () { return api_1.listFiles; } });
6
+ Object.defineProperty(exports, "statFile", { enumerable: true, get: function () { return api_1.statFile; } });
7
+ Object.defineProperty(exports, "uploadFile", { enumerable: true, get: function () { return api_1.uploadFile; } });
8
+ Object.defineProperty(exports, "signDownload", { enumerable: true, get: function () { return api_1.signDownload; } });
9
+ Object.defineProperty(exports, "downloadFile", { enumerable: true, get: function () { return api_1.downloadFile; } });
10
+ Object.defineProperty(exports, "deleteFiles", { enumerable: true, get: function () { return api_1.deleteFiles; } });
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; } });
13
+ var client_1 = require("./client");
14
+ Object.defineProperty(exports, "getDefaultBucketId", { enumerable: true, get: function () { return client_1.getDefaultBucketId; } });
15
+ Object.defineProperty(exports, "resetBucketCache", { enumerable: true, get: function () { return client_1.resetBucketCache; } });
16
+ var detect_1 = require("./detect");
17
+ Object.defineProperty(exports, "looksLikePath", { enumerable: true, get: function () { return detect_1.looksLikePath; } });
18
+ Object.defineProperty(exports, "toAbsolutePath", { enumerable: true, get: function () { return detect_1.toAbsolutePath; } });
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseAttachment = parseAttachment;
4
+ exports.parseHead = parseHead;
5
+ /** 安全读取字符串字段(空字符串回退为默认值) */
6
+ function str(value, fallback = "") {
7
+ return typeof value === "string" && value.length > 0 ? value : fallback;
8
+ }
9
+ /** 安全读取数字字段(非正值回退到 0) */
10
+ function int(value) {
11
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
12
+ return Math.trunc(value);
13
+ }
14
+ return 0;
15
+ }
16
+ function readSize(meta) {
17
+ if (!meta)
18
+ return 0;
19
+ const v = meta.size ??
20
+ meta.fileSize ??
21
+ meta.file_size ??
22
+ meta.contentLength ??
23
+ 0;
24
+ return int(v);
25
+ }
26
+ /**
27
+ * 展示层 path 统一带前导 `/`(对齐 PRD / SKILL 里的示例)。
28
+ * 后端存储的 path 实际是无前导 `/` 的,这里补齐。
29
+ */
30
+ function toDisplayPath(p) {
31
+ if (!p)
32
+ return p;
33
+ return p.startsWith("/") ? p : `/${p}`;
34
+ }
35
+ /** 将后端 attachment 结构映射为 CLI FileInfo(用于 ls) */
36
+ function parseAttachment(att) {
37
+ const rawPath = str(att.filePath, str(att.name));
38
+ const info = {
39
+ file_name: str(att.name),
40
+ path: toDisplayPath(rawPath),
41
+ size_bytes: readSize(att.metadata),
42
+ type: str(att.metadata?.mimeType),
43
+ uploaded_at: str(att.createdAt),
44
+ };
45
+ const downloadUrl = str(att.downloadURL);
46
+ if (downloadUrl)
47
+ info.download_url = downloadUrl;
48
+ // uploaded_by 从 createdBy.name 映射(后端返 {userID, name, email, ...},空代表匿名)
49
+ const uploader = str(att.createdBy?.name);
50
+ if (uploader)
51
+ info.uploaded_by = uploader;
52
+ return info;
53
+ }
54
+ /** 将后端 head 响应映射为 CLI FileInfo(用于 stat) */
55
+ function parseHead(data, filePath) {
56
+ const outer = data?.metadata ?? {};
57
+ const inner = outer.metadata ?? {};
58
+ const info = {
59
+ file_name: str(outer.name),
60
+ path: toDisplayPath(filePath || str(outer.name)),
61
+ size_bytes: readSize(inner),
62
+ type: str(inner.mimeType),
63
+ uploaded_at: str(inner.lastModified),
64
+ };
65
+ const downloadUrl = str(outer.downloadURL);
66
+ if (downloadUrl)
67
+ info.download_url = downloadUrl;
68
+ const uploader = str(outer.createdBy?.name);
69
+ if (uploader)
70
+ info.uploaded_by = uploader;
71
+ return info;
72
+ }
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ // ── API 响应外层信封(file-storage inner API 统一结构) ──
3
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/api/index.js CHANGED
@@ -33,6 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.plugin = void 0;
36
+ exports.db = exports.file = exports.plugin = void 0;
37
37
  const _plugin = __importStar(require("../api/plugin/index"));
38
+ const _file = __importStar(require("../api/file/index"));
39
+ const _db = __importStar(require("../api/db/index"));
38
40
  exports.plugin = { ..._plugin };
41
+ exports.file = { ..._file };
42
+ exports.db = { ..._db };
@@ -22,7 +22,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
22
22
  const node_path_1 = __importDefault(require("node:path"));
23
23
  // ── Plugin Version API ──
24
24
  async function getPluginVersions(keys, latestOnly = true) {
25
- const client = (0, http_1.getHttpClient)();
25
+ const client = (0, http_1.getRuntimeHttpClient)();
26
26
  const response = await client.post(`/api/v1/studio/innerapi/plugins/-/versions/batch_get?keys=${keys.join(",")}&latest_only=${String(latestOnly)}`);
27
27
  if (!response.ok) {
28
28
  throw new error_1.HttpError(response.status, response.url, `Failed to get plugin versions: ${String(response.status)} ${response.statusText}`);
@@ -58,7 +58,7 @@ function parsePluginKey(key) {
58
58
  return { scope: match[1], name: match[2] };
59
59
  }
60
60
  async function downloadFromInner(pluginKey, version) {
61
- const client = (0, http_1.getHttpClient)();
61
+ const client = (0, http_1.getRuntimeHttpClient)();
62
62
  const { scope, name } = parsePluginKey(pluginKey);
63
63
  const url = `/api/v1/studio/innerapi/plugins/${scope}/${name}/versions/${version}/package`;
64
64
  const response = await client.get(url);
@@ -203,7 +203,7 @@ async function reportEvents(events) {
203
203
  if (events.length === 0)
204
204
  return true;
205
205
  try {
206
- const client = (0, http_1.getHttpClient)();
206
+ const client = (0, http_1.getRuntimeHttpClient)();
207
207
  const response = await client.post("/api/v1/studio/innerapi/resource_events", { events });
208
208
  if (!response.ok) {
209
209
  (0, logger_1.log)("telemetry", `Failed to report events: ${String(response.status)} ${response.statusText}`);
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerDbCommands = registerDbCommands;
4
+ const index_1 = require("../../../cli/handlers/db/index");
5
+ function registerDbCommands(program) {
6
+ const dbCmd = program
7
+ .command("db")
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,仅专家模式应用支持)");
13
+ dbCmd.action(() => {
14
+ dbCmd.outputHelp();
15
+ });
16
+ dbCmd
17
+ .command("sql")
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
+ `);
60
+ // schema 二级资源分组
61
+ const schemaCmd = dbCmd
62
+ .command("schema")
63
+ .summary("查看数据库表结构")
64
+ .description("查看应用数据库的表结构信息。")
65
+ .usage("<command> [flags]");
66
+ schemaCmd.action(() => {
67
+ schemaCmd.outputHelp();
68
+ });
69
+ schemaCmd
70
+ .command("list")
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
+ `);
97
+ schemaCmd
98
+ .command("get")
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
+ `);
127
+ // data 二级资源分组
128
+ const dataCmd = dbCmd
129
+ .command("data")
130
+ .summary("表数据导入导出")
131
+ .description("表数据的批量导入导出,适合数据备份、跨环境迁移、外部分析。")
132
+ .usage("<command> [flags]");
133
+ dataCmd.action(() => {
134
+ dataCmd.outputHelp();
135
+ });
136
+ dataCmd
137
+ .command("import")
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
+ `);
173
+ dataCmd
174
+ .command("export")
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>", "输出文件路径,默认 <表名>.<格式>")
181
+ .option("--limit <n>", "最多导出行数(不超过 5000)")
182
+ .option("--force", "输出文件已存在时覆盖(默认报错)")
183
+ .action(async function (table) {
184
+ await (0, index_1.handleDbDataExport)(table, this.optsWithGlobals());
185
+ })
186
+ .addHelpText("after", `
187
+ Notes:
188
+ - SQL 格式生成 INSERT 语句,可用 \`miaoda db sql < <文件>.sql\` 在其他环境回放。
189
+ - 文件已存在时默认报错(FILE_ALREADY_EXISTS),用 --force 覆盖或 -f 改路径。
190
+
191
+ Examples:
192
+ $ miaoda db data export users
193
+ ✓ Exported users → users.csv (1523 rows)
194
+
195
+ $ miaoda db data export users --format json
196
+ ✓ Exported users → users.json (1523 rows)
197
+
198
+ $ miaoda db data export users --format sql -f users_backup.sql
199
+ ✓ Exported users → users_backup.sql (1523 rows)
200
+
201
+ $ miaoda db data export users -f ~/Desktop/users.csv
202
+ ✓ Exported users → ~/Desktop/users.csv (1523 rows)
203
+
204
+ # 报错:文件已存在
205
+ $ miaoda db data export users
206
+ Error: Output file 'users.csv' already exists
207
+ hint: Use -f to specify a different path, or --force to overwrite.
208
+ `);
209
+ }