@lark-apaas/miaoda-cli 0.1.0-alpha.08508f4

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 (47) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +66 -0
  3. package/bin/miaoda.js +2 -0
  4. package/dist/api/db/api.js +200 -0
  5. package/dist/api/db/client.js +166 -0
  6. package/dist/api/db/index.js +16 -0
  7. package/dist/api/db/parsers.js +161 -0
  8. package/dist/api/db/types.js +10 -0
  9. package/dist/api/file/api.js +425 -0
  10. package/dist/api/file/client.js +199 -0
  11. package/dist/api/file/detect.js +56 -0
  12. package/dist/api/file/index.js +17 -0
  13. package/dist/api/file/parsers.js +72 -0
  14. package/dist/api/file/types.js +3 -0
  15. package/dist/api/index.js +42 -0
  16. package/dist/api/plugin/api.js +243 -0
  17. package/dist/api/plugin/index.js +12 -0
  18. package/dist/api/plugin/types.js +3 -0
  19. package/dist/cli/commands/db/index.js +69 -0
  20. package/dist/cli/commands/file/index.js +75 -0
  21. package/dist/cli/commands/index.js +11 -0
  22. package/dist/cli/commands/plugin/index.js +204 -0
  23. package/dist/cli/commands/shared.js +52 -0
  24. package/dist/cli/handlers/db/data.js +168 -0
  25. package/dist/cli/handlers/db/index.js +11 -0
  26. package/dist/cli/handlers/db/schema.js +163 -0
  27. package/dist/cli/handlers/db/sql.js +252 -0
  28. package/dist/cli/handlers/file/cp.js +220 -0
  29. package/dist/cli/handlers/file/index.js +13 -0
  30. package/dist/cli/handlers/file/ls.js +110 -0
  31. package/dist/cli/handlers/file/rm.js +263 -0
  32. package/dist/cli/handlers/file/sign.js +96 -0
  33. package/dist/cli/handlers/file/stat.js +97 -0
  34. package/dist/cli/handlers/index.js +19 -0
  35. package/dist/cli/handlers/plugin/index.js +10 -0
  36. package/dist/cli/handlers/plugin/plugin-local.js +382 -0
  37. package/dist/cli/handlers/plugin/plugin.js +308 -0
  38. package/dist/main.js +31 -0
  39. package/dist/utils/config.js +31 -0
  40. package/dist/utils/error.js +38 -0
  41. package/dist/utils/http.js +67 -0
  42. package/dist/utils/index.js +27 -0
  43. package/dist/utils/log_id.js +13 -0
  44. package/dist/utils/logger.js +15 -0
  45. package/dist/utils/output.js +72 -0
  46. package/dist/utils/render.js +187 -0
  47. package/package.json +53 -0
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listFiles = listFiles;
4
+ exports.resolveInputs = resolveInputs;
5
+ exports.statFile = statFile;
6
+ exports.uploadFile = uploadFile;
7
+ exports.signDownload = signDownload;
8
+ exports.downloadFile = downloadFile;
9
+ exports.deleteFiles = deleteFiles;
10
+ const error_1 = require("../../utils/error");
11
+ const logger_1 = require("../../utils/logger");
12
+ const client_1 = require("./client");
13
+ const detect_1 = require("./detect");
14
+ const parsers_1 = require("./parsers");
15
+ // ── 内部 helper ──
16
+ /**
17
+ * 后端存储 path 是无前导 `/` 的,CLI 对外使用带前导 `/` 的表示。
18
+ * 所有进入 API 层的 path 都要 strip 前导 `/`。
19
+ */
20
+ function toApiPath(filePath) {
21
+ return filePath.replace(/^\/+/, "");
22
+ }
23
+ function encodePath(filePath) {
24
+ return toApiPath(filePath)
25
+ .split("/")
26
+ .map((seg) => encodeURIComponent(seg))
27
+ .join("/");
28
+ }
29
+ function extractEnvelope(body) {
30
+ (0, client_1.ensureSuccess)(body);
31
+ if (!body.data) {
32
+ throw new error_1.AppError("INTERNAL_FILE_API_ERROR", "API returned empty data");
33
+ }
34
+ return body.data;
35
+ }
36
+ // ── list ──
37
+ /** 底层单次 list 调用(一页)。支持 filterExpr / sortBy 下推。 */
38
+ async function listOnce(appId, bucketId, opts) {
39
+ const url = `/v1/storage/app/${encodeURIComponent(appId)}/bucket/${encodeURIComponent(bucketId)}/list`;
40
+ const reqBody = {
41
+ bucketID: bucketId,
42
+ maxKeys: opts.limit,
43
+ };
44
+ if (opts.cursor)
45
+ reqBody.continuationToken = opts.cursor;
46
+ if (opts.filterExpr)
47
+ reqBody.filterExpr = opts.filterExpr;
48
+ if (opts.sortBy && opts.sortBy.length > 0)
49
+ reqBody.sortBy = opts.sortBy;
50
+ const body = await (0, client_1.doPost)(url, reqBody, {
51
+ errorContext: "list files",
52
+ });
53
+ return extractEnvelope(body);
54
+ }
55
+ /**
56
+ * 把 ls 的过滤字段合并为一个 FilterExpression 下推到后端。
57
+ *
58
+ * 所有字段**精确匹配**(后端不支持 glob / 前缀)。后端支持的 field × operator 矩阵:
59
+ * name → eq / ne
60
+ * type → eq / ne / in / notIn
61
+ * size → eq / ne / gt / gte / lt / lte
62
+ * createdAt→ gt / gte / lt / lte(value 要毫秒 timestamp)
63
+ * createdBy→ eq / ne / isNull / isNotNull
64
+ * (`--path` 字段后端不支持,走 sidecar 过滤)
65
+ */
66
+ function buildFilterExpr(opts) {
67
+ // 用户直接传 filterExpr(高级用法)→ 原样透传
68
+ if (opts.filterExpr)
69
+ return opts.filterExpr;
70
+ const conds = [];
71
+ if (opts.name) {
72
+ conds.push({ field: "name", operator: "eq", value: opts.name });
73
+ }
74
+ if (opts.type) {
75
+ conds.push({ field: "type", operator: "eq", value: opts.type });
76
+ }
77
+ if (opts.sizeGt !== undefined) {
78
+ conds.push({ field: "size", operator: "gt", value: String(opts.sizeGt) });
79
+ }
80
+ if (opts.sizeLt !== undefined) {
81
+ conds.push({ field: "size", operator: "lt", value: String(opts.sizeLt) });
82
+ }
83
+ if (opts.uploadedSince) {
84
+ // 后端期望毫秒 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
+ }
89
+ }
90
+ if (conds.length === 0)
91
+ return undefined;
92
+ return { logic: "and", groups: [{ conditions: conds }] };
93
+ }
94
+ /**
95
+ * 列出文件:所有过滤下推到后端 FilterExpression,纯精确匹配,无 glob。
96
+ *
97
+ * 后端:POST /v1/storage/app/{appId}/bucket/{bucketId}/list
98
+ */
99
+ async function listFiles(opts) {
100
+ const bucketId = await (0, client_1.getDefaultBucketId)(opts.appId);
101
+ const pageSize = Math.min(Math.max(opts.limit ?? 50, 1), 200);
102
+ const filterExpr = buildFilterExpr(opts);
103
+ const collected = [];
104
+ let cursor = opts.cursor;
105
+ let hasMore = false;
106
+ // 分页 cursor 用 raw attachment.id(后端 continuationToken 语义);
107
+ // CLI 对外不暴露 attachment.id,所以这里单独追踪最后一页的 raw id
108
+ let lastRawId = "";
109
+ for (;;) {
110
+ const fetchLimit = opts.all ? 200 : pageSize;
111
+ const data = await listOnce(opts.appId, bucketId, {
112
+ limit: fetchLimit,
113
+ cursor,
114
+ filterExpr,
115
+ sortBy: opts.sortBy,
116
+ });
117
+ const rawItems = data.attachments ?? [];
118
+ hasMore = Boolean(data.hasMore);
119
+ if (rawItems.length > 0) {
120
+ lastRawId = rawItems[rawItems.length - 1].id ?? "";
121
+ }
122
+ for (const raw of rawItems) {
123
+ const info = (0, parsers_1.parseAttachment)(raw);
124
+ // 后端 FilterExpression 不支持 fileKey 字段,--path 精准匹配靠 sidecar
125
+ // (PRD:按文件路径精准匹配,path 在 bucket 内 unique → 0 或 1 条命中)
126
+ if (opts.path && info.path !== opts.path)
127
+ continue;
128
+ collected.push(info);
129
+ if (!opts.all && collected.length >= pageSize)
130
+ break;
131
+ }
132
+ if (!opts.all)
133
+ break;
134
+ if (!hasMore || rawItems.length === 0)
135
+ break;
136
+ cursor = lastRawId;
137
+ if (!cursor)
138
+ break;
139
+ }
140
+ const next = !opts.all && hasMore && lastRawId ? lastRawId : null;
141
+ return {
142
+ items: collected,
143
+ next_cursor: next,
144
+ has_more: opts.all ? false : Boolean(next),
145
+ };
146
+ }
147
+ /**
148
+ * 把一批 CLI 输入(path 或 file_name)批量解析成 FileInfo。
149
+ *
150
+ * 受限于后端 FilterExpression 能力(只支持 `name eq` / `type eq|in` / `size/createdAt` 范围查,
151
+ * 不支持 `fileKey` 字段、不支持 `name in`),CLI 按输入类型走两条路径:
152
+ *
153
+ * - path 输入(`looksLikePath(input) === true`):后端 head 接口直接接 path → 单次 HEAD 判存在性
154
+ * - file_name 输入:后端 list 支持 `name eq X` → 单次 list 精确匹配 → 多匹配 AMBIGUOUS_FILE_NAME
155
+ *
156
+ * path 识别规则见 `./detect.ts#looksLikePath`,涵盖:
157
+ * 1) `/` 开头的绝对路径
158
+ * 2) 含 `/` 的命名空间 key(如 `logs/2026/data.csv`)
159
+ * 3) 后端生成的 fileKey 形态(16+ 位 alphanumeric 可带扩展名)
160
+ *
161
+ * 需要强制指定类型时调用方可通过 `forceAs` 跳过自动识别。
162
+ *
163
+ * N 个 input 并发 N 个请求,每个 O(1)。
164
+ */
165
+ async function resolveInputs(opts) {
166
+ if (opts.inputs.length === 0)
167
+ return [];
168
+ return Promise.all(opts.inputs.map((input) => {
169
+ const kind = opts.forceAs ?? ((0, detect_1.looksLikePath)(input) ? "path" : "name");
170
+ return kind === "path"
171
+ ? resolveByPath(opts.appId, input)
172
+ : resolveByName(opts.appId, input);
173
+ }));
174
+ }
175
+ /** path 输入:HEAD 判存在性,path 在 bucket 内 unique 无需反查。 */
176
+ async function resolveByPath(appId, input) {
177
+ // 后端 head 接口要求 path 以 `/` 开头。auto-detected 的 "abc123...xyz.txt" 这里补齐。
178
+ const filePath = (0, detect_1.toAbsolutePath)(input);
179
+ try {
180
+ const info = await statFile({ appId, filePath });
181
+ return { status: "ok", input, file: info };
182
+ }
183
+ catch (err) {
184
+ if (err instanceof error_1.AppError && err.code === "FILE_NOT_FOUND") {
185
+ return {
186
+ status: "error",
187
+ input,
188
+ error: {
189
+ code: "FILE_NOT_FOUND",
190
+ message: `File '${input}' does not exist`,
191
+ hint: "Run `miaoda file ls` to see available files.",
192
+ },
193
+ };
194
+ }
195
+ throw err;
196
+ }
197
+ }
198
+ /** file_name 输入:list + FilterExpression `name eq X`,多匹配 AMBIGUOUS。 */
199
+ async function resolveByName(appId, input) {
200
+ const filterExpr = {
201
+ logic: "and",
202
+ groups: [{ conditions: [{ field: "name", operator: "eq", value: input }] }],
203
+ };
204
+ const result = await listFiles({ appId, filterExpr, limit: 200 });
205
+ // 后端返 name 精确匹配的所有 attachments。basename fallback 防御(后端某些场景 name 可能为空)
206
+ const matches = result.items.filter((i) => {
207
+ const basename = i.path.split("/").pop() ?? i.path;
208
+ return i.file_name === input || basename === input;
209
+ });
210
+ if (matches.length === 0) {
211
+ return {
212
+ status: "error",
213
+ input,
214
+ error: {
215
+ code: "FILE_NOT_FOUND",
216
+ message: `File '${input}' does not exist`,
217
+ hint: "Run `miaoda file ls` to see available files.",
218
+ },
219
+ };
220
+ }
221
+ if (matches.length > 1) {
222
+ return {
223
+ status: "error",
224
+ input,
225
+ error: {
226
+ code: "AMBIGUOUS_FILE_NAME",
227
+ message: `Multiple files match name '${input}' (${String(matches.length)} found)`,
228
+ hint: `Use path instead. Run \`miaoda file ls --name ${input}\` to see candidates.`,
229
+ },
230
+ };
231
+ }
232
+ return { status: "ok", input, file: matches[0] };
233
+ }
234
+ // ── stat ──
235
+ /**
236
+ * 查看文件元数据。
237
+ * 后端:GET /v1/storage/app/{appId}/object/{bucketId}/{filePath}/head
238
+ */
239
+ async function statFile(opts) {
240
+ const bucketId = await (0, client_1.getDefaultBucketId)(opts.appId);
241
+ // 对齐 file-storage-skill Py 版:URL 顺序是 .../object/{bucket}/head/{path}
242
+ const url = `/v1/storage/app/${encodeURIComponent(opts.appId)}/object/${encodeURIComponent(bucketId)}/head/${encodePath(opts.filePath)}`;
243
+ const body = await (0, client_1.doGet)(url, {
244
+ notFoundCode: "FILE_NOT_FOUND",
245
+ notFoundMessage: `File '${opts.filePath}' does not exist`,
246
+ notFoundHint: "Run `miaoda file ls` to see available files.",
247
+ errorContext: "stat file",
248
+ });
249
+ return (0, parsers_1.parseHead)(extractEnvelope(body), opts.filePath);
250
+ }
251
+ // ── 预签上传 URL ──
252
+ async function preUpload(appId, req) {
253
+ const bucketId = await (0, client_1.getDefaultBucketId)(appId);
254
+ const url = `/v1/storage/app/${encodeURIComponent(appId)}/bucket/${encodeURIComponent(bucketId)}/preUpload`;
255
+ const body = await (0, client_1.doPost)(url, { bucketID: bucketId, appID: appId, ...req }, { errorContext: "pre-upload" });
256
+ return extractEnvelope(body);
257
+ }
258
+ async function uploadCallback(appId, req) {
259
+ const url = `/v1/storage/app/${encodeURIComponent(appId)}/object/callback`;
260
+ const body = await (0, client_1.doPost)(url, req, { errorContext: "upload callback" });
261
+ (0, client_1.ensureSuccess)(body);
262
+ }
263
+ /**
264
+ * 上传文件(3 步:preUpload → PUT uploadURL → callback)。
265
+ * PUT 步骤直接 fetch uploadURL(对象存储直传,绕开框架 HttpClient)。
266
+ */
267
+ async function uploadFile(opts) {
268
+ const pre = await preUpload(opts.appId, {
269
+ fileName: opts.fileName,
270
+ fileSize: opts.fileSize,
271
+ contentType: opts.contentType,
272
+ filePath: opts.remotePath ? toApiPath(opts.remotePath) : undefined,
273
+ });
274
+ if (!pre.uploadURL || !pre.uploadID) {
275
+ throw new error_1.AppError("FILE_UPLOAD_FAILED", "preUpload did not return uploadURL/uploadID");
276
+ }
277
+ const body = await opts.readFile();
278
+ let etag = "";
279
+ try {
280
+ // Node 20+ 内置 fetch 运行时接受 Buffer,但 TS 的 BodyInit 要求更严格的类型;
281
+ // 这里复制到独立 ArrayBuffer 以满足 lib.dom.d.ts 的 BodyInit 约束
282
+ const ab = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
283
+ const uploadStart = Date.now();
284
+ const res = await fetch(pre.uploadURL, {
285
+ method: "PUT",
286
+ headers: {
287
+ "Content-Type": opts.contentType,
288
+ "Content-Length": String(opts.fileSize),
289
+ },
290
+ body: ab,
291
+ });
292
+ (0, logger_1.debug)(`http PUT <upload-cdn> ${String(res.status)} cost=${String(Date.now() - uploadStart)}ms size=${String(opts.fileSize)}`);
293
+ if (!res.ok) {
294
+ throw new error_1.AppError("FILE_UPLOAD_FAILED", `Upload PUT failed with status ${String(res.status)}`);
295
+ }
296
+ etag = res.headers.get("ETag") ?? "";
297
+ }
298
+ catch (err) {
299
+ if (err instanceof error_1.AppError)
300
+ throw err;
301
+ throw new error_1.AppError("FILE_UPLOAD_FAILED", `Upload PUT failed: ${err instanceof Error ? err.message : String(err)}`);
302
+ }
303
+ if (!etag) {
304
+ throw new error_1.AppError("FILE_UPLOAD_FAILED", "Upload PUT returned no ETag");
305
+ }
306
+ // callback 失败仅 warning,文件已经上传到对象存储
307
+ try {
308
+ await uploadCallback(opts.appId, { uploadID: pre.uploadID, eTag: etag });
309
+ }
310
+ catch (err) {
311
+ (0, logger_1.debug)(`upload callback failed: ${err instanceof Error ? err.message : String(err)}`);
312
+ }
313
+ const remotePath = opts.remotePath ?? `/${opts.fileName}`;
314
+ const result = {
315
+ file_name: opts.fileName,
316
+ path: remotePath,
317
+ size: opts.fileSize,
318
+ type: opts.contentType,
319
+ };
320
+ // 上传完成后补一次 stat,拿到后端返回的 download_url(preUpload/callback 都不返)
321
+ // 非关键路径:拿不到就降级,只是 JSON 输出里缺 download_url 字段
322
+ try {
323
+ const info = await statFile({ appId: opts.appId, filePath: remotePath });
324
+ if (info.download_url)
325
+ result.download_url = info.download_url;
326
+ if (info.file_name)
327
+ result.file_name = info.file_name;
328
+ }
329
+ catch (err) {
330
+ (0, logger_1.debug)(`post-upload stat failed: ${err instanceof Error ? err.message : String(err)}`);
331
+ }
332
+ return result;
333
+ }
334
+ // ── 预签下载 URL ──
335
+ /**
336
+ * 获取预签下载 URL。
337
+ * 后端:POST /v1/storage/app/{appId}/object/sign/{bucketId}/{filePath}
338
+ */
339
+ async function signDownload(opts) {
340
+ const bucketId = await (0, client_1.getDefaultBucketId)(opts.appId);
341
+ const url = `/v1/storage/app/${encodeURIComponent(opts.appId)}/object/sign/${encodeURIComponent(bucketId)}/${encodePath(opts.filePath)}`;
342
+ const reqBody = {
343
+ bucketID: bucketId,
344
+ filePath: toApiPath(opts.filePath),
345
+ expiresIn: opts.expiresIn,
346
+ };
347
+ const body = await (0, client_1.doPost)(url, reqBody, {
348
+ errorContext: "sign download URL",
349
+ });
350
+ // 后端对不存在文件走业务 ErrorCode(status_code=k_img_ec_000034),
351
+ // 由 ensureSuccess → BIZ_ERR_MAP 统一抛出 FILE_NOT_FOUND,此处无需兜底
352
+ const data = extractEnvelope(body);
353
+ const signed = data.signedURL ?? "";
354
+ const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
355
+ const displayPath = opts.filePath.startsWith("/") ? opts.filePath : `/${opts.filePath}`;
356
+ const fileName = displayPath.split("/").pop() ?? displayPath;
357
+ return {
358
+ file_name: fileName,
359
+ path: displayPath,
360
+ signed_url: signed,
361
+ expires_at: expiresAt,
362
+ };
363
+ }
364
+ /**
365
+ * 下载文件到本地。直接 GET signedURL(对象存储直取,绕开框架 HttpClient)。
366
+ */
367
+ async function downloadFile(opts) {
368
+ let res;
369
+ const downloadStart = Date.now();
370
+ try {
371
+ res = await fetch(opts.signedURL);
372
+ }
373
+ catch (err) {
374
+ (0, logger_1.debug)(`http GET <download-cdn> 0 cost=${String(Date.now() - downloadStart)}ms err=${err instanceof Error ? err.message : String(err)}`);
375
+ throw new error_1.AppError("FILE_DOWNLOAD_FAILED", `Download GET failed: ${err instanceof Error ? err.message : String(err)}`);
376
+ }
377
+ (0, logger_1.debug)(`http GET <download-cdn> ${String(res.status)} cost=${String(Date.now() - downloadStart)}ms`);
378
+ if (!res.ok) {
379
+ throw new error_1.AppError("FILE_DOWNLOAD_FAILED", `Download failed with status ${String(res.status)}`);
380
+ }
381
+ const array = new Uint8Array(await res.arrayBuffer());
382
+ const buf = Buffer.from(array);
383
+ await opts.writeFile(buf);
384
+ return buf.length;
385
+ }
386
+ // ── 批量删除(best-effort) ──
387
+ /**
388
+ * 批量删除文件。
389
+ * 后端:DELETE /v1/storage/app/{appId}/bucket/{bucketId}/delete
390
+ */
391
+ async function deleteFiles(opts) {
392
+ const bucketId = await (0, client_1.getDefaultBucketId)(opts.appId);
393
+ const url = `/v1/storage/app/${encodeURIComponent(opts.appId)}/bucket/${encodeURIComponent(bucketId)}/delete`;
394
+ const reqBody = {
395
+ bucketID: bucketId,
396
+ filePaths: opts.filePaths.map(toApiPath),
397
+ };
398
+ const body = await (0, client_1.doRequest)({
399
+ method: "DELETE",
400
+ url,
401
+ headers: { "Content-Type": "application/json" },
402
+ body: JSON.stringify(reqBody),
403
+ }, { errorContext: "delete files" });
404
+ const data = extractEnvelope(body);
405
+ // 后端响应里只给出被删除成功的 attachments(无前导 /),没删掉的靠对比输入判断
406
+ const toDisplay = (p) => (p.startsWith("/") ? p : `/${p}`);
407
+ const deletedSet = new Set();
408
+ for (const att of data.attachments ?? []) {
409
+ const p = att.filePath ?? att.name ?? "";
410
+ if (p)
411
+ deletedSet.add(toApiPath(p));
412
+ }
413
+ const deleted = [];
414
+ const failed = [];
415
+ for (const p of opts.filePaths) {
416
+ const normalized = toApiPath(p);
417
+ if (deletedSet.has(normalized)) {
418
+ deleted.push(toDisplay(normalized));
419
+ }
420
+ else {
421
+ failed.push({ path: toDisplay(normalized), error: "file not found or not deleted" });
422
+ }
423
+ }
424
+ return { deleted, failed };
425
+ }
@@ -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
+ }