@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.
- package/dist/api/db/api.js +278 -0
- package/dist/api/db/client.js +169 -0
- package/dist/api/db/index.js +16 -0
- package/dist/api/db/parsers.js +174 -0
- package/dist/api/db/sql-keywords.js +52 -0
- package/dist/api/db/types.js +10 -0
- package/dist/api/file/api.js +509 -0
- package/dist/api/file/client.js +199 -0
- package/dist/api/file/detect.js +56 -0
- package/dist/api/file/index.js +18 -0
- package/dist/api/file/parsers.js +72 -0
- package/dist/api/file/types.js +3 -0
- package/dist/api/index.js +5 -1
- package/dist/api/plugin/api.js +3 -3
- package/dist/cli/commands/db/index.js +209 -0
- package/dist/cli/commands/file/index.js +212 -0
- package/dist/cli/commands/index.js +4 -0
- package/dist/cli/commands/plugin/index.js +2 -1
- package/dist/cli/commands/shared.js +7 -8
- package/dist/cli/handlers/db/data.js +183 -0
- package/dist/cli/handlers/db/index.js +11 -0
- package/dist/cli/handlers/db/schema.js +174 -0
- package/dist/cli/handlers/db/sql.js +647 -0
- package/dist/cli/handlers/file/cp.js +221 -0
- package/dist/cli/handlers/file/index.js +13 -0
- package/dist/cli/handlers/file/ls.js +111 -0
- package/dist/cli/handlers/file/rm.js +264 -0
- package/dist/cli/handlers/file/sign.js +96 -0
- package/dist/cli/handlers/file/stat.js +97 -0
- package/dist/cli/handlers/index.js +2 -0
- package/dist/cli/help.js +188 -0
- package/dist/main.js +9 -1
- package/dist/utils/colors.js +98 -0
- package/dist/utils/error.js +14 -0
- package/dist/utils/fuzzy-match.js +91 -0
- package/dist/utils/http.js +31 -10
- package/dist/utils/index.js +3 -1
- package/dist/utils/output.js +75 -9
- package/dist/utils/render.js +192 -0
- package/package.json +4 -3
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SQL_KEYWORDS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* 常见 PG SQL 关键字白名单,用于 db sql 拼写错误的 did-you-mean 提示。
|
|
6
|
+
*
|
|
7
|
+
* # 选词原则
|
|
8
|
+
*
|
|
9
|
+
* PG 的关键字总数 700+(见 [pg_keyword 系统视图]),但 CLI 用户实际打错的几乎
|
|
10
|
+
* 都集中在核心动词 / 子句 / 类型 / 事务关键字。这里**少而精**,约 70 个:
|
|
11
|
+
*
|
|
12
|
+
* - DML 动词:SELECT / INSERT / UPDATE / DELETE / MERGE
|
|
13
|
+
* - 子句:FROM / WHERE / JOIN / GROUP / ORDER / HAVING / LIMIT / ...
|
|
14
|
+
* - 操作符词:AND / OR / NOT / IN / IS / NULL / LIKE / ...
|
|
15
|
+
* - DDL:CREATE / ALTER / DROP / TABLE / INDEX / VIEW / ...
|
|
16
|
+
* - 约束:PRIMARY / UNIQUE / FOREIGN / REFERENCES / CHECK / DEFAULT / ...
|
|
17
|
+
* - 事务:BEGIN / COMMIT / ROLLBACK / SAVEPOINT / TRANSACTION
|
|
18
|
+
* - 控制:IF / EXISTS / CASE / WHEN / THEN / ELSE / END / WITH / ...
|
|
19
|
+
*
|
|
20
|
+
* **不要往里加冷僻关键字**——候选集越大,短词越容易 false-positive 错配。
|
|
21
|
+
*/
|
|
22
|
+
exports.SQL_KEYWORDS = [
|
|
23
|
+
// DML 动词
|
|
24
|
+
"SELECT", "INSERT", "UPDATE", "DELETE", "MERGE",
|
|
25
|
+
// FROM / JOIN 系列
|
|
26
|
+
"FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "USING",
|
|
27
|
+
// 聚合 / 排序 / 分页
|
|
28
|
+
"GROUP", "ORDER", "BY", "HAVING", "LIMIT", "OFFSET", "FETCH",
|
|
29
|
+
// 集合操作
|
|
30
|
+
"UNION", "INTERSECT", "EXCEPT", "DISTINCT", "ALL",
|
|
31
|
+
// 别名 / 关联
|
|
32
|
+
"AS", "ON",
|
|
33
|
+
// 操作符词
|
|
34
|
+
"AND", "OR", "NOT", "IN", "IS", "NULL", "TRUE", "FALSE",
|
|
35
|
+
"BETWEEN", "LIKE", "ILIKE", "SIMILAR",
|
|
36
|
+
// DDL
|
|
37
|
+
"CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME",
|
|
38
|
+
"TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", "COLUMN", "CONSTRAINT", "SEQUENCE",
|
|
39
|
+
// 约束
|
|
40
|
+
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CHECK", "DEFAULT",
|
|
41
|
+
// 写入
|
|
42
|
+
"VALUES", "SET", "RETURNING", "INTO",
|
|
43
|
+
// 事务
|
|
44
|
+
"BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "TRANSACTION",
|
|
45
|
+
// 控制流 / CTE
|
|
46
|
+
"IF", "EXISTS", "REPLACE", "WITH", "RECURSIVE",
|
|
47
|
+
"CASE", "WHEN", "THEN", "ELSE", "END",
|
|
48
|
+
// 类型转换 / 时间提取
|
|
49
|
+
"CAST", "EXTRACT",
|
|
50
|
+
// 排序方向
|
|
51
|
+
"ASC", "DESC", "NULLS", "FIRST", "LAST",
|
|
52
|
+
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── dataloom InnerAPI 响应信封 ──
|
|
3
|
+
//
|
|
4
|
+
// 实际响应结构(与 file-storage 类似):
|
|
5
|
+
// { "data": { ...业务字段... }, "status_code": "0", "error_msg": "..." }
|
|
6
|
+
//
|
|
7
|
+
// - `status_code == "0"` 成功
|
|
8
|
+
// - `status_code != "0"` 业务错误(如 PG SQLSTATE 映射后的 code)
|
|
9
|
+
// - 业务字段统一在 `data` 下:`results` / `schema` / `ddl` 等
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,509 @@
|
|
|
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
|
+
/**
|
|
301
|
+
* 调用 upload callback 拿到对象元数据。
|
|
302
|
+
*
|
|
303
|
+
* 网关 IDL 在 metadata 字段加了 api.response.converter = "decode",正常路径下
|
|
304
|
+
* HTTP 响应里的 metadata 已经被网关从字符串解码成对象;这里两种形态都兼容:
|
|
305
|
+
* - object → 直接当 CallbackObjectVO 用(网关解码场景)
|
|
306
|
+
* - string → JSON.parse 出来用(后端原始形态 / 网关行为变化兜底)
|
|
307
|
+
*
|
|
308
|
+
* 解析失败 / metadata 缺失时返回空对象,由 uploadFile 用本地兜底字段填充。
|
|
309
|
+
*/
|
|
310
|
+
async function uploadCallback(appId, req) {
|
|
311
|
+
const url = `/v1/storage/app/${encodeURIComponent(appId)}/object/callback`;
|
|
312
|
+
const body = await (0, client_1.doPost)(url, req, { errorContext: "upload callback" });
|
|
313
|
+
const data = extractEnvelope(body);
|
|
314
|
+
const metadata = data.metadata;
|
|
315
|
+
if (!metadata) {
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
if (typeof metadata === "object") {
|
|
319
|
+
return metadata;
|
|
320
|
+
}
|
|
321
|
+
// string 形态兜底
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(metadata);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
(0, logger_1.debug)(`upload callback metadata json parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 上传文件(3 步:preUpload → PUT uploadURL → callback)。
|
|
332
|
+
* PUT 步骤直接 fetch uploadURL(对象存储直传,绕开框架 HttpClient)。
|
|
333
|
+
*
|
|
334
|
+
* 注意:opts.remotePath 仅作为目录前缀传给服务端;最终对象 key 由服务端唯一生成
|
|
335
|
+
* (形如 "<前缀>/<16位ID>.<扩展名>"),从 preUpload 响应的 filePath 字段取回。
|
|
336
|
+
*/
|
|
337
|
+
async function uploadFile(opts) {
|
|
338
|
+
const pre = await preUpload(opts.appId, {
|
|
339
|
+
fileName: opts.fileName,
|
|
340
|
+
fileSize: opts.fileSize,
|
|
341
|
+
contentType: opts.contentType,
|
|
342
|
+
filePath: opts.remotePath ? toApiPath(opts.remotePath) : undefined,
|
|
343
|
+
});
|
|
344
|
+
if (!pre.uploadURL || !pre.uploadID) {
|
|
345
|
+
throw new error_1.AppError("FILE_UPLOAD_FAILED", "preUpload did not return uploadURL/uploadID");
|
|
346
|
+
}
|
|
347
|
+
const body = await opts.readFile();
|
|
348
|
+
let etag = "";
|
|
349
|
+
try {
|
|
350
|
+
// Node 20+ 内置 fetch 运行时接受 Buffer,但 TS 的 BodyInit 要求更严格的类型;
|
|
351
|
+
// 这里复制到独立 ArrayBuffer 以满足 lib.dom.d.ts 的 BodyInit 约束
|
|
352
|
+
const ab = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
353
|
+
const uploadStart = Date.now();
|
|
354
|
+
// Content-Disposition 用 attachment + filename 编码原始文件名。TOS 会把这个
|
|
355
|
+
// header 作为对象 metadata 存住,服务端 callback 阶段 HeadObject 读回并解析
|
|
356
|
+
// filename 写入 DB。我们要不传 header,服务端走兜底会把 storage key 当文件名。
|
|
357
|
+
const res = await fetch(pre.uploadURL, {
|
|
358
|
+
method: "PUT",
|
|
359
|
+
headers: {
|
|
360
|
+
"Content-Type": opts.contentType,
|
|
361
|
+
"Content-Length": String(opts.fileSize),
|
|
362
|
+
"Content-Disposition": `attachment; filename="${sanitizeFileName(opts.fileName)}"`,
|
|
363
|
+
},
|
|
364
|
+
body: ab,
|
|
365
|
+
});
|
|
366
|
+
(0, logger_1.debug)(`http PUT <upload-cdn> ${String(res.status)} cost=${String(Date.now() - uploadStart)}ms size=${String(opts.fileSize)}`);
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
throw new error_1.AppError("FILE_UPLOAD_FAILED", `Upload PUT failed with status ${String(res.status)}`);
|
|
369
|
+
}
|
|
370
|
+
etag = res.headers.get("ETag") ?? "";
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
if (err instanceof error_1.AppError)
|
|
374
|
+
throw err;
|
|
375
|
+
throw new error_1.AppError("FILE_UPLOAD_FAILED", `Upload PUT failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
376
|
+
}
|
|
377
|
+
if (!etag) {
|
|
378
|
+
throw new error_1.AppError("FILE_UPLOAD_FAILED", "Upload PUT returned no ETag");
|
|
379
|
+
}
|
|
380
|
+
// callback 返回服务端实际生成的对象元数据(filePath / file_name / download_url)。
|
|
381
|
+
// 这是 CLI 拿到真实存储路径的唯一来源——preUpload 不暴露 path,避免客户端再做拼接。
|
|
382
|
+
// callback 网络失败 / metadata 解析失败时仍按"上传成功"处理:文件已经在对象存储里,
|
|
383
|
+
// 缺失的字段(如 download_url)只是展示降级;用本地已知信息兜底。
|
|
384
|
+
let metadata = {};
|
|
385
|
+
try {
|
|
386
|
+
metadata = await uploadCallback(opts.appId, { uploadID: pre.uploadID, eTag: etag });
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
(0, logger_1.debug)(`upload callback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
390
|
+
}
|
|
391
|
+
const path = metadata.filePath
|
|
392
|
+
? (metadata.filePath.startsWith("/") ? metadata.filePath : "/" + metadata.filePath)
|
|
393
|
+
: (opts.remotePath ?? "/" + opts.fileName);
|
|
394
|
+
const result = {
|
|
395
|
+
// 优先取服务端 ObjectVO.name(来自 PUT 时带的 Content-Disposition),
|
|
396
|
+
// 与后续 file ls / file stat 返回的展示名保持一致;缺失时降级用本地 fileName。
|
|
397
|
+
file_name: metadata.name ?? opts.fileName,
|
|
398
|
+
path,
|
|
399
|
+
size: metadata.metadata?.contentLength ?? opts.fileSize,
|
|
400
|
+
type: metadata.metadata?.mimeType ?? opts.contentType,
|
|
401
|
+
};
|
|
402
|
+
if (metadata.downloadURL) {
|
|
403
|
+
result.download_url = metadata.downloadURL;
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* 把文件名清理成可安全放进 Content-Disposition `filename="..."` 的形态。
|
|
409
|
+
* 与 fullstack-plugin 的 sanitizeFileName 行为一致:
|
|
410
|
+
* 1. 去掉对 TOS / 文件系统不友好的字符 [: " \ / * ? < > | , ;]
|
|
411
|
+
* 2. encodeURIComponent 把非 ASCII(中文等)做百分号编码,保证 header 合法
|
|
412
|
+
* 3. 处理后为空时退回 "download_file" 兜底
|
|
413
|
+
*/
|
|
414
|
+
function sanitizeFileName(fileName) {
|
|
415
|
+
const illegalChars = /[:"\\/*?<>|,;]/g;
|
|
416
|
+
return encodeURIComponent(fileName.replace(illegalChars, "")) || "download_file";
|
|
417
|
+
}
|
|
418
|
+
// ── 预签下载 URL ──
|
|
419
|
+
/**
|
|
420
|
+
* 获取预签下载 URL。
|
|
421
|
+
* 后端:POST /v1/storage/app/{appId}/object/sign/{bucketId}/{filePath}
|
|
422
|
+
*/
|
|
423
|
+
async function signDownload(opts) {
|
|
424
|
+
const bucketId = await (0, client_1.getDefaultBucketId)(opts.appId);
|
|
425
|
+
const url = `/v1/storage/app/${encodeURIComponent(opts.appId)}/object/sign/${encodeURIComponent(bucketId)}/${encodePath(opts.filePath)}`;
|
|
426
|
+
const reqBody = {
|
|
427
|
+
bucketID: bucketId,
|
|
428
|
+
filePath: toApiPath(opts.filePath),
|
|
429
|
+
expiresIn: opts.expiresIn,
|
|
430
|
+
};
|
|
431
|
+
const body = await (0, client_1.doPost)(url, reqBody, {
|
|
432
|
+
errorContext: "sign download URL",
|
|
433
|
+
});
|
|
434
|
+
// 后端对不存在文件走业务 ErrorCode(status_code=k_img_ec_000034),
|
|
435
|
+
// 由 ensureSuccess → BIZ_ERR_MAP 统一抛出 FILE_NOT_FOUND,此处无需兜底
|
|
436
|
+
const data = extractEnvelope(body);
|
|
437
|
+
const signed = data.signedURL ?? "";
|
|
438
|
+
const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
|
|
439
|
+
const displayPath = opts.filePath.startsWith("/") ? opts.filePath : `/${opts.filePath}`;
|
|
440
|
+
const fileName = displayPath.split("/").pop() ?? displayPath;
|
|
441
|
+
return {
|
|
442
|
+
file_name: fileName,
|
|
443
|
+
path: displayPath,
|
|
444
|
+
signed_url: signed,
|
|
445
|
+
expires_at: expiresAt,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* 下载文件到本地。直接 GET signedURL(对象存储直取,绕开框架 HttpClient)。
|
|
450
|
+
*/
|
|
451
|
+
async function downloadFile(opts) {
|
|
452
|
+
let res;
|
|
453
|
+
const downloadStart = Date.now();
|
|
454
|
+
try {
|
|
455
|
+
res = await fetch(opts.signedURL);
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
(0, logger_1.debug)(`http GET <download-cdn> 0 cost=${String(Date.now() - downloadStart)}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
459
|
+
throw new error_1.AppError("FILE_DOWNLOAD_FAILED", `Download GET failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
460
|
+
}
|
|
461
|
+
(0, logger_1.debug)(`http GET <download-cdn> ${String(res.status)} cost=${String(Date.now() - downloadStart)}ms`);
|
|
462
|
+
if (!res.ok) {
|
|
463
|
+
throw new error_1.AppError("FILE_DOWNLOAD_FAILED", `Download failed with status ${String(res.status)}`);
|
|
464
|
+
}
|
|
465
|
+
const array = new Uint8Array(await res.arrayBuffer());
|
|
466
|
+
const buf = Buffer.from(array);
|
|
467
|
+
await opts.writeFile(buf);
|
|
468
|
+
return buf.length;
|
|
469
|
+
}
|
|
470
|
+
// ── 批量删除(best-effort) ──
|
|
471
|
+
/**
|
|
472
|
+
* 批量删除文件。
|
|
473
|
+
* 后端:DELETE /v1/storage/app/{appId}/bucket/{bucketId}/delete
|
|
474
|
+
*/
|
|
475
|
+
async function deleteFiles(opts) {
|
|
476
|
+
const bucketId = await (0, client_1.getDefaultBucketId)(opts.appId);
|
|
477
|
+
const url = `/v1/storage/app/${encodeURIComponent(opts.appId)}/bucket/${encodeURIComponent(bucketId)}/delete`;
|
|
478
|
+
const reqBody = {
|
|
479
|
+
bucketID: bucketId,
|
|
480
|
+
filePaths: opts.filePaths.map(toApiPath),
|
|
481
|
+
};
|
|
482
|
+
const body = await (0, client_1.doRequest)({
|
|
483
|
+
method: "DELETE",
|
|
484
|
+
url,
|
|
485
|
+
headers: { "Content-Type": "application/json" },
|
|
486
|
+
body: JSON.stringify(reqBody),
|
|
487
|
+
}, { errorContext: "delete files" });
|
|
488
|
+
const data = extractEnvelope(body);
|
|
489
|
+
// 后端响应里只给出被删除成功的 attachments(无前导 /),没删掉的靠对比输入判断
|
|
490
|
+
const toDisplay = (p) => (p.startsWith("/") ? p : `/${p}`);
|
|
491
|
+
const deletedSet = new Set();
|
|
492
|
+
for (const att of data.attachments ?? []) {
|
|
493
|
+
const p = att.filePath ?? att.name ?? "";
|
|
494
|
+
if (p)
|
|
495
|
+
deletedSet.add(toApiPath(p));
|
|
496
|
+
}
|
|
497
|
+
const deleted = [];
|
|
498
|
+
const failed = [];
|
|
499
|
+
for (const p of opts.filePaths) {
|
|
500
|
+
const normalized = toApiPath(p);
|
|
501
|
+
if (deletedSet.has(normalized)) {
|
|
502
|
+
deleted.push(toDisplay(normalized));
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
failed.push({ path: toDisplay(normalized), error: "file not found or not deleted" });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { deleted, failed };
|
|
509
|
+
}
|