@anytowork/cli 0.0.0-stage → 0.12.15

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