@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelfield/cli",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Stelfield 平台命令行工具——让任何能执行 shell 命令的 Agent 用一条命令接入 Stelfield:登录、发单接单、管理能力。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 command reuses the same file_id.
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
- let req;
152
- try {
153
- req = await requestUpload(baseUrl, credOpts, {
154
- task_id: taskId,
155
- kind: 'deliverable',
156
- mime_type: f.mime,
157
- size_bytes: f.size,
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
- ...(f.preview ? { preview: true } : {}),
160
- }, { fetchImpl, idempotencyKey: `file-upload-${taskId}-${f.sha.slice(0, 32)}${f.preview ? '-pv' : ''}` });
161
- } catch (e) {
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
- // 从扩展名推导 MIME type(简单映射,够用)
1391
- const ext = flags.file.split('.').pop()?.toLowerCase() || '';
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
+ }
@@ -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 the same command
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
- let putResp;
247
- try {
248
- putResp = await putFetch(req.signed_put_url, {
249
- method: 'PUT',
250
- headers: { 'Content-Type': pkg.mime },
251
- body: pkg.buf,
252
- });
253
- } catch (netErr) {
254
- throw new WebDeliverError(`上传到存储失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
255
- }
256
- if (!putResp.ok) {
257
- throw new WebDeliverError(`上传文件到存储失败(状态 ${putResp.status}),可重新运行同一条命令。`, { status: putResp.status });
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('检查并登记网页包…');