@stelfield/cli 0.12.7 → 0.12.8
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/package.json +1 -1
- package/src/file-deliver.js +25 -27
- package/src/index.js +3 -18
- package/src/storage-put.js +76 -0
- package/src/web-deliver.js +27 -34
package/package.json
CHANGED
package/src/file-deliver.js
CHANGED
|
@@ -16,6 +16,7 @@ import { basename, extname } from 'node:path';
|
|
|
16
16
|
import { getTaskDeliverables, requestUpload, runtimeAct } from './api.js';
|
|
17
17
|
import { humanSize } from './media-upload.js';
|
|
18
18
|
import { buildWebDeliverIdemKey, usageFields, WebDeliverError } from './web-deliver.js';
|
|
19
|
+
import { putWithRetry } from './storage-put.js';
|
|
19
20
|
|
|
20
21
|
/** Same cap as the backend upload route (_MAX_FILE_SIZE_BYTES). */
|
|
21
22
|
export const FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
@@ -146,35 +147,32 @@ export async function deliverFiles(ctx) {
|
|
|
146
147
|
const uploaded = [];
|
|
147
148
|
const putFetch = fetchImpl || globalThis.fetch;
|
|
148
149
|
for (const f of prepared) {
|
|
149
|
-
// 1. upload address. The key depends on the bytes, so re-running the same
|
|
150
|
+
// 1+2. upload address, then direct upload. The first key depends on the bytes, so re-running the same
|
|
151
|
+
// command reuses the same file_id; after a storage 403 the second try asks with a new key (fresh address).
|
|
150
152
|
progress(`申请上传(${f.filename},${humanSize(f.size)})…`);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
const baseKey = `file-upload-${taskId}-${f.sha.slice(0, 32)}${f.preview ? '-pv' : ''}`;
|
|
154
|
+
const req = await putWithRetry({
|
|
155
|
+
requestAddress: async (attempt) => {
|
|
156
|
+
try {
|
|
157
|
+
return await requestUpload(baseUrl, credOpts, {
|
|
158
|
+
task_id: taskId,
|
|
159
|
+
kind: 'deliverable',
|
|
160
|
+
mime_type: f.mime,
|
|
161
|
+
size_bytes: f.size,
|
|
162
|
+
filename: f.filename,
|
|
163
|
+
...(f.preview ? { preview: true } : {}),
|
|
164
|
+
}, { fetchImpl, idempotencyKey: attempt === 1 ? baseKey : `${baseKey}-r${attempt}` });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
throw wrap(e, `申请上传 ${f.filename} `);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
putFetch,
|
|
170
|
+
mime: f.mime,
|
|
171
|
+
buf: f.buf,
|
|
158
172
|
filename: f.filename,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
throw wrap(e, `申请上传 ${f.filename} `);
|
|
163
|
-
}
|
|
164
|
-
if (!req || !req.signed_put_url || !req.file_id) {
|
|
165
|
-
throw new WebDeliverError('平台没有返回上传地址,请稍后重试。');
|
|
166
|
-
}
|
|
167
|
-
// 2. direct upload
|
|
168
|
-
progress(`上传 ${f.filename}…`);
|
|
169
|
-
let putResp;
|
|
170
|
-
try {
|
|
171
|
-
putResp = await putFetch(req.signed_put_url, { method: 'PUT', headers: { 'Content-Type': f.mime }, body: f.buf });
|
|
172
|
-
} catch (netErr) {
|
|
173
|
-
throw new WebDeliverError(`上传 ${f.filename} 失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
|
|
174
|
-
}
|
|
175
|
-
if (!putResp.ok) {
|
|
176
|
-
throw new WebDeliverError(`上传 ${f.filename} 失败(状态 ${putResp.status}),可重新运行同一条命令。`, { status: putResp.status });
|
|
177
|
-
}
|
|
173
|
+
progress,
|
|
174
|
+
makeError: (msg, o) => new WebDeliverError(msg, o),
|
|
175
|
+
});
|
|
178
176
|
uploaded.push({ fileId: req.file_id, filename: f.filename, sizeBytes: f.size });
|
|
179
177
|
}
|
|
180
178
|
|
package/src/index.js
CHANGED
|
@@ -115,7 +115,7 @@ import { resolveIntroInput, PROFILE_INTRO_MAX_CHARS, PROFILE_INTRO_LENGTH_HINT,
|
|
|
115
115
|
import { runMediaUpload, prepareIntroMedia } from './media-commands.js';
|
|
116
116
|
import { findLocalLikeTargets } from './local-media-refs.js';
|
|
117
117
|
import { deliverWebPackage, formatWebDeliver, usageFields, WebDeliverError } from './web-deliver.js';
|
|
118
|
-
import { deliverFiles, formatFileDeliver } from './file-deliver.js';
|
|
118
|
+
import { deliverFiles, formatFileDeliver, mimeFor } from './file-deliver.js';
|
|
119
119
|
import { runLibra, isLibraCommand } from './libra-commands.js';
|
|
120
120
|
import { runRefund, isRefundCommand } from './refund-commands.js';
|
|
121
121
|
import { runExtension } from './extension-commands.js';
|
|
@@ -1387,23 +1387,8 @@ export async function run(argv, deps = {}) {
|
|
|
1387
1387
|
return 1;
|
|
1388
1388
|
}
|
|
1389
1389
|
|
|
1390
|
-
//
|
|
1391
|
-
|
|
1392
|
-
const MIME_MAP = {
|
|
1393
|
-
pdf: 'application/pdf',
|
|
1394
|
-
png: 'image/png',
|
|
1395
|
-
jpg: 'image/jpeg',
|
|
1396
|
-
jpeg: 'image/jpeg',
|
|
1397
|
-
gif: 'image/gif',
|
|
1398
|
-
webp: 'image/webp',
|
|
1399
|
-
txt: 'text/plain',
|
|
1400
|
-
md: 'text/markdown',
|
|
1401
|
-
json: 'application/json',
|
|
1402
|
-
zip: 'application/zip',
|
|
1403
|
-
doc: 'application/msword',
|
|
1404
|
-
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1405
|
-
};
|
|
1406
|
-
mimeType = MIME_MAP[ext] || 'application/octet-stream';
|
|
1390
|
+
// MIME type from the extension, same table as file delivery (v0.26.49: the old local table lacked .html etc.)
|
|
1391
|
+
mimeType = mimeFor(flags.file);
|
|
1407
1392
|
|
|
1408
1393
|
// 可见性:accepted-only(缺省)或 public
|
|
1409
1394
|
// CLI 参数用连字符(accepted-only),后端 API 用下划线(accepted_only)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Direct upload of one file to platform storage, with one automatic retry on a 403.
|
|
2
|
+
//
|
|
3
|
+
// Why (v0.26.49): the upload address from `POST /uploads` is only valid for 5 minutes. A storage 403 almost
|
|
4
|
+
// always means "this address is stale" (or the file type no longer matches what the address was signed for),
|
|
5
|
+
// not that the platform is down - but agents reported it as "the platform upload returns 403" and kept retrying
|
|
6
|
+
// with the same stale address. This helper asks for a fresh address and retries once, and when it still fails it
|
|
7
|
+
// says which of the known causes it is and what to do next.
|
|
8
|
+
|
|
9
|
+
/** Why the storage said no, from the response text (OSS answers with an XML error document). */
|
|
10
|
+
export function classifyStorageFailure(status, bodyText) {
|
|
11
|
+
const text = String(bodyText || '');
|
|
12
|
+
if (status === 403) {
|
|
13
|
+
if (/expire/i.test(text)) return 'expired';
|
|
14
|
+
if (/SignatureDoesNotMatch/i.test(text)) return 'signature';
|
|
15
|
+
return 'forbidden';
|
|
16
|
+
}
|
|
17
|
+
if (status >= 500) return 'storage_down';
|
|
18
|
+
return 'rejected';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Chinese explanation + next step for the agent (and for whoever reads its report). */
|
|
22
|
+
export function describeStorageFailure(kind, filename, status) {
|
|
23
|
+
const head = `上传 ${filename} 失败(存储返回 ${status}):`;
|
|
24
|
+
switch (kind) {
|
|
25
|
+
case 'expired':
|
|
26
|
+
return `${head}上传地址已过期(只有 5 分钟有效),换新地址重试也没成功。请过一会儿重新运行同一条命令;仍失败请把这条提示原样告诉平台。`;
|
|
27
|
+
case 'signature':
|
|
28
|
+
return `${head}文件类型与申请上传时不一致,存储拒绝了。请用命令自带的上传,不要手动改 Content-Type。`;
|
|
29
|
+
case 'storage_down':
|
|
30
|
+
return `${head}存储暂时不可用,请过几分钟重新运行同一条命令。`;
|
|
31
|
+
case 'forbidden':
|
|
32
|
+
return `${head}存储拒绝了这次上传,换新地址重试仍被拒。请重新运行同一条命令;仍失败请把这条提示原样告诉平台。`;
|
|
33
|
+
default:
|
|
34
|
+
return `${head}存储没有接受这次上传,请重新运行同一条命令。`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ask for an upload address and PUT the bytes; on a 403 ask for a fresh address and try once more.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} p
|
|
42
|
+
* @param {(attempt:number) => Promise<{signed_put_url:string, file_id:string}>} p.requestAddress
|
|
43
|
+
* attempt is 1 or 2; the caller must use a different idempotency key for attempt 2
|
|
44
|
+
* @param {Function} p.putFetch
|
|
45
|
+
* @param {string} p.mime
|
|
46
|
+
* @param {Buffer} p.buf
|
|
47
|
+
* @param {string} p.filename
|
|
48
|
+
* @param {(msg:string, opts?:object) => Error} p.makeError builds the caller's own error class
|
|
49
|
+
* @param {(msg:string) => void} [p.progress]
|
|
50
|
+
* @returns {Promise<{signed_put_url:string, file_id:string}>} the address record that worked
|
|
51
|
+
*/
|
|
52
|
+
export async function putWithRetry(p) {
|
|
53
|
+
const { requestAddress, putFetch, mime, buf, filename, makeError } = p;
|
|
54
|
+
const progress = p.progress || (() => {});
|
|
55
|
+
let last = null;
|
|
56
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
57
|
+
const req = await requestAddress(attempt);
|
|
58
|
+
if (!req || !req.signed_put_url || !req.file_id) {
|
|
59
|
+
throw makeError('平台没有返回上传地址,请稍后重试。');
|
|
60
|
+
}
|
|
61
|
+
let resp;
|
|
62
|
+
try {
|
|
63
|
+
resp = await putFetch(req.signed_put_url, { method: 'PUT', headers: { 'Content-Type': mime }, body: buf });
|
|
64
|
+
} catch (netErr) {
|
|
65
|
+
throw makeError(`上传 ${filename} 失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
|
|
66
|
+
}
|
|
67
|
+
if (resp.ok) return req;
|
|
68
|
+
let bodyText = '';
|
|
69
|
+
try { bodyText = await resp.text(); } catch { /* the status alone is enough */ }
|
|
70
|
+
last = { status: resp.status, kind: classifyStorageFailure(resp.status, bodyText) };
|
|
71
|
+
// Only a 403 can be a stale address; anything else will not change with a new one.
|
|
72
|
+
if (resp.status !== 403 || attempt === 2) break;
|
|
73
|
+
progress('上传地址可能已过期,换新地址重试…');
|
|
74
|
+
}
|
|
75
|
+
throw makeError(describeStorageFailure(last.kind, filename, last.status), { status: last.status });
|
|
76
|
+
}
|
package/src/web-deliver.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// Limits mirror the backend risk policy (RISK_WEB_ZIP_MAX_MB = 20, unzipped 50MB, 300 files).
|
|
19
19
|
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
|
+
import { putWithRetry } from './storage-put.js';
|
|
21
22
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
22
23
|
import { basename, extname } from 'node:path';
|
|
23
24
|
import { getTaskDeliverables, registerWebPackage, requestUpload, runtimeAct } from './api.js';
|
|
@@ -220,42 +221,34 @@ export async function deliverWebPackage(ctx) {
|
|
|
220
221
|
return new WebDeliverError(text, { status: e && typeof e.status === 'number' ? e.status : null });
|
|
221
222
|
};
|
|
222
223
|
|
|
223
|
-
// 1. ask for an upload address. The key depends on the file bytes, so running
|
|
224
|
-
// again gets the same file_id back instead of piling up duplicate uploads
|
|
224
|
+
// 1+2. ask for an upload address, then upload directly. The first key depends on the file bytes, so running
|
|
225
|
+
// the same command again gets the same file_id back instead of piling up duplicate uploads; after a storage
|
|
226
|
+
// 403 the second try asks with a new key (fresh address).
|
|
225
227
|
progress(`申请上传(${pkg.filename},${humanSize(pkg.size)})…`);
|
|
226
|
-
let req;
|
|
227
|
-
try {
|
|
228
|
-
req = await requestUpload(baseUrl, credOpts, {
|
|
229
|
-
task_id: taskId,
|
|
230
|
-
kind: 'deliverable',
|
|
231
|
-
mime_type: pkg.mime,
|
|
232
|
-
size_bytes: pkg.size,
|
|
233
|
-
filename: pkg.filename,
|
|
234
|
-
web_package: true,
|
|
235
|
-
}, { fetchImpl, idempotencyKey: `web-upload-${taskId}-${sha.slice(0, 32)}` });
|
|
236
|
-
} catch (e) {
|
|
237
|
-
throw wrap(e, '申请上传');
|
|
238
|
-
}
|
|
239
|
-
if (!req || !req.signed_put_url || !req.file_id) {
|
|
240
|
-
throw new WebDeliverError('平台没有返回上传地址,请稍后重试。');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// 2. direct upload
|
|
244
|
-
progress('上传文件…');
|
|
245
228
|
const putFetch = fetchImpl || globalThis.fetch;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
229
|
+
const baseKey = `web-upload-${taskId}-${sha.slice(0, 32)}`;
|
|
230
|
+
const req = await putWithRetry({
|
|
231
|
+
requestAddress: async (attempt) => {
|
|
232
|
+
try {
|
|
233
|
+
return await requestUpload(baseUrl, credOpts, {
|
|
234
|
+
task_id: taskId,
|
|
235
|
+
kind: 'deliverable',
|
|
236
|
+
mime_type: pkg.mime,
|
|
237
|
+
size_bytes: pkg.size,
|
|
238
|
+
filename: pkg.filename,
|
|
239
|
+
web_package: true,
|
|
240
|
+
}, { fetchImpl, idempotencyKey: attempt === 1 ? baseKey : `${baseKey}-r${attempt}` });
|
|
241
|
+
} catch (e) {
|
|
242
|
+
throw wrap(e, '申请上传');
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
putFetch,
|
|
246
|
+
mime: pkg.mime,
|
|
247
|
+
buf: pkg.buf,
|
|
248
|
+
filename: pkg.filename,
|
|
249
|
+
progress,
|
|
250
|
+
makeError: (msg, o) => new WebDeliverError(msg, o),
|
|
251
|
+
});
|
|
259
252
|
|
|
260
253
|
// 3. register: the platform unpacks and checks it
|
|
261
254
|
progress('检查并登记网页包…');
|