@lark-apaas/miaoda-cli 0.1.0 → 0.1.1

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