zen-gitsync 2.16.51 → 2.17.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.
- package/package.json +12 -2
- package/src/cli/ai/agent.js +2 -2
- package/src/cli/ai/images.js +5 -3
- package/src/cli/ai/modelSetup.js +1 -1
- package/src/cli/ai/sessionStore.js +4 -6
- package/src/config.js +178 -39
- package/src/gitCommit.js +8 -0
- package/src/ui/public/assets/AgentView-BsN9y4Kg.css +1 -0
- package/src/ui/public/assets/AgentView-YDATCFcA.js +4 -0
- package/src/ui/public/assets/AppVersionBadge-Bw4-tHkL.js +2 -0
- package/src/ui/public/assets/{AppVersionBadge-CJrDr7GV.css → AppVersionBadge-D5tJzJfa.css} +1 -1
- package/src/ui/public/assets/BranchSelector-BZTHIIaF.js +1 -0
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +1 -0
- package/src/ui/public/assets/CommitForm-DKedPnu-.js +6 -0
- package/src/ui/public/assets/CommitForm-Oij8R6P-.css +1 -0
- package/src/ui/public/assets/CommonDialog-C_RjF9Mp.css +1 -0
- package/src/ui/public/assets/CommonDialog-OCekbVQM.js +1 -0
- package/src/ui/public/assets/EditorView-BgA-_8WV.css +1 -0
- package/src/ui/public/assets/EditorView-C5O3ihKq.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-vIvDP419.js +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-SlVXZt60.js +21 -0
- package/src/ui/public/assets/LogList-DlPJsPy_.js +7 -0
- package/src/ui/public/assets/LogList-DlWwSZet.css +1 -0
- package/src/ui/public/assets/MindmapView-B1Go82pJ.js +1 -0
- package/src/ui/public/assets/MindmapView-CS2t30Ko.css +1 -0
- package/src/ui/public/assets/MonitorView-CphDOlQu.css +1 -0
- package/src/ui/public/assets/MonitorView-GQ34uXPs.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-0UNF9CeP.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-D51q3x7T.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-bpwaVAvX.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-Bin9d3GZ.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-CDykPlPj.css +1 -0
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +1 -0
- package/src/ui/public/assets/SourceMapView-OxlKvnEc.js +3 -0
- package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
- package/src/ui/public/assets/SvgIcon-BpKq_phu.js +1 -0
- package/src/ui/public/assets/UserInputNode-D2EDkZ_H.js +1 -0
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DWMdGe8g.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DyhIOjGD.js +13 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-N7dqbp6x.js → _plugin-vue_export-helper-BaSe6lmR.js} +3 -3
- package/src/ui/public/assets/configStore-CyAQ0a-U.js +1 -0
- package/src/ui/public/assets/element-plus-H7kW-grr.js +19 -0
- package/src/ui/public/assets/{flow-mindmap-wqtwJcDW.js → flow-mindmap-DEwSVurC.js} +12 -12
- package/src/ui/public/assets/index-BXdqTR9A.css +1 -0
- package/src/ui/public/assets/index-SjRTI8Of.js +46 -0
- package/src/ui/public/assets/office-docx-C67al1tG.js +31 -0
- package/src/ui/public/assets/{office-excel-5lGzd5Yy.js → office-excel-ffLec5k6.js} +1 -1
- package/src/ui/public/assets/office-pptx-vRGbnHIO.js +60 -0
- package/src/ui/public/assets/toolsStore-BcJP9HmK.js +1 -0
- package/src/ui/public/assets/{vendor-C6ntdXkR.js → vendor-Dwln8rZH.js} +39 -46
- package/src/ui/public/assets/vue-flow-BfBE1dKN.js +39 -0
- package/src/ui/public/index.html +13 -13
- package/src/ui/server/index.js +9 -0
- package/src/ui/server/routes/config.js +9 -6
- package/src/ui/server/routes/git/aiDiffSummary.js +2 -2
- package/src/ui/server/routes/gitOps.js +63 -7
- package/src/ui/server/routes/workbench/agentSessionStore.js +6 -9
- package/src/ui/server/routes/workbench/attachmentUtils.js +93 -0
- package/src/ui/server/routes/workbench/attachmentUtils.test.js +100 -0
- package/src/ui/server/routes/workbench/envContext.js +137 -0
- package/src/ui/server/routes/workbench/envContext.test.js +178 -0
- package/src/ui/server/routes/workbench/index.js +210 -8
- package/src/ui/server/routes/workbench/orchestratorStore.js +6 -0
- package/src/ui/server/routes/workbench/pathParity.test.js +73 -0
- package/src/ui/server/routes/workbench/projectRegistry.js +37 -16
- package/src/ui/server/routes/workbench/projectRegistry.test.js +62 -17
- package/src/ui/server/routes/workbench/shared.js +7 -2
- package/src/ui/server/routes/workbench/targetResolver.js +260 -0
- package/src/ui/server/routes/workbench/targetResolver.test.js +224 -0
- package/src/ui/server/routes/workbench/taskRunner.js +42 -0
- package/src/ui/server/utils/gitExitCode.js +116 -0
- package/src/ui/server/utils/gitExitCode.test.js +108 -0
- package/src/ui/server/utils/instanceRegistry.js +17 -6
- package/src/ui/public/assets/AgentView-8-xsOmE6.css +0 -1
- package/src/ui/public/assets/AgentView-DcTGw8-j.js +0 -4
- package/src/ui/public/assets/AppVersionBadge-CA5-POGA.js +0 -2
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
- package/src/ui/public/assets/BranchSelector-qn-iGHOI.js +0 -1
- package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
- package/src/ui/public/assets/CommitForm-DHfyJ331.js +0 -6
- package/src/ui/public/assets/CommonDialog-DJBixJcI.js +0 -1
- package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
- package/src/ui/public/assets/EditorView-BRWoa7--.js +0 -1
- package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-YoPgld-R.js +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DyEjlCBW.js +0 -21
- package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
- package/src/ui/public/assets/LogList-_z4dIgvS.js +0 -7
- package/src/ui/public/assets/MindmapView-BQTqx842.css +0 -1
- package/src/ui/public/assets/MindmapView-BrkcAcDh.js +0 -2
- package/src/ui/public/assets/MonitorView-8TG_K4m3.js +0 -1
- package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-BqLyrik7.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-LOVV6KrB.js +0 -4
- package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
- package/src/ui/public/assets/RemoteRepoCard-SQyKcXDG.js +0 -7
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +0 -1
- package/src/ui/public/assets/SourceMapView-DckLynaL.js +0 -3
- package/src/ui/public/assets/SvgIcon-CvIX1KL7.js +0 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
- package/src/ui/public/assets/UserInputNode-Bc124-BK.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BtQkd9Oy.js +0 -37
- package/src/ui/public/assets/WorkbenchView-FdhmMI-b.css +0 -1
- package/src/ui/public/assets/configStore-NaVpBxPo.js +0 -1
- package/src/ui/public/assets/element-plus-Csu3CJKj.js +0 -39
- package/src/ui/public/assets/index-Ci3lbjk2.css +0 -1
- package/src/ui/public/assets/index-D9u0mCSi.js +0 -98
- package/src/ui/public/assets/office-docx-BKrAsewH.js +0 -32
- package/src/ui/public/assets/office-pptx-DKtkU_7r.js +0 -74
- package/src/ui/public/assets/toolsStore-CNDLLgUG.js +0 -1
- package/src/ui/public/assets/vue-flow-D7IxiTyP.js +0 -39
package/src/ui/server/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { Server } from 'socket.io';
|
|
|
28
28
|
import { spawn, exec } from 'child_process';
|
|
29
29
|
import iconv from 'iconv-lite';
|
|
30
30
|
import { createRequestLogger } from './middleware/requestLogger.js';
|
|
31
|
+
import { migrateDataDir } from '../../dataDirMigration.js';
|
|
31
32
|
import { createErrorHandler } from './middleware/errorHandler.js';
|
|
32
33
|
import { registerUiSocketHandlers } from './socket/registerUiSocketHandlers.js';
|
|
33
34
|
import { registerExecRoutes } from './routes/exec.js';
|
|
@@ -190,6 +191,14 @@ let recentPushStatus = {
|
|
|
190
191
|
|
|
191
192
|
const showConsole = true;
|
|
192
193
|
async function startUIServer(noOpen = false, savePort = false) {
|
|
194
|
+
// 数据目录布局迁移:把散在主目录的历史文件收进 ~/.zen-gitsync/(见 src/paths.js)。
|
|
195
|
+
// 必须早于任何数据读写 —— 尤其是下面的实例注册表(它要读 instances/ 目录)。
|
|
196
|
+
// 幂等 + 永不抛错,失败只是"这轮没搬成",下次启动重试。
|
|
197
|
+
const migrationReport = await migrateDataDir();
|
|
198
|
+
if (migrationReport.moved.length || migrationReport.cleaned.length) {
|
|
199
|
+
logger.info(`[dataDirMigration] 搬移 ${migrationReport.moved.length} 项、留档 ${migrationReport.cleaned.length} 项`);
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
const app = express();
|
|
194
203
|
const httpServer = createServer(app);
|
|
195
204
|
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
import express from 'express';
|
|
16
16
|
import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
|
|
17
17
|
import path from 'path';
|
|
18
|
-
import os from 'os';
|
|
19
18
|
import fs from 'fs/promises';
|
|
20
19
|
import open from 'open';
|
|
21
20
|
import logger from '../utils/logger.js';
|
|
21
|
+
import { CONFIG_FILE } from '../../../paths.js';
|
|
22
22
|
|
|
23
23
|
// 跳过的产物/资源/lock 文件(用 stat 一行带过,不打 patch)
|
|
24
24
|
const SKIP_FILE_PATTERNS = [
|
|
@@ -345,7 +345,7 @@ export function registerConfigRoutes({
|
|
|
345
345
|
|
|
346
346
|
res.json(config)
|
|
347
347
|
} catch (error) {
|
|
348
|
-
const configPath =
|
|
348
|
+
const configPath = CONFIG_FILE
|
|
349
349
|
res.status(500).json({
|
|
350
350
|
success: false,
|
|
351
351
|
code: 'CONFIG_LOAD_FAILED',
|
|
@@ -396,7 +396,7 @@ export function registerConfigRoutes({
|
|
|
396
396
|
// 检查系统配置文件格式
|
|
397
397
|
app.get('/api/config/check-file-format', asyncRoute(async (req, res) => {
|
|
398
398
|
try {
|
|
399
|
-
const configPath =
|
|
399
|
+
const configPath = CONFIG_FILE;
|
|
400
400
|
|
|
401
401
|
try {
|
|
402
402
|
const data = await fs.readFile(configPath, 'utf-8');
|
|
@@ -428,16 +428,19 @@ export function registerConfigRoutes({
|
|
|
428
428
|
}
|
|
429
429
|
}));
|
|
430
430
|
|
|
431
|
-
// 使用系统默认程序打开配置文件
|
|
431
|
+
// 使用系统默认程序打开配置文件(~/.zen-gitsync/config.json)
|
|
432
432
|
app.post('/api/config/open-file', asyncRoute(async (req, res) => {
|
|
433
433
|
try {
|
|
434
|
-
const filePath =
|
|
434
|
+
const filePath = CONFIG_FILE;
|
|
435
435
|
try {
|
|
436
436
|
// 检查文件是否存在,不存在也尝试让系统创建(可能会打开空文件)
|
|
437
437
|
await fs.access(filePath);
|
|
438
438
|
} catch (_) {
|
|
439
|
-
//
|
|
439
|
+
// 如果文件不存在,先创建一个最小结构,避免某些系统无法打开不存在的路径。
|
|
440
|
+
// 注意:新路径是嵌套目录,首次打开时数据目录可能还没建(正常启动流程里由
|
|
441
|
+
// dataDirMigration 建好,这里是独立于迁移的兜底)。
|
|
440
442
|
try {
|
|
443
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
441
444
|
await fs.writeFile(filePath, '{}', 'utf-8');
|
|
442
445
|
} catch (e) {
|
|
443
446
|
// 创建失败不阻断打开尝试
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
|
|
36
36
|
import { createHash } from 'node:crypto';
|
|
37
37
|
import fs from 'node:fs/promises';
|
|
38
|
-
import os from 'node:os';
|
|
39
38
|
import path from 'node:path';
|
|
40
39
|
import express from 'express';
|
|
41
40
|
import { callLlmStream } from '../workbench/llmClient.js';
|
|
42
41
|
import { ensureWithinCwd } from '../../utils/pathGuard.js';
|
|
43
42
|
import { createThinkFilter } from '../../../../cli/ai/streamFilter.js';
|
|
43
|
+
import { AI_DIFF_SUMMARIES_DIR } from '../../../../paths.js';
|
|
44
44
|
|
|
45
45
|
// prompt 预算:diff 超过预算只保留头部并标注截断,防止 token 爆炸
|
|
46
46
|
const MAX_FILE_DIFF_CHARS = 8000;
|
|
@@ -51,7 +51,7 @@ const MAX_UNTRACKED_FILES = 40;
|
|
|
51
51
|
// 提示词或输出契约变化时递增,避免复用旧规则生成的说明。
|
|
52
52
|
const SUMMARY_CACHE_VERSION = 1;
|
|
53
53
|
const MAX_SUMMARY_CACHE_ENTRIES = 300;
|
|
54
|
-
const SUMMARY_CACHE_DIR =
|
|
54
|
+
const SUMMARY_CACHE_DIR = AI_DIFF_SUMMARIES_DIR;
|
|
55
55
|
|
|
56
56
|
function hashText(value) {
|
|
57
57
|
return createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
assertGitConfigKey,
|
|
34
34
|
assertGitConfigValue
|
|
35
35
|
} from '../utils/gitArgs.js';
|
|
36
|
+
import {
|
|
37
|
+
describeGitProcessFailure,
|
|
38
|
+
GIT_NOT_FOUND
|
|
39
|
+
} from '../utils/gitExitCode.js';
|
|
36
40
|
|
|
37
41
|
// 解析 push 的 remote 参数:不传 = 空数组(裸 push 走上游,历史行为);
|
|
38
42
|
// 传了则结合当前分支与上游计算 refspec(详见 remotes.js 的 buildPushRefs)。
|
|
@@ -354,7 +358,26 @@ export function registerGitOpsRoutes({
|
|
|
354
358
|
logger.info('推送成功,已设置推送状态标记');
|
|
355
359
|
res.json({ success: true, message: stdout });
|
|
356
360
|
} catch (error) {
|
|
357
|
-
|
|
361
|
+
// PATH 里没有 git.exe:走 spawn 失败而不是退出码,单独给指引
|
|
362
|
+
if (error?.code === 'ENOENT') {
|
|
363
|
+
return res.status(500).json({ success: false, ...GIT_NOT_FOUND });
|
|
364
|
+
}
|
|
365
|
+
// HttpError(参数校验失败)走原样返回;只有 git 进程真的失败(error.code 是退出码)时
|
|
366
|
+
// 才做退出码翻译 —— 零输出时 error.message 只有 "Command failed: git push"。
|
|
367
|
+
if (typeof error?.code !== 'number' || error?.statusCode) {
|
|
368
|
+
return res.status(error?.statusCode || 500).json({ success: false, error: error.message });
|
|
369
|
+
}
|
|
370
|
+
const failure = describeGitProcessFailure({
|
|
371
|
+
code: error.code,
|
|
372
|
+
stdout: error.stdout,
|
|
373
|
+
stderr: error.stderr
|
|
374
|
+
});
|
|
375
|
+
res.status(500).json({
|
|
376
|
+
success: false,
|
|
377
|
+
error: failure.error,
|
|
378
|
+
errorCode: failure.errorCode,
|
|
379
|
+
exitCode: failure.exitCode
|
|
380
|
+
});
|
|
358
381
|
}
|
|
359
382
|
});
|
|
360
383
|
|
|
@@ -394,7 +417,24 @@ export function registerGitOpsRoutes({
|
|
|
394
417
|
res.json({ success: true, message: stdout });
|
|
395
418
|
} catch (error) {
|
|
396
419
|
// assertGitRef 抛的是 HttpError(400),别吞成 500
|
|
397
|
-
|
|
420
|
+
if (error?.code === 'ENOENT') {
|
|
421
|
+
return res.status(500).json({ success: false, ...GIT_NOT_FOUND });
|
|
422
|
+
}
|
|
423
|
+
if (typeof error?.code !== 'number' || error?.statusCode) {
|
|
424
|
+
return res.status(error?.statusCode || 500).json({ success: false, error: error.message });
|
|
425
|
+
}
|
|
426
|
+
// 与 /api/push 同理:git 进程级失败(含零输出的 NTSTATUS)翻成人话
|
|
427
|
+
const failure = describeGitProcessFailure({
|
|
428
|
+
code: error.code,
|
|
429
|
+
stdout: error.stdout,
|
|
430
|
+
stderr: error.stderr
|
|
431
|
+
});
|
|
432
|
+
res.status(500).json({
|
|
433
|
+
success: false,
|
|
434
|
+
error: failure.error,
|
|
435
|
+
errorCode: failure.errorCode,
|
|
436
|
+
exitCode: failure.exitCode
|
|
437
|
+
});
|
|
398
438
|
}
|
|
399
439
|
});
|
|
400
440
|
|
|
@@ -603,22 +643,31 @@ export function registerGitOpsRoutes({
|
|
|
603
643
|
message: standardOutput || errorOutput || 'Push successful'
|
|
604
644
|
});
|
|
605
645
|
} else {
|
|
606
|
-
//
|
|
646
|
+
// 推送失败。git 自己报错时 stderr 里有 `fatal: ...`,直接透传;
|
|
647
|
+
// 但进程**没跑起来**时 stdout/stderr 全空,退出码是 3221225794(0xC0000142)
|
|
648
|
+
// 这类 NTSTATUS —— 原样抛给用户等于什么都没说,必须翻译成人话。
|
|
607
649
|
logger.error('推送失败:', errorOutput || standardOutput);
|
|
650
|
+
const failure = describeGitProcessFailure({
|
|
651
|
+
code,
|
|
652
|
+
stdout: standardOutput,
|
|
653
|
+
stderr: errorOutput
|
|
654
|
+
});
|
|
608
655
|
|
|
609
656
|
// 添加到命令历史(失败情况)
|
|
610
657
|
addCommandToHistory(
|
|
611
658
|
pushCommandLabel,
|
|
612
659
|
standardOutput,
|
|
613
660
|
errorOutput,
|
|
614
|
-
|
|
661
|
+
failure.error,
|
|
615
662
|
executionTime
|
|
616
663
|
);
|
|
617
664
|
|
|
618
665
|
sendProgress({
|
|
619
666
|
type: 'complete',
|
|
620
667
|
success: false,
|
|
621
|
-
error:
|
|
668
|
+
error: failure.error,
|
|
669
|
+
errorCode: failure.errorCode,
|
|
670
|
+
exitCode: failure.exitCode
|
|
622
671
|
});
|
|
623
672
|
}
|
|
624
673
|
finish();
|
|
@@ -630,19 +679,26 @@ export function registerGitOpsRoutes({
|
|
|
630
679
|
// 计算执行时间
|
|
631
680
|
const executionTime = Date.now() - startTime;
|
|
632
681
|
|
|
682
|
+
// ENOENT = PATH 里没有 git.exe。这不是"推送失败"而是"根本没得推",
|
|
683
|
+
// 给明确指引比抛 "spawn git ENOENT" 有用。
|
|
684
|
+
const failure = error?.code === 'ENOENT'
|
|
685
|
+
? GIT_NOT_FOUND
|
|
686
|
+
: { errorCode: null, error: error.message };
|
|
687
|
+
|
|
633
688
|
// 添加到命令历史(错误情况)
|
|
634
689
|
addCommandToHistory(
|
|
635
690
|
'git push --progress',
|
|
636
691
|
'',
|
|
637
692
|
'',
|
|
638
|
-
error
|
|
693
|
+
failure.error,
|
|
639
694
|
executionTime
|
|
640
695
|
);
|
|
641
696
|
|
|
642
697
|
sendProgress({
|
|
643
698
|
type: 'complete',
|
|
644
699
|
success: false,
|
|
645
|
-
error: error
|
|
700
|
+
error: failure.error,
|
|
701
|
+
errorCode: failure.errorCode
|
|
646
702
|
});
|
|
647
703
|
finish();
|
|
648
704
|
});
|
|
@@ -27,16 +27,13 @@
|
|
|
27
27
|
import fsp from 'fs/promises';
|
|
28
28
|
import path from 'path';
|
|
29
29
|
import { logger } from './shared.js';
|
|
30
|
+
import { AGENT_SESSIONS_DIR } from '../../../../paths.js';
|
|
30
31
|
|
|
31
|
-
// 数据目录:与 ai-split-sessions
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
// 上面路径在 Windows 下不可靠,改用 os.homedir
|
|
38
|
-
import os from 'os';
|
|
39
|
-
export const AGENT_SESSIONS_DIR_FIXED = path.join(os.homedir(), '.zen-gitsync', 'agent-sessions');
|
|
32
|
+
// 数据目录:与 ai-split-sessions 平级,定义见 src/paths.js
|
|
33
|
+
export { AGENT_SESSIONS_DIR };
|
|
34
|
+
// 历史遗留别名:此前这里有个"从 import.meta.url 推路径"的错误实现(Windows 下不可靠),
|
|
35
|
+
// 后来补了个 _FIXED 版本两套并存。现在统一到 paths.js 的单一常量。
|
|
36
|
+
export const AGENT_SESSIONS_DIR_FIXED = AGENT_SESSIONS_DIR;
|
|
40
37
|
|
|
41
38
|
export const MAX_AGENT_SESSIONS = 200;
|
|
42
39
|
export const AGENT_SESSIONS_KEEP = 100;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// - mime → ext 与前端 el-upload accept 对齐
|
|
21
21
|
// - sanitizeExt 防路径穿越:未在白名单的后缀返回 fallback(默认 'bin')
|
|
22
22
|
|
|
23
|
+
import path from 'path';
|
|
23
24
|
import fsp from 'fs/promises';
|
|
24
25
|
import { IMAGES_DIR } from './shared.js';
|
|
25
26
|
|
|
@@ -74,3 +75,95 @@ export function resolveExt({ originalName, mime }) {
|
|
|
74
75
|
if (fromName) return fromName;
|
|
75
76
|
return null;
|
|
76
77
|
}
|
|
78
|
+
|
|
79
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
// 派发附件暂存区
|
|
81
|
+
//
|
|
82
|
+
// 主 Agent 控制台要能在**任务被建出来之前**收附件(粘贴的截图 / 选的文件),
|
|
83
|
+
// 而 §17 的上传端点都要求 taskId / subId 作为挂载点 —— 派发那一刻两者都还不存在。
|
|
84
|
+
// 所以先落到这个暂存目录:文件名 `{attId}.{ext}`,不写任何 JSON,
|
|
85
|
+
// 附件记录由前端持有;派发成功时服务端再把它 move 进 `_task-{taskId}/`。
|
|
86
|
+
//
|
|
87
|
+
// 目录名带下划线前缀是为了不和 `_task-{id}` / `{subId}` 撞名
|
|
88
|
+
// (genId 形如 `mu55toof-2555ou`,不会以 `_` 开头)。
|
|
89
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
90
|
+
export const DISPATCH_STAGING_DIR = path.join(IMAGES_DIR, '_dispatch');
|
|
91
|
+
|
|
92
|
+
/** genId 的实际形状;暂存区只认这种 id,挡掉 `../` 之类的路径穿越 */
|
|
93
|
+
const ATT_ID_RE = /^[a-z0-9][a-z0-9-]{4,62}$/i;
|
|
94
|
+
|
|
95
|
+
export function isSafeAttId(id) {
|
|
96
|
+
return typeof id === 'string' && ATT_ID_RE.test(id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 反向映射(raw 端点用):暂存附件没有 JSON 记录,mime 只能从后缀推。
|
|
100
|
+
// MIME_TO_EXT 里同一族只登记了规范后缀,白名单中的别名要单独补,
|
|
101
|
+
// 否则 `a.jpeg` 会被当成 octet-stream 下载而不是 inline 显示。
|
|
102
|
+
const EXT_TO_MIME = (() => {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [mime, ext] of Object.entries(MIME_TO_EXT)) {
|
|
105
|
+
if (!out[ext]) out[ext] = mime;
|
|
106
|
+
}
|
|
107
|
+
const aliases = { jpeg: 'image/jpeg', markdown: 'text/markdown' };
|
|
108
|
+
for (const [ext, mime] of Object.entries(aliases)) {
|
|
109
|
+
if (!out[ext]) out[ext] = mime;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
export function mimeForExt(ext) {
|
|
115
|
+
return EXT_TO_MIME[String(ext || '').toLowerCase()] || 'application/octet-stream';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 暂存文件的绝对路径。
|
|
120
|
+
* id 与后缀都要过白名单/形状校验,任一不合法返回 null —— 路径完全由服务端拼,
|
|
121
|
+
* 前端只提供 id + ext,因此不存在把文件写到暂存区之外的可能。
|
|
122
|
+
*/
|
|
123
|
+
export function stagingPath(attId, ext) {
|
|
124
|
+
if (!isSafeAttId(attId)) return null;
|
|
125
|
+
const e = String(ext || '').toLowerCase();
|
|
126
|
+
if (!ALLOWED_EXTS.has(e)) return null;
|
|
127
|
+
return path.join(DISPATCH_STAGING_DIR, `${attId}.${e}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 按 id 在暂存区里找实际文件(删除 / 预览用,不需要前端提供 ext) */
|
|
131
|
+
export async function findStagingFile(attId) {
|
|
132
|
+
if (!isSafeAttId(attId)) return null;
|
|
133
|
+
let names;
|
|
134
|
+
try {
|
|
135
|
+
names = await fsp.readdir(DISPATCH_STAGING_DIR);
|
|
136
|
+
} catch {
|
|
137
|
+
return null; // 目录还不存在 = 没有任何暂存文件
|
|
138
|
+
}
|
|
139
|
+
const hit = names.find(n => n.startsWith(attId + '.'));
|
|
140
|
+
return hit ? path.join(DISPATCH_STAGING_DIR, hit) : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 清理超时的暂存文件(用户粘了图却没派发就关页面 → 不留永久垃圾)。
|
|
145
|
+
* 进程启动时跑一次即可,不必定时器:暂存区的生命周期本来就是"几分钟内派发掉"。
|
|
146
|
+
*/
|
|
147
|
+
export async function cleanupDispatchStaging(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
148
|
+
let names;
|
|
149
|
+
try {
|
|
150
|
+
names = await fsp.readdir(DISPATCH_STAGING_DIR);
|
|
151
|
+
} catch {
|
|
152
|
+
return 0; // 目录不存在,没什么可清
|
|
153
|
+
}
|
|
154
|
+
const deadline = Date.now() - maxAgeMs;
|
|
155
|
+
let removed = 0;
|
|
156
|
+
for (const name of names) {
|
|
157
|
+
const full = path.join(DISPATCH_STAGING_DIR, name);
|
|
158
|
+
try {
|
|
159
|
+
const st = await fsp.stat(full);
|
|
160
|
+
if (!st.isFile() || st.mtimeMs >= deadline) continue;
|
|
161
|
+
await fsp.unlink(full);
|
|
162
|
+
removed++;
|
|
163
|
+
} catch { /* 单个文件出问题不该让整轮清理中断 */ }
|
|
164
|
+
}
|
|
165
|
+
if (removed > 0) {
|
|
166
|
+
try { await fsp.rmdir(DISPATCH_STAGING_DIR); } catch { /* 还有别的文件留在里面,留着目录 */ }
|
|
167
|
+
}
|
|
168
|
+
return removed;
|
|
169
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 派发附件暂存区的纯函数单测。
|
|
16
|
+
//
|
|
17
|
+
// 这一层是**安全边界**:派发时前端只回传 { id, ext },文件路径完全由服务端拼。
|
|
18
|
+
// 所以"无论前端传什么,产物都不会跑出暂存目录"必须由断言钉死 ——
|
|
19
|
+
// 一旦 stagingPath 的校验被放松,就等于开了一个任意文件读取/覆盖的口子。
|
|
20
|
+
//
|
|
21
|
+
// 只测纯函数,不碰真实文件系统:临时目录相关行为(findStagingFile / cleanup)
|
|
22
|
+
// 由端到端验证覆盖,避免单测在用户真实的 ~/.zen-gitsync 里制造/删除文件。
|
|
23
|
+
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
DISPATCH_STAGING_DIR,
|
|
30
|
+
ALLOWED_EXTS,
|
|
31
|
+
isSafeAttId,
|
|
32
|
+
mimeForExt,
|
|
33
|
+
stagingPath,
|
|
34
|
+
} from './attachmentUtils.js';
|
|
35
|
+
|
|
36
|
+
test('stagingPath:合法 id + 白名单后缀 → 落在暂存目录内', () => {
|
|
37
|
+
const p = stagingPath('mu55toof-2555ou', 'png');
|
|
38
|
+
assert.equal(p, path.join(DISPATCH_STAGING_DIR, 'mu55toof-2555ou.png'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('stagingPath:大写后缀归一成小写', () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
stagingPath('mu55toof-2555ou', 'PNG'),
|
|
44
|
+
path.join(DISPATCH_STAGING_DIR, 'mu55toof-2555ou.png'),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('stagingPath:id 含路径分隔符 / 上跳 / 空白一律拒绝', () => {
|
|
49
|
+
const bad = ['../../etc/passwd', 'a/b', 'a\\b', '..', '', 'x', 'a b', 'a/../b', 'a'.repeat(70)];
|
|
50
|
+
for (const id of bad) {
|
|
51
|
+
assert.equal(stagingPath(id, 'png'), null, `应拒绝 id=${JSON.stringify(id)}`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('stagingPath:后缀不在白名单一律拒绝(含可执行文件与拼接花招)', () => {
|
|
56
|
+
const bad = ['exe', 'sh', 'bat', '', '.', 'png/../evil', 'png.exe', '..'];
|
|
57
|
+
for (const ext of bad) {
|
|
58
|
+
assert.equal(stagingPath('mu55toof-2555ou', ext), null, `应拒绝 ext=${JSON.stringify(ext)}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('stagingPath:任何输入下产物都不会跳出暂存目录', () => {
|
|
63
|
+
const ids = ['../../evil', '..\\..\\evil', '/abs', 'C:/windows/x', 'a'.repeat(100), 'a--b'];
|
|
64
|
+
const exts = ['png', 'jpg', 'pdf', '../../x', 'exe'];
|
|
65
|
+
for (const id of ids) {
|
|
66
|
+
for (const ext of exts) {
|
|
67
|
+
const p = stagingPath(id, ext);
|
|
68
|
+
if (p === null) continue;
|
|
69
|
+
assert.equal(path.dirname(p), DISPATCH_STAGING_DIR, `产物跑出了暂存目录:${p}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('isSafeAttId:只放行 genId 形状', () => {
|
|
75
|
+
assert.ok(isSafeAttId('mu55toof-2555ou'));
|
|
76
|
+
assert.ok(isSafeAttId('abc12'));
|
|
77
|
+
assert.ok(!isSafeAttId('ab'), '太短');
|
|
78
|
+
assert.ok(!isSafeAttId('a'.repeat(70)), '太长');
|
|
79
|
+
assert.ok(!isSafeAttId('has space'));
|
|
80
|
+
assert.ok(!isSafeAttId('has/slash'));
|
|
81
|
+
assert.ok(!isSafeAttId('-leading-dash'), '不能以连字符开头');
|
|
82
|
+
assert.ok(!isSafeAttId(''));
|
|
83
|
+
assert.ok(!isSafeAttId(null));
|
|
84
|
+
assert.ok(!isSafeAttId(42));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('mimeForExt:常见后缀给具体类型,未知回退 octet-stream', () => {
|
|
88
|
+
assert.equal(mimeForExt('png'), 'image/png');
|
|
89
|
+
assert.equal(mimeForExt('JPG'), 'image/jpeg');
|
|
90
|
+
assert.equal(mimeForExt('pdf'), 'application/pdf');
|
|
91
|
+
assert.equal(mimeForExt('exe'), 'application/octet-stream');
|
|
92
|
+
assert.equal(mimeForExt(''), 'application/octet-stream');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('白名单与 mime 映射同源:每个允许的后缀都能反查出具体 mime', () => {
|
|
96
|
+
for (const ext of ALLOWED_EXTS) {
|
|
97
|
+
const m = mimeForExt(ext);
|
|
98
|
+
assert.ok(m && m !== 'application/octet-stream', `${ext} 没有对应的具体 mime`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 执行时注入给 Agent 的「运行环境上下文」。
|
|
16
|
+
//
|
|
17
|
+
// 解决什么问题:从主 Agent 调度台发出去的指令,最终 prompt 只是用户原文
|
|
18
|
+
// + 附件清单(拼装见 promptParts.js),cwd 之外的项目、看板进度、历史指令
|
|
19
|
+
// 一概不在里面。于是用户问"我的项目有哪些",Agent 只能就着当前目录猜 ——
|
|
20
|
+
// 而数据其实全在本机,只是没人告诉它路径。
|
|
21
|
+
//
|
|
22
|
+
// 这里做两件事,缺一不可:
|
|
23
|
+
// 1. 给**摘要** —— 项目清单 + 各列任务计数(顺着看板口径,一眼看到进度);
|
|
24
|
+
// 2. 给**真相源文件路径** —— 要细节自己去读。
|
|
25
|
+
// 刻意不把 tasks.json 全文塞进 prompt:几十条任务、每条还挂着执行输出,
|
|
26
|
+
// 既烧 token,又会把用户真正要办的那句话淹掉。
|
|
27
|
+
//
|
|
28
|
+
// ⚠️ 文案为什么可以是中文硬编码(与 orchestratorStore 的「服务端不写死中文」不冲突):
|
|
29
|
+
// 那一条约束针对的是**回给 UI 渲染的文案**,而这里是喂给模型的 prompt 内容,
|
|
30
|
+
// 不经过前端 $t(),也不会显示在界面上。反过来说,它也不该跟着界面语言变 ——
|
|
31
|
+
// 中文指令配中文上下文,模型理解得更稳。
|
|
32
|
+
//
|
|
33
|
+
// 纯函数:只吃入参、不读文件(文件读取在调用方 routes/workbench/index.js)。
|
|
34
|
+
// 这样单测不用碰真实用户数据 —— 与 promptParts.js 同一个理由。
|
|
35
|
+
// 统计口径复用 projectRegistry 的 summarizeProjectTasks:「任务算哪一列」
|
|
36
|
+
// 全局只有这一个实现,看板怎么分列,这里就怎么统计。
|
|
37
|
+
|
|
38
|
+
import { summarizeProjectTasks, canonicalProjectPath, TASK_COLUMNS } from './projectRegistry.js';
|
|
39
|
+
|
|
40
|
+
/** 项目清单最多列几个:再多就该让 Agent 自己去读文件,而不是往 prompt 里堆 */
|
|
41
|
+
export const ENV_CONTEXT_MAX_PROJECTS = 30;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 列 key → 中文标签。**按 key 取名,不靠数组下标**:
|
|
45
|
+
* 顺序一律由 TASK_COLUMNS 决定,这里只负责"这列叫什么"。
|
|
46
|
+
* (上一轮去掉「评审中」列时,这份清单和下面的 `${s.todo}/${s.doing}/${s.done}` 是两处
|
|
47
|
+
* 各写各的,改一处漏一处 —— 现在两份都从 TASK_COLUMNS 推。)
|
|
48
|
+
*/
|
|
49
|
+
const COLUMN_LABELS = { todo: '待处理', doing: '进行中', done: '已完成' };
|
|
50
|
+
|
|
51
|
+
/** 按 TASK_COLUMNS 的顺序取标签;缺标签时回落成 key 本身,
|
|
52
|
+
* 宁可让 Agent 看到 `todo` 也不要看到空段(空段会让列与标签整体错位)。 */
|
|
53
|
+
const columnLabels = TASK_COLUMNS.map(k => COLUMN_LABELS[k] || k);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 拼运行环境上下文块。
|
|
57
|
+
*
|
|
58
|
+
* @param {object} input
|
|
59
|
+
* @param {string} input.currentProjectPath 本次任务的工作目录(= Agent 的 cwd)
|
|
60
|
+
* @param {object[]} input.projects 项目条目(buildProjectEntries 的输出:{name, path, key})
|
|
61
|
+
* @param {object[]} input.tasks tasks.json 里的全部任务
|
|
62
|
+
* @param {object[]} input.jobs 执行记录(snapshotJobs() 的结果)
|
|
63
|
+
* @param {string} input.tasksFile 真相源文件绝对路径(下面几个同理)
|
|
64
|
+
* @param {string} input.jobsFile
|
|
65
|
+
* @param {string} input.orchestratorFile
|
|
66
|
+
* @param {string} input.configFile
|
|
67
|
+
* @param {number} [input.maxProjects] 只影响列出条数,不影响合计统计
|
|
68
|
+
* @returns {string|null} 上下文块;没有任何项目可列时返回 null(调用方据此跳过注入)
|
|
69
|
+
*/
|
|
70
|
+
export function buildEnvContextBlock({
|
|
71
|
+
currentProjectPath = '',
|
|
72
|
+
projects = [],
|
|
73
|
+
tasks = [],
|
|
74
|
+
jobs = [],
|
|
75
|
+
tasksFile = '',
|
|
76
|
+
jobsFile = '',
|
|
77
|
+
orchestratorFile = '',
|
|
78
|
+
configFile = '',
|
|
79
|
+
maxProjects = ENV_CONTEXT_MAX_PROJECTS,
|
|
80
|
+
} = {}) {
|
|
81
|
+
const list = Array.isArray(projects) ? projects.filter(p => p && p.key) : [];
|
|
82
|
+
if (list.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
const stats = summarizeProjectTasks(
|
|
85
|
+
Array.isArray(tasks) ? tasks : [],
|
|
86
|
+
Array.isArray(jobs) ? jobs : [],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// 合计按全部任务算(含「未关联项目」那一桶),所以不能用 list 求和
|
|
90
|
+
const total = { total: 0 };
|
|
91
|
+
for (const key of TASK_COLUMNS) total[key] = 0;
|
|
92
|
+
for (const s of stats.values()) {
|
|
93
|
+
total.total += s.total;
|
|
94
|
+
for (const key of TASK_COLUMNS) total[key] += s[key] || 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const limit = Number.isFinite(maxProjects) && maxProjects > 0 ? Math.floor(maxProjects) : ENV_CONTEXT_MAX_PROJECTS;
|
|
98
|
+
const shown = list.slice(0, limit);
|
|
99
|
+
// 当前项目的比对**必须归一化后再比**:条目 key 是 canonicalProjectPath 的结果
|
|
100
|
+
// (Windows 形式小写 + 斜杠归一如 d:\ws\x),而调用方给的 currentProjectPath 是原始写法
|
|
101
|
+
// (盘符/目录段大小写都不一定一致)。直接拿 path 跟 key 比会永远不等 —— 同款口径分叉
|
|
102
|
+
// 在后端项目列表里已经制造过一次「同名项目分裂成两个」,这里不重蹈。
|
|
103
|
+
const currentKey = canonicalProjectPath(currentProjectPath);
|
|
104
|
+
|
|
105
|
+
const lines = [];
|
|
106
|
+
lines.push('[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容]');
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`当前任务所在项目(也是你的工作目录): ${currentProjectPath || '(未指定)'}`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(`项目清单(共 ${list.length} 个;格式: 名称 | 路径 | ${columnLabels.join('/')}):`);
|
|
111
|
+
for (const p of shown) {
|
|
112
|
+
const s = stats.get(p.key) || {};
|
|
113
|
+
const mark = currentKey && p.key === currentKey ? ' ← 当前' : '';
|
|
114
|
+
lines.push(`- ${p.name || p.key} | ${p.path} | ${TASK_COLUMNS.map(k => s[k] || 0).join('/')}${mark}`);
|
|
115
|
+
}
|
|
116
|
+
if (list.length > shown.length) {
|
|
117
|
+
lines.push(`- …还有 ${list.length - shown.length} 个未列出(读下面的文件可以看全)`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(
|
|
121
|
+
`看板任务概览:全部项目合计 ${total.total} 条 —— ` +
|
|
122
|
+
TASK_COLUMNS.map(k => `${COLUMN_LABELS[k]} ${total[k] || 0}`).join(' / '),
|
|
123
|
+
);
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
|
|
126
|
+
if (tasksFile) lines.push(`- ${tasksFile} —— 全部任务全文(标题/描述/子任务状态/报错/projectPath)`);
|
|
127
|
+
if (jobsFile) lines.push(`- ${jobsFile} —— 历次执行记录(状态与输出)`);
|
|
128
|
+
if (orchestratorFile) lines.push(`- ${orchestratorFile} —— 用户在调度台发过的指令流水`);
|
|
129
|
+
if (configFile) lines.push(`- ${configFile} —— 应用配置(projects / recentDirectories)`);
|
|
130
|
+
lines.push('');
|
|
131
|
+
lines.push(
|
|
132
|
+
'用户问到"我有哪些项目""某件事进展如何"这类问题时,先读上面的文件再回答;' +
|
|
133
|
+
'不要凭当前目录猜测,也不要回答"我看不到/无法访问"。',
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|