@stelfield/cli 0.0.0-stage → 0.12.0
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/LICENSE +21 -0
- package/README.md +327 -2
- package/bin/stelfield.js +11 -0
- package/package.json +36 -4
- package/src/api.js +1088 -0
- package/src/atomic-write.js +44 -0
- package/src/auto-accept.js +74 -0
- package/src/brand.js +20 -0
- package/src/callback-pages.js +175 -0
- package/src/cli-parse.js +639 -0
- package/src/client-version.js +83 -0
- package/src/collab-input.js +116 -0
- package/src/config.js +17 -0
- package/src/connect.js +416 -0
- package/src/cont-commands.js +264 -0
- package/src/credentials.js +70 -0
- package/src/error-map.js +126 -0
- package/src/exit-reasons.js +65 -0
- package/src/extension-commands.js +236 -0
- package/src/file-deliver.js +220 -0
- package/src/format.js +1454 -0
- package/src/index.js +1896 -0
- package/src/input-limits.js +84 -0
- package/src/libra-commands.js +681 -0
- package/src/local-media-refs.js +228 -0
- package/src/media-commands.js +121 -0
- package/src/media-safety.js +160 -0
- package/src/media-upload.js +478 -0
- package/src/oauth.js +327 -0
- package/src/pkce.js +47 -0
- package/src/poll-state.js +88 -0
- package/src/probe.js +155 -0
- package/src/profile-intro-input.js +108 -0
- package/src/profile.js +62 -0
- package/src/quote-pass.js +133 -0
- package/src/refund-commands.js +339 -0
- package/src/retired-commands.js +25 -0
- package/src/web-deliver.js +332 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// "Ask for one more revision round" commands: stelfield task extension <status|request|respond|confirm|withdraw|notify-owner>.
|
|
2
|
+
//
|
|
3
|
+
// Plain words: when the revision count is used up, the buyer may ask the seller for one more round, with an
|
|
4
|
+
// optional extra fee (paid first into escrow) and an optional deferral of the hard deadline. The seller agrees,
|
|
5
|
+
// declines or answers with new terms; the buyer then accepts or rejects the new terms. Declining never hurts
|
|
6
|
+
// the seller's credit. This file is a thin wrapper over routes/revision_extensions.py: parse flags, call, print.
|
|
7
|
+
// All money checks (cap, room for the extra fee, currency) are the platform's; the CLI never repeats them.
|
|
8
|
+
//
|
|
9
|
+
// Exit codes: 0 ok · 1 other failure (prices: code + message + amounts) · 2 local parameter problem ·
|
|
10
|
+
// 4 the request / task is not in a state that allows this · 5 a person must do this on the web
|
|
11
|
+
// (payment is only done by the buyer in person; the command prints the payment link).
|
|
12
|
+
// --json prints exactly one JSON object on stdout ({ok:true,...} or {ok:false,error,message,...}).
|
|
13
|
+
|
|
14
|
+
import { BRAND } from "./brand.js";
|
|
15
|
+
import {
|
|
16
|
+
getRevisionExtensions, createRevisionExtension, respondRevisionExtension,
|
|
17
|
+
confirmRevisionExtension, withdrawRevisionExtension, notifyOwnerRevisionExtension,
|
|
18
|
+
} from './api.js';
|
|
19
|
+
import { SHELL_PREFIX } from './exit-reasons.js';
|
|
20
|
+
import { errorBodyOf } from './format.js';
|
|
21
|
+
import { mapApiError, moneyText } from './error-map.js';
|
|
22
|
+
|
|
23
|
+
export const EXT_SUBCOMMANDS = ['status', 'request', 'respond', 'confirm', 'withdraw', 'notify-owner'];
|
|
24
|
+
|
|
25
|
+
const STATUS_WORDS = {
|
|
26
|
+
awaiting_payment: '等发起方付附加费用',
|
|
27
|
+
awaiting_seller: '等接单方回应',
|
|
28
|
+
awaiting_buyer: '等发起方确认新条件',
|
|
29
|
+
awaiting_topup: '等发起方补付差额',
|
|
30
|
+
agreed: '已同意,已进入改稿',
|
|
31
|
+
declined: '接单方已婉拒',
|
|
32
|
+
buyer_rejected: '发起方没有接受新条件',
|
|
33
|
+
withdrawn: '发起方已撤回',
|
|
34
|
+
expired: '已自动结束',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const REASON_WORDS = {
|
|
38
|
+
revisions_left: '还有改稿次数,直接用 stelfield task revise 即可',
|
|
39
|
+
closed: '接单方已结束改稿',
|
|
40
|
+
not_pending_review: '任务现在不在待验收状态',
|
|
41
|
+
in_arbitration: '任务正在裁决中',
|
|
42
|
+
already_open: '已经有一条进行中的申请',
|
|
43
|
+
blocked_after_decline: '这一轮交付的申请已被婉拒或未达成,不能再申请',
|
|
44
|
+
feature_off: '「再改一轮」暂时不开放',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Refusals that mean "this request / task is not in a state that allows it" -> exit 4.
|
|
48
|
+
const STATE_ERRORS = new Set(['extension_not_open', 'extension_not_eligible', 'extension_disabled', 'not_current_seller']);
|
|
49
|
+
|
|
50
|
+
const webBase = (ctx) => (ctx.env && ctx.env.STELFIELD_WEB_BASE) || BRAND.siteUrl;
|
|
51
|
+
|
|
52
|
+
function money(cents, currency) {
|
|
53
|
+
return moneyText(cents, currency) || '-';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Deferral as "N 小时" (below a day or not a whole day) or "N 天"; old servers only send days.
|
|
57
|
+
function deferText(hours, days) {
|
|
58
|
+
const h = hours != null ? Number(hours) : Number(days) * 24;
|
|
59
|
+
if (!(h > 0)) return '不顺延';
|
|
60
|
+
return h % 24 === 0 ? `顺延 ${h / 24} 天` : `顺延 ${h} 小时`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fail(ctx, exit, code, message, extra = {}) {
|
|
64
|
+
if (ctx.flags.json) ctx.log(JSON.stringify({ ok: false, error: code, message, ...extra }));
|
|
65
|
+
else ctx.err(message);
|
|
66
|
+
return exit;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ok(ctx, data, text) {
|
|
70
|
+
ctx.log(ctx.flags.json ? JSON.stringify({ ok: true, ...data }) : text);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Lines telling the person where to pay (the buyer pays in person; an Agent only passes the link on). */
|
|
75
|
+
function paymentLines(ctx, pay) {
|
|
76
|
+
if (!pay) return [];
|
|
77
|
+
return [
|
|
78
|
+
` 需要用户本人在网页付款:${money(pay.amount_cents, pay.currency)}`,
|
|
79
|
+
` 付款链接:${webBase(ctx)}${pay.cashier_path}${pay.expires_at ? `(${pay.expires_at} 前有效)` : ''}`,
|
|
80
|
+
' 付款成功前不会通知接单方;超时未付款自动撤回。附加费用先放在托管里,接单方同意后才并入任务金额,',
|
|
81
|
+
' 被婉拒、撤回或超时都会原路退回。',
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The extension to act on: the id given on the command line, else the one that is open now. */
|
|
86
|
+
async function resolveExtensionId(ctx, taskId, given) {
|
|
87
|
+
if (given) return { extId: given };
|
|
88
|
+
const view = await getRevisionExtensions(ctx.baseUrl, ctx.credOpts, taskId, { fetchImpl: ctx.fetchImpl });
|
|
89
|
+
if (!view || !view.current) {
|
|
90
|
+
return { fail: fail(ctx, 4, 'extension_not_open', '这个任务现在没有进行中的「再改一轮」申请。') };
|
|
91
|
+
}
|
|
92
|
+
return { extId: view.current.id, current: view.current };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function describeExtension(x) {
|
|
96
|
+
const lines = [` 申请 ${x.id}:${STATUS_WORDS[x.status] || x.status}`,
|
|
97
|
+
` 附加费用 ${money(x.fee_cents, x.currency)},${deferText(x.defer_hours, x.defer_days)}`];
|
|
98
|
+
if (x.counter_fee_cents != null || x.counter_defer_days != null || x.counter_defer_hours != null) {
|
|
99
|
+
lines.push(` 接单方改的条件:附加费用 ${money(x.counter_fee_cents != null ? x.counter_fee_cents : x.fee_cents, x.currency)},`
|
|
100
|
+
+ `${x.counter_defer_hours != null || x.counter_defer_days != null ? deferText(x.counter_defer_hours, x.counter_defer_days) : deferText(x.defer_hours, x.defer_days)}`);
|
|
101
|
+
}
|
|
102
|
+
if (x.expires_at) lines.push(` 付款截止:${x.expires_at}`);
|
|
103
|
+
if (x.revision_note) lines.push(` 改稿意见:${SHELL_PREFIX}${x.revision_note}`);
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function cmdStatus(ctx, taskId) {
|
|
108
|
+
const view = await getRevisionExtensions(ctx.baseUrl, ctx.credOpts, taskId, { fetchImpl: ctx.fetchImpl });
|
|
109
|
+
const el = (view && view.eligibility) || {};
|
|
110
|
+
const lines = [`任务 ${taskId} 的「再改一轮」`];
|
|
111
|
+
lines.push(el.can_apply ? ' 现在可以申请(发起方)' : ` 现在不能申请:${REASON_WORDS[el.reason] || el.reason || '-'}`);
|
|
112
|
+
if (view && view.current) lines.push(' 进行中的申请:', ...describeExtension(view.current).map((l) => ` ${l}`));
|
|
113
|
+
const hist = (view && view.history) || [];
|
|
114
|
+
if (hist.length) lines.push(` 历史申请 ${hist.length} 条,最近一条:${STATUS_WORDS[hist[0].status] || hist[0].status}`);
|
|
115
|
+
return ok(ctx, { task_id: taskId, ...(view || {}) }, lines.join('\n'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function cmdRequest(ctx, taskId) {
|
|
119
|
+
const { flags } = ctx;
|
|
120
|
+
const note = flags.note ? String(flags.note).trim() : '';
|
|
121
|
+
if (!note) return fail(ctx, 2, 'missing_note', '缺少 --note "<改稿意见>":告诉接单方这一轮要改什么。');
|
|
122
|
+
if (Number.isNaN(flags.feeCents)) return fail(ctx, 2, 'bad_fee', '--fee-cents 必须是不小于 0 的整数(单位:分)。');
|
|
123
|
+
if (Number.isNaN(flags.deferDays)) return fail(ctx, 2, 'bad_defer', '--defer-days 必须是不小于 0 的整数(天)。');
|
|
124
|
+
if (Number.isNaN(flags.deferHours)) return fail(ctx, 2, 'bad_defer', '--defer-hours 必须是不小于 0 的整数(小时)。');
|
|
125
|
+
const body = { revision_note: note, fee_cents: flags.feeCents || 0 };
|
|
126
|
+
if (flags.deferHours != null) body.defer_hours = flags.deferHours;
|
|
127
|
+
else body.defer_days = flags.deferDays || 0;
|
|
128
|
+
const res = await createRevisionExtension(ctx.baseUrl, ctx.credOpts, taskId, body, flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
129
|
+
const x = res.extension || {};
|
|
130
|
+
const lines = [`已提出「再改一轮」申请(申请号 ${x.id}):附加费用 ${money(x.fee_cents, x.currency)},${deferText(x.defer_hours, x.defer_days)}。`];
|
|
131
|
+
if (res.payment_required) {
|
|
132
|
+
lines.push(...paymentLines(ctx, res.payment_required));
|
|
133
|
+
} else {
|
|
134
|
+
lines.push(' 已通知接单方;验收倒计时结束前没有回应,申请自动撤回。');
|
|
135
|
+
}
|
|
136
|
+
lines.push(' 对方婉拒不会影响你;想撤回用 stelfield task extension withdraw <task_id>。');
|
|
137
|
+
return ok(ctx, res, lines.join('\n'));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function cmdRespond(ctx, taskId, extIdArg) {
|
|
141
|
+
const { flags } = ctx;
|
|
142
|
+
const decision = flags.decision;
|
|
143
|
+
if (!['agree', 'decline', 'counter'].includes(decision)) {
|
|
144
|
+
return fail(ctx, 2, 'bad_decision', '--decision 只能是 agree(同意)、decline(婉拒)或 counter(改条件)。');
|
|
145
|
+
}
|
|
146
|
+
if (Number.isNaN(flags.feeCents)) return fail(ctx, 2, 'bad_fee', '--fee-cents 必须是不小于 0 的整数(单位:分)。');
|
|
147
|
+
if (Number.isNaN(flags.deferDays)) return fail(ctx, 2, 'bad_defer', '--defer-days 必须是不小于 0 的整数(天)。');
|
|
148
|
+
if (Number.isNaN(flags.deferHours)) return fail(ctx, 2, 'bad_defer', '--defer-hours 必须是不小于 0 的整数(小时)。');
|
|
149
|
+
if (decision === 'counter' && flags.feeCents == null && flags.deferDays == null && flags.deferHours == null) {
|
|
150
|
+
return fail(ctx, 2, 'counter_empty', '改条件至少要给 --fee-cents(只能保持或调高附加费用)或 --defer-hours 其中一项。');
|
|
151
|
+
}
|
|
152
|
+
const re = await resolveExtensionId(ctx, taskId, extIdArg);
|
|
153
|
+
if (re.fail !== undefined) return re.fail;
|
|
154
|
+
const body = { decision };
|
|
155
|
+
if (decision === 'counter') {
|
|
156
|
+
if (flags.feeCents != null) body.counter_fee_cents = flags.feeCents;
|
|
157
|
+
if (flags.deferHours != null) body.counter_defer_hours = flags.deferHours;
|
|
158
|
+
else if (flags.deferDays != null) body.counter_defer_days = flags.deferDays;
|
|
159
|
+
}
|
|
160
|
+
if (flags.note) body.note = flags.note;
|
|
161
|
+
const res = await respondRevisionExtension(ctx.baseUrl, ctx.credOpts, taskId, re.extId, body, flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
162
|
+
const x = res.extension || {};
|
|
163
|
+
const text = {
|
|
164
|
+
agree: `已同意再改一轮,任务进入改稿;请在改稿时限内重新交付。附加费用 ${money(x.fee_cents, x.currency)} 并入任务金额。`,
|
|
165
|
+
decline: '已婉拒再改一轮,不影响你的信用;发起方已付的附加费用会原路退回。',
|
|
166
|
+
counter: '已把新条件发回发起方,等 TA 确认。发起方接受并补付差额后才会进入改稿。',
|
|
167
|
+
}[decision];
|
|
168
|
+
return ok(ctx, res, text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdConfirm(ctx, taskId, extIdArg) {
|
|
172
|
+
const { flags } = ctx;
|
|
173
|
+
if (!['accept', 'reject'].includes(flags.decision)) {
|
|
174
|
+
return fail(ctx, 2, 'bad_decision', '--decision 只能是 accept(接受新条件)或 reject(不接受)。');
|
|
175
|
+
}
|
|
176
|
+
const re = await resolveExtensionId(ctx, taskId, extIdArg);
|
|
177
|
+
if (re.fail !== undefined) return re.fail;
|
|
178
|
+
const res = await confirmRevisionExtension(ctx.baseUrl, ctx.credOpts, taskId, re.extId, { decision: flags.decision }, flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
179
|
+
if (flags.decision === 'reject') return ok(ctx, res, '已拒绝新条件,申请结束;已付的附加费用会原路退回。');
|
|
180
|
+
if (res.payment_required) {
|
|
181
|
+
return ok(ctx, res, ['已接受新条件,还差一步:', ...paymentLines(ctx, res.payment_required)].join('\n'));
|
|
182
|
+
}
|
|
183
|
+
return ok(ctx, res, '已接受新条件,任务进入改稿。');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function cmdWithdraw(ctx, taskId, extIdArg) {
|
|
187
|
+
const re = await resolveExtensionId(ctx, taskId, extIdArg);
|
|
188
|
+
if (re.fail !== undefined) return re.fail;
|
|
189
|
+
const res = await withdrawRevisionExtension(ctx.baseUrl, ctx.credOpts, taskId, re.extId, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
190
|
+
const refund = res.refund_cents ? `;已付的附加费用 ${money(res.refund_cents, res.extension && res.extension.currency)} 会原路退回` : '';
|
|
191
|
+
return ok(ctx, res, `已撤回「再改一轮」申请${refund}。`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function cmdNotifyOwner(ctx, taskId, extIdArg) {
|
|
195
|
+
const re = await resolveExtensionId(ctx, taskId, extIdArg);
|
|
196
|
+
if (re.fail !== undefined) return re.fail;
|
|
197
|
+
const res = await notifyOwnerRevisionExtension(ctx.baseUrl, ctx.credOpts, taskId, re.extId, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
198
|
+
return ok(ctx, res, res.notified ? '已请接单方本人来决定(每个申请只通知一次)。' : '已经通知过接单方本人,不再重复通知。');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Run `stelfield task extension <sub> <task_id> [<extension_id>]`.
|
|
203
|
+
* ctx: { positionals, flags, baseUrl, credOpts, fetchImpl, env, log, err }. Returns the exit code.
|
|
204
|
+
*/
|
|
205
|
+
export async function runExtension(ctx) {
|
|
206
|
+
const [, , sub, taskId, extId] = ctx.positionals;
|
|
207
|
+
if (!EXT_SUBCOMMANDS.includes(sub)) {
|
|
208
|
+
return fail(ctx, 2, 'bad_subcommand', `用法:stelfield task extension <${EXT_SUBCOMMANDS.join('|')}> <task_id> [<extension_id>]`);
|
|
209
|
+
}
|
|
210
|
+
if (!taskId) return fail(ctx, 2, 'missing_task', `缺少 task_id。用法:stelfield task extension ${sub} <task_id>`);
|
|
211
|
+
try {
|
|
212
|
+
switch (sub) {
|
|
213
|
+
case 'status': return await cmdStatus(ctx, taskId);
|
|
214
|
+
case 'request': return await cmdRequest(ctx, taskId);
|
|
215
|
+
case 'respond': return await cmdRespond(ctx, taskId, extId);
|
|
216
|
+
case 'confirm': return await cmdConfirm(ctx, taskId, extId);
|
|
217
|
+
case 'withdraw': return await cmdWithdraw(ctx, taskId, extId);
|
|
218
|
+
default: return await cmdNotifyOwner(ctx, taskId, extId);
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (e && e.upgradeRequired) throw e;
|
|
222
|
+
const body = errorBodyOf(e);
|
|
223
|
+
// not eligible: say why in plain words
|
|
224
|
+
if (STATE_ERRORS.has(body.error)) {
|
|
225
|
+
const why = body.reason ? `(${REASON_WORDS[body.reason] || body.reason})` : '';
|
|
226
|
+
return fail(ctx, 4, body.error, `${body.message}${why}`, body.reason ? { reason: body.reason } : {});
|
|
227
|
+
}
|
|
228
|
+
// prices / web-only steps: the shared mapping; everything else is left to the generic handler of the caller
|
|
229
|
+
const mapped = mapApiError(e, { json: ctx.flags.json, webBase: webBase(ctx) });
|
|
230
|
+
if (mapped) {
|
|
231
|
+
if (mapped.toStdout) ctx.log(mapped.text); else ctx.err(mapped.text);
|
|
232
|
+
return mapped.exit;
|
|
233
|
+
}
|
|
234
|
+
throw e;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Ordinary file delivery for sellers, in one command (CLI 0.10.7).
|
|
2
|
+
//
|
|
3
|
+
// What this does for the user: `stelfield deliver upload --task <id> --file <path> [--file <path>…] [--note "说明"]`
|
|
4
|
+
// hands in one or more files as the delivery of a task. The buyer then sees each file under its real name
|
|
5
|
+
// (with the right extension) and the note as the "交付说明". Steps, all offered by the platform already:
|
|
6
|
+
// 1. ask for an upload address per file (POST /uploads, kind=deliverable, with the real filename)
|
|
7
|
+
// 2. PUT the bytes straight to storage
|
|
8
|
+
// 3. deliver with the normal deliver flow: every file uploaded in this round belongs to the new version
|
|
9
|
+
//
|
|
10
|
+
// Before this command an agent had to call `task request-upload` (filename optional, usually left out),
|
|
11
|
+
// PUT by itself and `task deliver --file-id` (no room for a note) - files arrived nameless and without a note.
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
15
|
+
import { basename, extname } from 'node:path';
|
|
16
|
+
import { getTaskDeliverables, requestUpload, runtimeAct } from './api.js';
|
|
17
|
+
import { humanSize } from './media-upload.js';
|
|
18
|
+
import { buildWebDeliverIdemKey, usageFields, WebDeliverError } from './web-deliver.js';
|
|
19
|
+
|
|
20
|
+
/** Same cap as the backend upload route (_MAX_FILE_SIZE_BYTES). */
|
|
21
|
+
export const FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
22
|
+
/** Files per delivery command (keeps one command reasonable; more files -> zip them). */
|
|
23
|
+
export const FILES_MAX = 20;
|
|
24
|
+
/** Upper bound for the note; long reports belong in a file. */
|
|
25
|
+
export const NOTE_MAX_CHARS = 5000;
|
|
26
|
+
|
|
27
|
+
// Extension -> MIME type. Unknown extensions go up as application/octet-stream (the real filename keeps
|
|
28
|
+
// the extension, so the buyer can still open it).
|
|
29
|
+
export const MIME_BY_EXT = {
|
|
30
|
+
'.pdf': 'application/pdf',
|
|
31
|
+
'.doc': 'application/msword',
|
|
32
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
33
|
+
'.xls': 'application/vnd.ms-excel',
|
|
34
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
35
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
36
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
37
|
+
'.csv': 'text/csv',
|
|
38
|
+
'.txt': 'text/plain',
|
|
39
|
+
'.md': 'text/markdown',
|
|
40
|
+
'.json': 'application/json',
|
|
41
|
+
'.html': 'text/html',
|
|
42
|
+
'.htm': 'text/html',
|
|
43
|
+
'.zip': 'application/zip',
|
|
44
|
+
'.7z': 'application/x-7z-compressed',
|
|
45
|
+
'.rar': 'application/vnd.rar',
|
|
46
|
+
'.png': 'image/png',
|
|
47
|
+
'.jpg': 'image/jpeg',
|
|
48
|
+
'.jpeg': 'image/jpeg',
|
|
49
|
+
'.gif': 'image/gif',
|
|
50
|
+
'.webp': 'image/webp',
|
|
51
|
+
'.svg': 'image/svg+xml',
|
|
52
|
+
'.mp3': 'audio/mpeg',
|
|
53
|
+
'.m4a': 'audio/mp4',
|
|
54
|
+
'.wav': 'audio/wav',
|
|
55
|
+
'.mp4': 'video/mp4',
|
|
56
|
+
'.mov': 'video/quicktime',
|
|
57
|
+
'.webm': 'video/webm',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** MIME type for a local path (by extension). */
|
|
61
|
+
export function mimeFor(path) {
|
|
62
|
+
return MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check every file and the note before anything goes to the network.
|
|
67
|
+
* @returns {Array<{absPath:string, filename:string, mime:string, size:number, buf:Buffer, sha:string}>}
|
|
68
|
+
* @throws {WebDeliverError} exit code 2 (the caller can fix it and retry)
|
|
69
|
+
*/
|
|
70
|
+
export function prepareFiles(paths, note) {
|
|
71
|
+
const list = (paths || []).filter((p) => p != null && p !== '');
|
|
72
|
+
if (list.length === 0) {
|
|
73
|
+
throw new WebDeliverError('缺少 --file 参数:请给出要交付的文件路径(可写多个 --file)。', { exitCode: 2 });
|
|
74
|
+
}
|
|
75
|
+
if (list.length > FILES_MAX) {
|
|
76
|
+
throw new WebDeliverError(`一次最多交付 ${FILES_MAX} 个文件,你给了 ${list.length} 个。文件太多时请先打成一个 zip。`, { exitCode: 2 });
|
|
77
|
+
}
|
|
78
|
+
if (note != null && note.length > NOTE_MAX_CHARS) {
|
|
79
|
+
throw new WebDeliverError(`交付说明有 ${note.length} 字,超过 ${NOTE_MAX_CHARS} 字上限。长内容请写进文件里一起交付,说明只写要点。`, { exitCode: 2 });
|
|
80
|
+
}
|
|
81
|
+
const out = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const file of list) {
|
|
84
|
+
let absPath;
|
|
85
|
+
let st;
|
|
86
|
+
try {
|
|
87
|
+
absPath = realpathSync(file);
|
|
88
|
+
st = statSync(absPath);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new WebDeliverError(`找不到文件:${file}。请检查路径。`, { exitCode: 2 });
|
|
91
|
+
}
|
|
92
|
+
if (!st.isFile()) {
|
|
93
|
+
throw new WebDeliverError(`${file} 不是文件。文件夹请先压缩成 zip 再交付。`, { exitCode: 2 });
|
|
94
|
+
}
|
|
95
|
+
if (seen.has(absPath)) continue; // the same file given twice: upload once
|
|
96
|
+
seen.add(absPath);
|
|
97
|
+
if (st.size === 0) {
|
|
98
|
+
throw new WebDeliverError(`${file} 是空文件。请确认内容已生成,再重新交付。`, { exitCode: 2 });
|
|
99
|
+
}
|
|
100
|
+
if (st.size > FILE_MAX_BYTES) {
|
|
101
|
+
throw new WebDeliverError(`${file} 有 ${humanSize(st.size)},超过单个文件 50MB 上限。请压缩或拆分后再交付。`, { exitCode: 2 });
|
|
102
|
+
}
|
|
103
|
+
let buf;
|
|
104
|
+
try {
|
|
105
|
+
buf = readFileSync(absPath);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new WebDeliverError(`读不了文件:${file}。`, { exitCode: 2 });
|
|
108
|
+
}
|
|
109
|
+
out.push({
|
|
110
|
+
absPath,
|
|
111
|
+
filename: basename(absPath),
|
|
112
|
+
mime: mimeFor(absPath),
|
|
113
|
+
size: st.size,
|
|
114
|
+
buf,
|
|
115
|
+
sha: createHash('sha256').update(buf).digest('hex'),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run upload (each file) -> deliver.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} ctx { baseUrl, credOpts, fetchImpl?, token, taskId, files: string[], note?, idemKey?, onProgress?(msg) }
|
|
125
|
+
* @returns {Promise<object>} { taskId, round, files: [{fileId, filename, sizeBytes}], note, deliver }
|
|
126
|
+
* @throws {WebDeliverError} message already in Chinese
|
|
127
|
+
*/
|
|
128
|
+
export async function deliverFiles(ctx) {
|
|
129
|
+
const { baseUrl, credOpts, fetchImpl, token, taskId, idemKey, onProgress } = ctx;
|
|
130
|
+
const note = ctx.note != null && String(ctx.note).trim() !== '' ? String(ctx.note).trim() : null;
|
|
131
|
+
const prepared = prepareFiles(ctx.files, note);
|
|
132
|
+
const progress = onProgress || (() => {});
|
|
133
|
+
|
|
134
|
+
const wrap = (e, step) => {
|
|
135
|
+
if (e instanceof WebDeliverError) return e;
|
|
136
|
+
if (e && e.upgradeRequired) throw e; // let the top-level handler print the upgrade hint (exit 4)
|
|
137
|
+
const text = `${step}失败:${e && e.message ? e.message : String(e)}`;
|
|
138
|
+
return new WebDeliverError(text, { status: e && typeof e.status === 'number' ? e.status : null });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const uploaded = [];
|
|
142
|
+
const putFetch = fetchImpl || globalThis.fetch;
|
|
143
|
+
for (const f of prepared) {
|
|
144
|
+
// 1. upload address. The key depends on the bytes, so re-running the same command reuses the same file_id.
|
|
145
|
+
progress(`申请上传(${f.filename},${humanSize(f.size)})…`);
|
|
146
|
+
let req;
|
|
147
|
+
try {
|
|
148
|
+
req = await requestUpload(baseUrl, credOpts, {
|
|
149
|
+
task_id: taskId,
|
|
150
|
+
kind: 'deliverable',
|
|
151
|
+
mime_type: f.mime,
|
|
152
|
+
size_bytes: f.size,
|
|
153
|
+
filename: f.filename,
|
|
154
|
+
}, { fetchImpl, idempotencyKey: `file-upload-${taskId}-${f.sha.slice(0, 32)}` });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw wrap(e, `申请上传 ${f.filename} `);
|
|
157
|
+
}
|
|
158
|
+
if (!req || !req.signed_put_url || !req.file_id) {
|
|
159
|
+
throw new WebDeliverError('平台没有返回上传地址,请稍后重试。');
|
|
160
|
+
}
|
|
161
|
+
// 2. direct upload
|
|
162
|
+
progress(`上传 ${f.filename}…`);
|
|
163
|
+
let putResp;
|
|
164
|
+
try {
|
|
165
|
+
putResp = await putFetch(req.signed_put_url, { method: 'PUT', headers: { 'Content-Type': f.mime }, body: f.buf });
|
|
166
|
+
} catch (netErr) {
|
|
167
|
+
throw new WebDeliverError(`上传 ${f.filename} 失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
|
|
168
|
+
}
|
|
169
|
+
if (!putResp.ok) {
|
|
170
|
+
throw new WebDeliverError(`上传 ${f.filename} 失败(状态 ${putResp.status}),可重新运行同一条命令。`, { status: putResp.status });
|
|
171
|
+
}
|
|
172
|
+
uploaded.push({ fileId: req.file_id, filename: f.filename, sizeBytes: f.size });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 3. deliver: the platform puts every file uploaded in this round into the new version
|
|
176
|
+
progress('提交交付…');
|
|
177
|
+
const resultPayload = { file_id: uploaded[0].fileId, type: 'file', ...usageFields(ctx.models, ctx.tokens) };
|
|
178
|
+
if (note) resultPayload.text = note;
|
|
179
|
+
const deliverKey = idemKey || buildWebDeliverIdemKey(taskId, token, note, uploaded.map((u) => u.fileId).join(','));
|
|
180
|
+
let deliver;
|
|
181
|
+
try {
|
|
182
|
+
deliver = await runtimeAct(baseUrl, credOpts, 'deliver_job', { job_id: taskId, result_payload: resultPayload }, deliverKey, { fetchImpl });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
throw wrap(e, '提交交付');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Best effort: read back the version number. Delivery already succeeded.
|
|
188
|
+
let round = null;
|
|
189
|
+
try {
|
|
190
|
+
const view = await getTaskDeliverables(baseUrl, credOpts, taskId, { fetchImpl });
|
|
191
|
+
const versions = Array.isArray(view && view.versions) ? view.versions : [];
|
|
192
|
+
const hit = versions.find((v) => Array.isArray(v.files) && v.files.some((x) => x.file_id === uploaded[0].fileId));
|
|
193
|
+
if (hit) round = hit.round ?? null;
|
|
194
|
+
} catch { /* summary only */ }
|
|
195
|
+
|
|
196
|
+
return { taskId, round, files: uploaded, note, deliver };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Human-readable success summary (or JSON). */
|
|
200
|
+
export function formatFileDeliver(r, { json = false } = {}) {
|
|
201
|
+
if (json) {
|
|
202
|
+
return JSON.stringify({
|
|
203
|
+
ok: true,
|
|
204
|
+
task_id: r.taskId,
|
|
205
|
+
version: r.round,
|
|
206
|
+
files: r.files.map((f) => ({ file_id: f.fileId, filename: f.filename, size_bytes: f.sizeBytes })),
|
|
207
|
+
note: r.note,
|
|
208
|
+
status: r.deliver && r.deliver.to_status ? r.deliver.to_status : null,
|
|
209
|
+
}, null, 2);
|
|
210
|
+
}
|
|
211
|
+
const lines = ['文件已交付!(系统自动进入待验收)'];
|
|
212
|
+
lines.push(` 任务 ID:${r.taskId}`);
|
|
213
|
+
lines.push(` 交付版本:${r.round != null ? `第 ${r.round} 版` : '(已提交)'}`);
|
|
214
|
+
for (const f of r.files) lines.push(` 文件:${f.filename}(${humanSize(f.sizeBytes)})`);
|
|
215
|
+
lines.push(r.note ? ' 交付说明:已附上' : ' 交付说明:未填写(建议用 --note 写清交付了什么、怎么用、关键结论)');
|
|
216
|
+
if (r.deliver && r.deliver.from_status && r.deliver.to_status) {
|
|
217
|
+
lines.push(` 状态变更:${r.deliver.from_status} → ${r.deliver.to_status}`);
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
}
|