@stelfield/cli 0.0.0-stage → 0.12.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,332 @@
1
+ // Web-package delivery for sellers (web hosting / CLI 0.10.1).
2
+ //
3
+ // What this does for the user: `stelfield deliver upload --task <id> --web <file>` hands in a
4
+ // single HTML page or a zip site as the delivery of a task, in ONE command. Four steps, all
5
+ // offered by the platform already:
6
+ // 1. ask for an upload address (POST /uploads, kind=deliverable, web_package=true)
7
+ // 2. PUT the bytes straight to storage
8
+ // 3. register the package so the platform can check and unpack it
9
+ // (POST /tasks/{id}/web-packages)
10
+ // 4. deliver it with the normal deliver flow (same idempotency-key rule as `task deliver`)
11
+ //
12
+ // This file holds everything that is NOT command parsing:
13
+ // - the local pre-check (exists / regular file / extension / size / zip header),
14
+ // - the Chinese fix-it advice for each backend reject_code,
15
+ // - the "upload restricted" explanation (reason, time left, how to appeal),
16
+ // - the four-step flow itself, with fetch injectable for tests.
17
+ //
18
+ // Limits mirror the backend risk policy (RISK_WEB_ZIP_MAX_MB = 20, unzipped 50MB, 300 files).
19
+
20
+ import { createHash } from 'node:crypto';
21
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
22
+ import { basename, extname } from 'node:path';
23
+ import { getTaskDeliverables, registerWebPackage, requestUpload, runtimeAct } from './api.js';
24
+ import { humanSize } from './media-upload.js';
25
+
26
+ export const WEB_ZIP_MAX_BYTES = 20 * 1024 * 1024;
27
+ /** A single HTML file has no zip limit; the unzipped cap (50MB) is the ceiling. */
28
+ export const WEB_HTML_MAX_BYTES = 50 * 1024 * 1024;
29
+ export const WEB_UNZIPPED_MAX_MB = 50;
30
+ export const WEB_FILES_MAX = 300;
31
+
32
+ const MIME_BY_EXT = {
33
+ '.html': 'text/html',
34
+ '.htm': 'text/html',
35
+ '.zip': 'application/zip',
36
+ };
37
+
38
+ /** Error carrying a ready-to-print Chinese message and the exit code to use. */
39
+ export class WebDeliverError extends Error {
40
+ constructor(message, { exitCode = 1, status = null } = {}) {
41
+ super(message);
42
+ this.name = 'WebDeliverError';
43
+ this.exitCode = exitCode;
44
+ this.status = status;
45
+ }
46
+ }
47
+
48
+ // ============================================================
49
+ // local pre-check
50
+ // ============================================================
51
+
52
+ /**
53
+ * Check the file before anything goes to the network.
54
+ * @returns {{absPath:string, filename:string, mime:string, isZip:boolean, size:number, buf:Buffer}}
55
+ * @throws {WebDeliverError} exit code 2 (the caller can fix it and retry)
56
+ */
57
+ export function prepareWebPackage(file) {
58
+ if (!file) {
59
+ throw new WebDeliverError('缺少 --web 参数:请给出要交付的网页文件(单个 .html,或根目录有 index.html 的 .zip)。', { exitCode: 2 });
60
+ }
61
+ let absPath;
62
+ let st;
63
+ try {
64
+ absPath = realpathSync(file);
65
+ st = statSync(absPath);
66
+ } catch {
67
+ throw new WebDeliverError(`找不到文件:${file}。请检查路径。`, { exitCode: 2 });
68
+ }
69
+ if (!st.isFile()) {
70
+ throw new WebDeliverError(`${file} 不是文件。网页包必须是单个 .html 文件,或打包好的 .zip(不能直接给文件夹,请先压缩成 zip)。`, { exitCode: 2 });
71
+ }
72
+ const ext = extname(absPath).toLowerCase();
73
+ const mime = MIME_BY_EXT[ext];
74
+ if (!mime) {
75
+ throw new WebDeliverError(`不支持的文件类型「${ext || '无扩展名'}」。网页包只能是 .html / .htm(单个页面)或 .zip(站点包,入口页请命名为 index.html 并放在根目录)。`, { exitCode: 2 });
76
+ }
77
+ const isZip = mime === 'application/zip';
78
+ if (st.size === 0) {
79
+ throw new WebDeliverError('文件是空的。请确认网页内容已生成,再重新交付。', { exitCode: 2 });
80
+ }
81
+ if (isZip && st.size > WEB_ZIP_MAX_BYTES) {
82
+ throw new WebDeliverError(`zip 有 ${humanSize(st.size)},超过 20MB 上限。请删掉不必要的大文件(大图可压缩,视频建议改用外链或去掉)后重新打包。`, { exitCode: 2 });
83
+ }
84
+ if (!isZip && st.size > WEB_HTML_MAX_BYTES) {
85
+ throw new WebDeliverError(`HTML 文件有 ${humanSize(st.size)},超过 ${WEB_UNZIPPED_MAX_MB}MB 上限。请精简内容,或拆成 zip 站点包(资源放包内,zip ≤ 20MB)。`, { exitCode: 2 });
86
+ }
87
+ let buf;
88
+ try {
89
+ buf = readFileSync(absPath);
90
+ } catch {
91
+ throw new WebDeliverError(`读不了文件:${file}。`, { exitCode: 2 });
92
+ }
93
+ if (isZip && !(buf[0] === 0x50 && buf[1] === 0x4b)) {
94
+ throw new WebDeliverError('这个 .zip 文件头不对,不是有效的 zip。请用 zip 工具重新打包(例如在站点目录里运行 `zip -r ../site.zip .`)。', { exitCode: 2 });
95
+ }
96
+ return { absPath, filename: basename(absPath), mime, isZip, size: st.size, buf };
97
+ }
98
+
99
+ // ============================================================
100
+ // error advice
101
+ // ============================================================
102
+
103
+ /** reject_code -> what to tell the agent to fix. */
104
+ export const REJECT_ADVICE = {
105
+ too_large: '网页包太大:zip 不能超过 20MB,解压后不能超过 50MB。请删掉不必要的大文件或压缩图片后重新打包。',
106
+ too_many_files: `包里的文件太多:最多 ${WEB_FILES_MAX} 个文件。请合并或删掉不用的文件(例如 node_modules、日志、素材原图)后重新打包。`,
107
+ no_index: '包的根目录里没有任何 .html 文件。请把入口页放在 zip 的最外层并命名为 index.html(注意不要多套一层文件夹:应在站点目录里运行 `zip -r ../site.zip .`,而不是压缩文件夹本身)。',
108
+ ambiguous_index: '包的根目录里有多个 .html 文件,但没有 index.html,平台无法判断哪个是入口页。请把入口页改名为 index.html(根目录只有一个 .html 时平台会自动当作入口)后重新打包。',
109
+ bad_path: '包里有不合法的路径(含 `..` 或绝对路径、符号链接)。请用相对路径重新打包,不要引用包外文件。',
110
+ forbidden_file: '包里含有禁止的文件:可执行文件和服务端脚本(.php .asp .aspx .jsp .cgi .pl .py .rb .sh、.exe 等)不能放进网页包。网页只能是静态内容(HTML / CSS / JS / 图片 / 字体)。请删掉后重新打包。',
111
+ not_zip: '平台没能把它当作 zip 解开。请用 zip 工具重新打包后再试。',
112
+ empty: '包里没有可展示的文件。请确认网页内容已放进去,再重新打包。',
113
+ };
114
+
115
+ function bodyOf(e) {
116
+ const b = e && e.body && typeof e.body === 'object' ? e.body : {};
117
+ const d = b.detail && typeof b.detail === 'object' ? b.detail : {};
118
+ return { ...d, ...b };
119
+ }
120
+
121
+ /** "3 天 4 小时" / "5 小时 10 分钟" / "不到 1 分钟"; null when `until` cannot be read. */
122
+ export function formatRemaining(until, now = Date.now()) {
123
+ const t = Date.parse(until);
124
+ if (!Number.isFinite(t)) return null;
125
+ const sec = Math.floor((t - now) / 1000);
126
+ if (sec <= 0) return '即将解除';
127
+ const d = Math.floor(sec / 86400);
128
+ const h = Math.floor((sec % 86400) / 3600);
129
+ const m = Math.floor((sec % 3600) / 60);
130
+ if (d > 0) return h > 0 ? `${d} 天 ${h} 小时` : `${d} 天`;
131
+ if (h > 0) return m > 0 ? `${h} 小时 ${m} 分钟` : `${h} 小时`;
132
+ return m > 0 ? `${m} 分钟` : '不到 1 分钟';
133
+ }
134
+
135
+ /**
136
+ * Turn an API error into a user-facing Chinese message; null when it is not a web-delivery error
137
+ * (the caller then prints the plain error text).
138
+ */
139
+ export function mapWebDeliverError(e, now = Date.now()) {
140
+ if (!e || typeof e.status !== 'number') return null;
141
+ const b = bodyOf(e);
142
+ const code = b.error;
143
+
144
+ if (e.status === 403 && code === 'web_upload_restricted') {
145
+ const lines = ['暂时不能交付网页:你的网页交付功能目前处于限制期。'];
146
+ if (b.reason) lines.push(` 限制原因:${b.reason}`);
147
+ if (b.until) {
148
+ const left = formatRemaining(b.until, now);
149
+ lines.push(` 解除时间:${b.until}${left ? `(还剩 ${left})` : ''}`);
150
+ }
151
+ lines.push(' 限制到期会自动解除,期间不影响其他类型的交付(文字、普通文件)。');
152
+ lines.push(' 如果认为判断有误:请告诉你的用户,到任务的「交付物」页点「申诉」,由 AI 独立复核;通过后限制会立即解除。');
153
+ return lines.join('\n');
154
+ }
155
+ if (e.status === 422 && code === 'web_package_invalid') {
156
+ const rc = b.reject_code;
157
+ const advice = REJECT_ADVICE[rc];
158
+ const lines = [`网页包没有通过检查${rc ? `(${rc})` : ''}。`];
159
+ if (b.message) lines.push(` 平台说明:${b.message}`);
160
+ lines.push(` 怎么改:${advice || '请按「网页包要求」检查后重新打包(运行 stelfield --help 查看)。'}`);
161
+ return lines.join('\n');
162
+ }
163
+ if (e.status === 503 && code === 'web_hosting_disabled') {
164
+ return '网页托管暂时关闭,现在不能交付网页包。请稍后再试,或先用普通文件交付。';
165
+ }
166
+ if (e.status === 409 && code === 'web_package_not_registered') {
167
+ return '网页包还没登记成功就被拒绝交付(web_package_not_registered)。请重新运行同一条命令。';
168
+ }
169
+ return null;
170
+ }
171
+
172
+ // ============================================================
173
+ // the four-step flow
174
+ // ============================================================
175
+
176
+ /**
177
+ * Optional "which models, roughly how many tokens" note that rides along with a delivery (#117).
178
+ * Returns the extra result_payload fields, or {} when nothing was given. It never changes the idempotency key.
179
+ */
180
+ export function usageFields(models, tokens) {
181
+ const out = {};
182
+ const list = String(models == null ? '' : models).split(/[,,]/).map((s) => s.trim()).filter(Boolean).slice(0, 5);
183
+ if (list.length) out.models = list;
184
+ if (Number.isFinite(tokens) && tokens > 0) out.tokens_approx = Math.round(tokens);
185
+ return out;
186
+ }
187
+
188
+ /** Same shape as the `task deliver` key: deliver-{task}-{caller sub}-{sha256[:16]}. */
189
+ export function buildWebDeliverIdemKey(taskId, token, note, fileId) {
190
+ let callerSub = 'unknown-caller';
191
+ try {
192
+ const payloadB64 = String(token || '').split('.')[1];
193
+ if (payloadB64) {
194
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
195
+ callerSub = payload.sub || payload.agent_id || 'unknown-caller';
196
+ }
197
+ } catch { /* non-JWT token: keep fallback */ }
198
+ const digest = createHash('sha256').update((note || '') + (fileId || '')).digest('hex').slice(0, 16);
199
+ return `deliver-${taskId}-${callerSub}-${digest}`;
200
+ }
201
+
202
+ /**
203
+ * Run the whole upload -> register -> deliver flow.
204
+ *
205
+ * @param {object} ctx { baseUrl, credOpts, fetchImpl?, token, taskId, file, note?, idemKey?, onProgress?(msg) }
206
+ * @returns {Promise<object>} { taskId, fileId, round, sizeBytes, filesCount, unzippedBytes, isZip, filename, deliver }
207
+ * @throws {WebDeliverError} message already in Chinese
208
+ */
209
+ export async function deliverWebPackage(ctx) {
210
+ const { baseUrl, credOpts, fetchImpl, token, taskId, note, idemKey, onProgress } = ctx;
211
+ const pkg = prepareWebPackage(ctx.file);
212
+ const progress = onProgress || (() => {});
213
+ const sha = createHash('sha256').update(pkg.buf).digest('hex');
214
+
215
+ const wrap = (e, step) => {
216
+ if (e instanceof WebDeliverError) return e;
217
+ if (e && e.upgradeRequired) throw e; // let the top-level handler print the upgrade hint (exit 4)
218
+ const mapped = mapWebDeliverError(e);
219
+ const text = mapped || `${step}失败:${e && e.message ? e.message : String(e)}`;
220
+ return new WebDeliverError(text, { status: e && typeof e.status === 'number' ? e.status : null });
221
+ };
222
+
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.
225
+ 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
+ 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
+ }
259
+
260
+ // 3. register: the platform unpacks and checks it
261
+ progress('检查并登记网页包…');
262
+ let reg;
263
+ try {
264
+ reg = await registerWebPackage(baseUrl, credOpts, taskId, req.file_id, { fetchImpl });
265
+ } catch (e) {
266
+ throw wrap(e, '登记网页包');
267
+ }
268
+
269
+ // 4. deliver, same flow and key rule as `task deliver --file-id`
270
+ progress('提交交付…');
271
+ const resultPayload = { file_id: req.file_id, type: 'file', ...usageFields(ctx.models, ctx.tokens) };
272
+ if (note) resultPayload.text = note;
273
+ const deliverKey = idemKey || buildWebDeliverIdemKey(taskId, token, note, req.file_id);
274
+ let deliver;
275
+ try {
276
+ deliver = await runtimeAct(baseUrl, credOpts, 'deliver_job', { job_id: taskId, result_payload: resultPayload }, deliverKey, { fetchImpl });
277
+ } catch (e) {
278
+ throw wrap(e, '提交交付');
279
+ }
280
+
281
+ // Best effort: read back the version number and the package facts. Delivery already succeeded,
282
+ // so any failure here only means the summary shows fewer details.
283
+ let round = null;
284
+ let web = null;
285
+ try {
286
+ const view = await getTaskDeliverables(baseUrl, credOpts, taskId, { fetchImpl });
287
+ const versions = Array.isArray(view && view.versions) ? view.versions : [];
288
+ const hit = versions.find((v) => Array.isArray(v.files) && v.files.some((f) => f.id === req.file_id || f.file_id === req.file_id));
289
+ if (hit) {
290
+ round = hit.round ?? null;
291
+ web = hit.web || null;
292
+ }
293
+ } catch { /* summary only */ }
294
+
295
+ return {
296
+ taskId,
297
+ fileId: req.file_id,
298
+ round,
299
+ sizeBytes: web && web.size_bytes != null ? web.size_bytes : pkg.size,
300
+ filesCount: web && web.files_count != null ? web.files_count : (reg && reg.files_count != null ? reg.files_count : null),
301
+ unzippedBytes: web && web.unzipped_bytes != null ? web.unzipped_bytes : (reg && reg.unzipped_bytes != null ? reg.unzipped_bytes : null),
302
+ isZip: pkg.isZip,
303
+ filename: pkg.filename,
304
+ deliver,
305
+ };
306
+ }
307
+
308
+ /** Human-readable success summary (or JSON). */
309
+ export function formatWebDeliver(r, { json = false } = {}) {
310
+ if (json) {
311
+ return JSON.stringify({
312
+ ok: true,
313
+ task_id: r.taskId,
314
+ file_id: r.fileId,
315
+ version: r.round,
316
+ size_bytes: r.sizeBytes,
317
+ files_count: r.filesCount,
318
+ unzipped_bytes: r.unzippedBytes,
319
+ status: r.deliver && r.deliver.to_status ? r.deliver.to_status : null,
320
+ }, null, 2);
321
+ }
322
+ const lines = ['网页已交付!(系统自动进入待验收)'];
323
+ lines.push(` 任务 ID:${r.taskId}`);
324
+ lines.push(` 交付版本:${r.round != null ? `第 ${r.round} 版` : '(已提交)'}`);
325
+ lines.push(` 包大小:${humanSize(r.sizeBytes)}`);
326
+ lines.push(` 文件数:${r.filesCount != null ? r.filesCount : (r.isZip ? '?' : 1)}`);
327
+ if (r.deliver && r.deliver.from_status && r.deliver.to_status) {
328
+ lines.push(` 状态变更:${r.deliver.from_status} → ${r.deliver.to_status}`);
329
+ }
330
+ lines.push('买家可在交付物页预览;成交后买家可自行生成公开链接(公开链接只能由买家生成,你可以在网页上「建议买家公开」)。');
331
+ return lines.join('\n');
332
+ }