aws-runtime-bridge 1.7.31 → 1.7.32
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/dist/routes/git.d.ts +15 -0
- package/dist/routes/git.d.ts.map +1 -1
- package/dist/routes/git.js +290 -33
- package/dist/routes/git.test.d.ts +0 -3
- package/dist/routes/git.test.d.ts.map +1 -1
- package/dist/routes/git.test.js +20 -172
- package/dist/routes/instance.d.ts.map +1 -1
- package/dist/routes/instance.js +126 -24
- package/dist/routes/instance.test.js +77 -0
- package/package.json +1 -1
package/dist/routes/git.d.ts
CHANGED
|
@@ -9,6 +9,14 @@ export declare const gitRouter: import("express-serve-static-core").Router;
|
|
|
9
9
|
* 主流程:优先使用环境变量,非法或过小配置回落到默认值,避免 Git 检测请求长期挂起。
|
|
10
10
|
*/
|
|
11
11
|
export declare function resolveGitCommandTimeoutMs(): number;
|
|
12
|
+
interface GitRepositoryContext {
|
|
13
|
+
workspacePath: string;
|
|
14
|
+
repositoryRootPath: string | null;
|
|
15
|
+
hasRepository: boolean;
|
|
16
|
+
isGitRepo: boolean;
|
|
17
|
+
usingParentGitRepo: boolean;
|
|
18
|
+
relativeWorkspacePath: string;
|
|
19
|
+
}
|
|
12
20
|
export declare function assertGitWorkspacePathAccessible(workspacePath: string): Promise<void>;
|
|
13
21
|
type GitDiffFileStatus = 'modified' | 'added' | 'deleted' | 'renamed';
|
|
14
22
|
/**
|
|
@@ -36,5 +44,12 @@ interface LatestGitStash {
|
|
|
36
44
|
* 主流程:提取 stash 引用并保留后续描述,供无变更快照复用最近快照引用。
|
|
37
45
|
*/
|
|
38
46
|
export declare function parseLatestGitStash(output: string): LatestGitStash | null;
|
|
47
|
+
/**
|
|
48
|
+
* 解析 git diff 的 name-status 与 numstat 输出,生成前端文件树需要的汇总数据。
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseGitStatusStagedPaths(output: string): Set<string>;
|
|
51
|
+
export declare function normalizeGitCommitMessage(message: unknown): string;
|
|
52
|
+
export declare function createScopedGitPathspecArgs(context: Pick<GitRepositoryContext, 'relativeWorkspacePath'>): string[];
|
|
53
|
+
export declare function createScopedFilePathspecArgs(filePath: string, context: Pick<GitRepositoryContext, 'relativeWorkspacePath'>): string[];
|
|
39
54
|
export {};
|
|
40
55
|
//# sourceMappingURL=git.d.ts.map
|
package/dist/routes/git.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../../src/routes/git.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AASH,eAAO,MAAM,SAAS,4CAAW,CAAC;AAIlC;;;GAGG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAOnD;
|
|
1
|
+
{"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../../src/routes/git.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AASH,eAAO,MAAM,SAAS,4CAAW,CAAC;AAIlC;;;GAGG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAOnD;AAED,UAAU,oBAAoB;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAED,wBAAsB,gCAAgC,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe3F;AASD,KAAK,iBAAiB,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAkBtE;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,MAAM,EAAE,iBAAiB,EACzB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,OAAO,GACpB,OAAO,CAMT;AAED;;;GAGG;AACH,wBAAgB,mCAAmC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAK3E;AAED,wBAAgB,uCAAuC,IAAI,MAAM,CAEhE;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAKjE;AAED,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAWzE;AA+KD;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAmBrE;AAkDD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAElE;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,IAAI,CAAC,oBAAoB,EAAE,uBAAuB,CAAC,GAAG,MAAM,EAAE,CAElH;AAED,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,oBAAoB,EAAE,uBAAuB,CAAC,GAAG,MAAM,EAAE,CAErI"}
|
package/dist/routes/git.js
CHANGED
|
@@ -232,6 +232,96 @@ function parseRenameDisplayPath(rawPath) {
|
|
|
232
232
|
}
|
|
233
233
|
return normalizedPath;
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* 解析 git diff 的 name-status 与 numstat 输出,生成前端文件树需要的汇总数据。
|
|
237
|
+
*/
|
|
238
|
+
export function parseGitStatusStagedPaths(output) {
|
|
239
|
+
const stagedPaths = new Set();
|
|
240
|
+
const lines = String(output || '').split('\n').filter(line => line.trim());
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
const indexStatus = line[0] || ' ';
|
|
243
|
+
if (indexStatus === ' ' || indexStatus === '?') {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const rawPath = line.slice(3).trim();
|
|
247
|
+
const pathParts = rawPath.split(' -> ');
|
|
248
|
+
const displayPath = parseRenameDisplayPath(pathParts[pathParts.length - 1] || rawPath);
|
|
249
|
+
if (displayPath) {
|
|
250
|
+
stagedPaths.add(displayPath);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return stagedPaths;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 解析 git diff 的 name-status 与 numstat 输出,生成前端文件树需要的汇总数据。
|
|
257
|
+
*/
|
|
258
|
+
function parseGitDiffSummary(nameStatusOutput, numstatOutput, stagedPaths = new Set()) {
|
|
259
|
+
const statusMap = new Map();
|
|
260
|
+
const nameStatusLines = nameStatusOutput.split('\n').filter(line => line.trim());
|
|
261
|
+
for (const line of nameStatusLines) {
|
|
262
|
+
const parts = line.split('\t');
|
|
263
|
+
if (parts.length < 2) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const status = resolveGitDiffStatus(parts[0]);
|
|
267
|
+
const pathIndex = status === 'renamed' && parts.length >= 3 ? 2 : 1;
|
|
268
|
+
const displayPath = parseRenameDisplayPath(parts[pathIndex]);
|
|
269
|
+
if (!displayPath) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
statusMap.set(displayPath, status);
|
|
273
|
+
}
|
|
274
|
+
const files = [];
|
|
275
|
+
const lines = numstatOutput.split('\n').filter(line => line.trim());
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
const parts = line.split('\t');
|
|
278
|
+
if (parts.length < 3)
|
|
279
|
+
continue;
|
|
280
|
+
const isBinaryDiff = parts[0] === '-' || parts[1] === '-';
|
|
281
|
+
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
|
|
282
|
+
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
|
|
283
|
+
const filePath = parseRenameDisplayPath(parts.slice(2).join('\t').trim());
|
|
284
|
+
const status = statusMap.get(filePath) || 'modified';
|
|
285
|
+
if (!filePath || !shouldIncludeGitDiffSummaryFile(status, additions, deletions, isBinaryDiff)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
files.push({ path: filePath, status, additions, deletions, staged: stagedPaths.has(filePath) });
|
|
289
|
+
}
|
|
290
|
+
return files;
|
|
291
|
+
}
|
|
292
|
+
function normalizeCommitHash(commitHash) {
|
|
293
|
+
return String(commitHash || '').trim();
|
|
294
|
+
}
|
|
295
|
+
export function normalizeGitCommitMessage(message) {
|
|
296
|
+
return String(message || '').trim();
|
|
297
|
+
}
|
|
298
|
+
export function createScopedGitPathspecArgs(context) {
|
|
299
|
+
return ['--', context.relativeWorkspacePath || '.'];
|
|
300
|
+
}
|
|
301
|
+
export function createScopedFilePathspecArgs(filePath, context) {
|
|
302
|
+
return ['--', resolveRepositoryRelativeFilePath(filePath, context)];
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 解析 git log 的 NUL 分隔输出,避免提交标题中的特殊字符破坏字段切分。
|
|
306
|
+
*/
|
|
307
|
+
function parseGitCommitLog(output) {
|
|
308
|
+
const fields = output.split('\0');
|
|
309
|
+
const commits = [];
|
|
310
|
+
for (let index = 0; index + 4 < fields.length; index += 5) {
|
|
311
|
+
const hash = fields[index]?.trim();
|
|
312
|
+
if (!hash) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
commits.push({
|
|
316
|
+
hash,
|
|
317
|
+
shortHash: fields[index + 1]?.trim() || hash.slice(0, 8),
|
|
318
|
+
authorName: fields[index + 2]?.trim() || 'unknown',
|
|
319
|
+
authorDate: fields[index + 3]?.trim() || '',
|
|
320
|
+
subject: fields[index + 4]?.trim() || hash,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return commits;
|
|
324
|
+
}
|
|
235
325
|
/**
|
|
236
326
|
* 检查路径是否为 git 仓库
|
|
237
327
|
* POST /runtime/git/check
|
|
@@ -504,38 +594,12 @@ gitRouter.post('/git/diff', validateToken, async (req, res) => {
|
|
|
504
594
|
res.status(400).json({ error: result.stderr || 'git diff failed' });
|
|
505
595
|
return;
|
|
506
596
|
}
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (parts.length < 2) {
|
|
512
|
-
continue;
|
|
513
|
-
}
|
|
514
|
-
const status = resolveGitDiffStatus(parts[0]);
|
|
515
|
-
const pathIndex = status === 'renamed' && parts.length >= 3 ? 2 : 1;
|
|
516
|
-
const displayPath = parseRenameDisplayPath(parts[pathIndex]);
|
|
517
|
-
if (!displayPath) {
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
statusMap.set(displayPath, status);
|
|
521
|
-
}
|
|
522
|
-
const files = [];
|
|
523
|
-
const lines = result.stdout.split('\n').filter(line => line.trim());
|
|
524
|
-
for (const line of lines) {
|
|
525
|
-
// 格式: "12\t5\tsrc/file.java"
|
|
526
|
-
const parts = line.split('\t');
|
|
527
|
-
if (parts.length < 3)
|
|
528
|
-
continue;
|
|
529
|
-
const isBinaryDiff = parts[0] === '-' || parts[1] === '-';
|
|
530
|
-
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
|
|
531
|
-
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
|
|
532
|
-
const filePath = parseRenameDisplayPath(parts.slice(2).join('\t').trim());
|
|
533
|
-
const status = statusMap.get(filePath) || 'modified';
|
|
534
|
-
if (!filePath || !shouldIncludeGitDiffSummaryFile(status, additions, deletions, isBinaryDiff)) {
|
|
535
|
-
continue;
|
|
536
|
-
}
|
|
537
|
-
files.push({ path: filePath, status, additions, deletions });
|
|
597
|
+
const statusResult = await execGitCommand(context.repositoryRootPath, createScopedGitArgs(['status', '--porcelain=v1'], context));
|
|
598
|
+
if (statusResult.exitCode !== 0) {
|
|
599
|
+
res.status(400).json({ error: statusResult.stderr || 'git status failed' });
|
|
600
|
+
return;
|
|
538
601
|
}
|
|
602
|
+
const files = parseGitDiffSummary(nameStatusResult.stdout, result.stdout, parseGitStatusStagedPaths(statusResult.stdout));
|
|
539
603
|
res.json({
|
|
540
604
|
ok: true,
|
|
541
605
|
isGitRepo: context.isGitRepo,
|
|
@@ -553,12 +617,200 @@ gitRouter.post('/git/diff', validateToken, async (req, res) => {
|
|
|
553
617
|
res.status(500).json({ error: err.message || 'git diff failed' });
|
|
554
618
|
}
|
|
555
619
|
});
|
|
620
|
+
/**
|
|
621
|
+
* 提交工作区未提交变更
|
|
622
|
+
* POST /runtime/git/commit
|
|
623
|
+
*/
|
|
624
|
+
gitRouter.post('/git/commit', validateToken, async (req, res) => {
|
|
625
|
+
const { workspacePath, repositoryPath, message } = req.body || {};
|
|
626
|
+
if (!workspacePath || !String(workspacePath).trim()) {
|
|
627
|
+
res.status(400).json({ error: 'workspacePath is required' });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const commitMessage = normalizeGitCommitMessage(message);
|
|
631
|
+
if (!commitMessage) {
|
|
632
|
+
res.status(400).json({ error: 'commit message is required' });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const context = await resolveGitRepositoryContext(String(repositoryPath || workspacePath).trim());
|
|
637
|
+
if (!context.hasRepository || !context.repositoryRootPath) {
|
|
638
|
+
res.status(400).json({ error: 'workspace is not inside a git repository' });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const pathspecArgs = createScopedGitPathspecArgs(context);
|
|
642
|
+
const addResult = await execGitCommand(context.repositoryRootPath, ['add', '--all', ...pathspecArgs]);
|
|
643
|
+
if (addResult.exitCode !== 0) {
|
|
644
|
+
res.status(400).json({ error: addResult.stderr || 'git add failed' });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const stagedResult = await execGitCommand(context.repositoryRootPath, ['diff', '--cached', '--quiet', ...pathspecArgs]);
|
|
648
|
+
if (stagedResult.exitCode === 0) {
|
|
649
|
+
res.status(400).json({ error: '没有可提交的变更' });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (stagedResult.exitCode !== 1) {
|
|
653
|
+
res.status(400).json({ error: stagedResult.stderr || 'git staged diff failed' });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const commitResult = await execGitCommand(context.repositoryRootPath, ['commit', '-m', commitMessage, ...pathspecArgs]);
|
|
657
|
+
if (commitResult.exitCode !== 0) {
|
|
658
|
+
res.status(400).json({ error: commitResult.stderr || commitResult.stdout || 'git commit failed' });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
res.json({
|
|
662
|
+
ok: true,
|
|
663
|
+
message: '提交完成',
|
|
664
|
+
output: commitResult.stdout,
|
|
665
|
+
workspacePath: context.workspacePath,
|
|
666
|
+
repositoryRootPath: context.repositoryRootPath,
|
|
667
|
+
relativeWorkspacePath: context.relativeWorkspacePath,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
const err = error;
|
|
672
|
+
res.status(500).json({ error: err.message || 'git commit failed' });
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
/**
|
|
676
|
+
* 将单个文件加入暂存区
|
|
677
|
+
* POST /runtime/git/add-file
|
|
678
|
+
*/
|
|
679
|
+
gitRouter.post('/git/add-file', validateToken, async (req, res) => {
|
|
680
|
+
const { workspacePath, repositoryPath, filePath } = req.body || {};
|
|
681
|
+
if (!workspacePath || !String(workspacePath).trim()) {
|
|
682
|
+
res.status(400).json({ error: 'workspacePath is required' });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const normalizedFilePath = normalizeGitPath(String(filePath || '').trim());
|
|
686
|
+
if (!normalizedFilePath) {
|
|
687
|
+
res.status(400).json({ error: 'filePath is required' });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const context = await resolveGitRepositoryContext(String(repositoryPath || workspacePath).trim());
|
|
692
|
+
if (!context.hasRepository || !context.repositoryRootPath) {
|
|
693
|
+
res.status(400).json({ error: 'workspace is not inside a git repository' });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const addResult = await execGitCommand(context.repositoryRootPath, ['add', ...createScopedFilePathspecArgs(normalizedFilePath, context)]);
|
|
697
|
+
if (addResult.exitCode !== 0) {
|
|
698
|
+
res.status(400).json({ error: addResult.stderr || 'git add file failed' });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
res.json({
|
|
702
|
+
ok: true,
|
|
703
|
+
filePath: normalizedFilePath,
|
|
704
|
+
workspacePath: context.workspacePath,
|
|
705
|
+
repositoryRootPath: context.repositoryRootPath,
|
|
706
|
+
relativeWorkspacePath: context.relativeWorkspacePath,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
const err = error;
|
|
711
|
+
res.status(500).json({ error: err.message || 'git add file failed' });
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
/**
|
|
715
|
+
* 获取工作区提交列表
|
|
716
|
+
* POST /runtime/git/commits
|
|
717
|
+
*/
|
|
718
|
+
gitRouter.post('/git/commits', validateToken, async (req, res) => {
|
|
719
|
+
const { workspacePath, repositoryPath, limit } = req.body || {};
|
|
720
|
+
if (!workspacePath || !String(workspacePath).trim()) {
|
|
721
|
+
res.status(400).json({ error: 'workspacePath is required' });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const context = await resolveGitRepositoryContext(String(repositoryPath || workspacePath).trim());
|
|
726
|
+
if (!context.hasRepository || !context.repositoryRootPath) {
|
|
727
|
+
res.json({ ok: true, commits: [], workspacePath: context.workspacePath, repositoryRootPath: null });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const parsedLimit = Number(limit);
|
|
731
|
+
const safeLimit = Number.isInteger(parsedLimit) && parsedLimit > 0 && parsedLimit <= 100 ? parsedLimit : 30;
|
|
732
|
+
const result = await execGitCommand(context.repositoryRootPath, [
|
|
733
|
+
'log',
|
|
734
|
+
`-${safeLimit}`,
|
|
735
|
+
'--date=iso-strict',
|
|
736
|
+
'--pretty=format:%H%x00%h%x00%an%x00%ad%x00%s%x00',
|
|
737
|
+
'--',
|
|
738
|
+
context.relativeWorkspacePath || '.',
|
|
739
|
+
]);
|
|
740
|
+
if (result.exitCode !== 0) {
|
|
741
|
+
res.status(400).json({ error: result.stderr || 'git log failed' });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
res.json({
|
|
745
|
+
ok: true,
|
|
746
|
+
commits: parseGitCommitLog(result.stdout),
|
|
747
|
+
workspacePath: context.workspacePath,
|
|
748
|
+
repositoryRootPath: context.repositoryRootPath,
|
|
749
|
+
relativeWorkspacePath: context.relativeWorkspacePath,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
const err = error;
|
|
754
|
+
res.status(500).json({ error: err.message || 'git commits failed' });
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
/**
|
|
758
|
+
* 获取指定提交相对父提交的变更概览
|
|
759
|
+
* POST /runtime/git/commit-diff
|
|
760
|
+
*/
|
|
761
|
+
gitRouter.post('/git/commit-diff', validateToken, async (req, res) => {
|
|
762
|
+
const { workspacePath, repositoryPath, commitHash } = req.body || {};
|
|
763
|
+
if (!workspacePath || !String(workspacePath).trim()) {
|
|
764
|
+
res.status(400).json({ error: 'workspacePath is required' });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const normalizedCommitHash = normalizeCommitHash(commitHash);
|
|
768
|
+
if (!normalizedCommitHash) {
|
|
769
|
+
res.status(400).json({ error: 'commitHash is required' });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
const context = await resolveGitRepositoryContext(String(repositoryPath || workspacePath).trim());
|
|
774
|
+
if (!context.hasRepository || !context.repositoryRootPath) {
|
|
775
|
+
res.status(400).json({ error: 'workspace is not inside a git repository' });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const diffBaseArgs = ['diff', `${normalizedCommitHash}^!`];
|
|
779
|
+
const nameStatusResult = await execGitCommand(context.repositoryRootPath, createScopedGitArgs([...diffBaseArgs, '--name-status'], context));
|
|
780
|
+
if (nameStatusResult.exitCode !== 0) {
|
|
781
|
+
res.status(400).json({ error: nameStatusResult.stderr || 'git commit diff failed' });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const numstatResult = await execGitCommand(context.repositoryRootPath, createScopedGitArgs([...diffBaseArgs, '--numstat'], context));
|
|
785
|
+
if (numstatResult.exitCode !== 0) {
|
|
786
|
+
res.status(400).json({ error: numstatResult.stderr || 'git commit diff failed' });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const files = parseGitDiffSummary(nameStatusResult.stdout, numstatResult.stdout);
|
|
790
|
+
res.json({
|
|
791
|
+
ok: true,
|
|
792
|
+
commitHash: normalizedCommitHash,
|
|
793
|
+
isGitRepo: context.isGitRepo,
|
|
794
|
+
hasRepository: true,
|
|
795
|
+
usingParentGitRepo: context.usingParentGitRepo,
|
|
796
|
+
hasChanges: files.length > 0,
|
|
797
|
+
files,
|
|
798
|
+
workspacePath: context.workspacePath,
|
|
799
|
+
repositoryRootPath: context.repositoryRootPath,
|
|
800
|
+
relativeWorkspacePath: context.relativeWorkspacePath,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
const err = error;
|
|
805
|
+
res.status(500).json({ error: err.message || 'git commit diff failed' });
|
|
806
|
+
}
|
|
807
|
+
});
|
|
556
808
|
/**
|
|
557
809
|
* 获取单个文件的详细差异
|
|
558
810
|
* POST /runtime/git/diff-file
|
|
559
811
|
*/
|
|
560
812
|
gitRouter.post('/git/diff-file', validateToken, async (req, res) => {
|
|
561
|
-
const { workspacePath, filePath } = req.body || {};
|
|
813
|
+
const { workspacePath, filePath, commitHash } = req.body || {};
|
|
562
814
|
if (!workspacePath || !String(workspacePath).trim()) {
|
|
563
815
|
res.status(400).json({ error: 'workspacePath is required' });
|
|
564
816
|
return;
|
|
@@ -577,7 +829,11 @@ gitRouter.post('/git/diff-file', validateToken, async (req, res) => {
|
|
|
577
829
|
return;
|
|
578
830
|
}
|
|
579
831
|
const repositoryRelativeFilePath = resolveRepositoryRelativeFilePath(normalizedFilePath, context);
|
|
580
|
-
const
|
|
832
|
+
const normalizedCommitHash = normalizeCommitHash(commitHash);
|
|
833
|
+
const gitArgs = normalizedCommitHash
|
|
834
|
+
? ['show', '--format=', '--no-ext-diff', normalizedCommitHash, '--', repositoryRelativeFilePath]
|
|
835
|
+
: ['diff', '--', repositoryRelativeFilePath];
|
|
836
|
+
const result = await execGitCommand(context.repositoryRootPath, gitArgs);
|
|
581
837
|
if (result.exitCode !== 0) {
|
|
582
838
|
res.status(400).json({ error: result.stderr || 'git diff file failed' });
|
|
583
839
|
return;
|
|
@@ -586,6 +842,7 @@ gitRouter.post('/git/diff-file', validateToken, async (req, res) => {
|
|
|
586
842
|
ok: true,
|
|
587
843
|
filePath: normalizedFilePath,
|
|
588
844
|
repositoryRelativeFilePath,
|
|
845
|
+
commitHash: normalizedCommitHash || null,
|
|
589
846
|
diffContent: result.stdout,
|
|
590
847
|
workspacePath: context.workspacePath,
|
|
591
848
|
repositoryRootPath: context.repositoryRootPath,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git.test.d.ts","sourceRoot":"","sources":["../../src/routes/git.test.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"git.test.d.ts","sourceRoot":"","sources":["../../src/routes/git.test.ts"],"names":[],"mappings":""}
|
package/dist/routes/git.test.js
CHANGED
|
@@ -1,175 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git 路由单元测试
|
|
3
|
-
*/
|
|
4
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
1
|
import { describe, expect, it } from 'vitest';
|
|
8
|
-
import {
|
|
9
|
-
describe('git
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return stashes;
|
|
29
|
-
}
|
|
30
|
-
it('parses simple stash list output', () => {
|
|
31
|
-
const output = 'stash@{0}: On main: 快照: 添加新功能';
|
|
32
|
-
const result = parseStashList(output);
|
|
33
|
-
expect(result.length).toBe(1);
|
|
34
|
-
expect(result[0].ref).toBe('stash@{0}');
|
|
35
|
-
expect(result[0].branch).toBe('main');
|
|
36
|
-
expect(result[0].message).toBe('快照: 添加新功能');
|
|
37
|
-
});
|
|
38
|
-
it('parses WIP stash format', () => {
|
|
39
|
-
const output = 'stash@{0}: WIP on feature: test changes';
|
|
40
|
-
const result = parseStashList(output);
|
|
41
|
-
expect(result.length).toBe(1);
|
|
42
|
-
expect(result[0].branch).toBe('feature');
|
|
43
|
-
expect(result[0].message).toBe('test changes');
|
|
44
|
-
});
|
|
45
|
-
it('handles multiple stash entries', () => {
|
|
46
|
-
const output = [
|
|
47
|
-
'stash@{0}: On main: 最新快照',
|
|
48
|
-
'stash@{1}: On main: 旧快照',
|
|
49
|
-
].join('\n');
|
|
50
|
-
const result = parseStashList(output);
|
|
51
|
-
expect(result.length).toBe(2);
|
|
52
|
-
});
|
|
53
|
-
it('returns empty array for empty output', () => {
|
|
54
|
-
expect(parseStashList('')).toEqual([]);
|
|
55
|
-
});
|
|
56
|
-
it('parses latest stash for no-change snapshot reuse', () => {
|
|
57
|
-
const result = parseLatestGitStash('stash@{0}: On master: 快照: 快照: t1\n');
|
|
58
|
-
expect(result).toEqual({
|
|
59
|
-
ref: 'stash@{0}',
|
|
60
|
-
message: 'On master: 快照: 快照: t1',
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
it('returns null when there is no latest stash to reuse', () => {
|
|
64
|
-
expect(parseLatestGitStash('')).toBeNull();
|
|
65
|
-
expect(parseLatestGitStash('not a stash line')).toBeNull();
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
describe('git command validation', () => {
|
|
69
|
-
it('falls back to a safe git command timeout for invalid configuration', () => {
|
|
70
|
-
const previous = process.env.AWS_RUNTIME_GIT_COMMAND_TIMEOUT_MS;
|
|
71
|
-
try {
|
|
72
|
-
process.env.AWS_RUNTIME_GIT_COMMAND_TIMEOUT_MS = 'not-a-number';
|
|
73
|
-
expect(resolveGitCommandTimeoutMs()).toBe(10_000);
|
|
74
|
-
process.env.AWS_RUNTIME_GIT_COMMAND_TIMEOUT_MS = '999';
|
|
75
|
-
expect(resolveGitCommandTimeoutMs()).toBe(10_000);
|
|
76
|
-
process.env.AWS_RUNTIME_GIT_COMMAND_TIMEOUT_MS = '1500';
|
|
77
|
-
expect(resolveGitCommandTimeoutMs()).toBe(1500);
|
|
78
|
-
}
|
|
79
|
-
finally {
|
|
80
|
-
if (previous === undefined) {
|
|
81
|
-
delete process.env.AWS_RUNTIME_GIT_COMMAND_TIMEOUT_MS;
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
process.env.AWS_RUNTIME_GIT_COMMAND_TIMEOUT_MS = previous;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
it('validates workspacePath requirement', () => {
|
|
89
|
-
const validateInput = (body) => {
|
|
90
|
-
if (!body.workspacePath || !String(body.workspacePath).trim()) {
|
|
91
|
-
return { valid: false, error: 'workspacePath is required' };
|
|
92
|
-
}
|
|
93
|
-
return { valid: true };
|
|
94
|
-
};
|
|
95
|
-
expect(validateInput({}).valid).toBe(false);
|
|
96
|
-
expect(validateInput({ workspacePath: '/valid/path' }).valid).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
it('validates stashRef format', () => {
|
|
99
|
-
const isValidStashRef = (ref) => /^stash@\{\d+\}$/.test(ref);
|
|
100
|
-
expect(isValidStashRef('stash@{0}')).toBe(true);
|
|
101
|
-
expect(isValidStashRef('stash@{99}')).toBe(true);
|
|
102
|
-
expect(isValidStashRef('invalid')).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
describe('git workspace path validation', () => {
|
|
106
|
-
it('accepts existing workspace directories', async () => {
|
|
107
|
-
const workspaceDir = await mkdtemp(path.join(tmpdir(), 'aws-git-workspace-'));
|
|
108
|
-
try {
|
|
109
|
-
await expect(assertGitWorkspacePathAccessible(workspaceDir)).resolves.toBeUndefined();
|
|
110
|
-
}
|
|
111
|
-
finally {
|
|
112
|
-
await rm(workspaceDir, { recursive: true, force: true });
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
it('rejects missing workspace paths with a diagnostic error', async () => {
|
|
116
|
-
const missingPath = path.join(tmpdir(), `aws-missing-workspace-${Date.now()}`);
|
|
117
|
-
await expect(assertGitWorkspacePathAccessible(missingPath)).rejects.toThrow('workspace path does not exist');
|
|
118
|
-
});
|
|
119
|
-
it('rejects file paths because git cwd must be a directory', async () => {
|
|
120
|
-
const workspaceDir = await mkdtemp(path.join(tmpdir(), 'aws-git-workspace-file-'));
|
|
121
|
-
const filePath = path.join(workspaceDir, 'workspace.txt');
|
|
122
|
-
try {
|
|
123
|
-
await writeFile(filePath, 'not a directory');
|
|
124
|
-
await expect(assertGitWorkspacePathAccessible(filePath)).rejects.toThrow('workspace path is not a directory');
|
|
125
|
-
}
|
|
126
|
-
finally {
|
|
127
|
-
await rm(workspaceDir, { recursive: true, force: true });
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
describe('git diff summary visibility', () => {
|
|
132
|
-
it('filters ordinary modified files without textual changes', () => {
|
|
133
|
-
expect(shouldIncludeGitDiffSummaryFile('modified', 0, 0, false)).toBe(false);
|
|
134
|
-
});
|
|
135
|
-
it('keeps modified files with text or binary changes', () => {
|
|
136
|
-
expect(shouldIncludeGitDiffSummaryFile('modified', 1, 0, false)).toBe(true);
|
|
137
|
-
expect(shouldIncludeGitDiffSummaryFile('modified', 0, 1, false)).toBe(true);
|
|
138
|
-
expect(shouldIncludeGitDiffSummaryFile('modified', 0, 0, true)).toBe(true);
|
|
139
|
-
});
|
|
140
|
-
it('keeps structural status changes even when line counts are zero', () => {
|
|
141
|
-
expect(shouldIncludeGitDiffSummaryFile('added', 0, 0, false)).toBe(true);
|
|
142
|
-
expect(shouldIncludeGitDiffSummaryFile('deleted', 0, 0, false)).toBe(true);
|
|
143
|
-
expect(shouldIncludeGitDiffSummaryFile('renamed', 0, 0, false)).toBe(true);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
describe('error handling', () => {
|
|
147
|
-
it('detects "no local changes" error', () => {
|
|
148
|
-
expect(isGitStashNoChangesOutput('No local changes to save')).toBe(true);
|
|
149
|
-
expect(isGitStashNoChangesOutput('stdout: No changes to save')).toBe(true);
|
|
150
|
-
expect(isGitStashNoChangesOutput('没有本地变更需要保存')).toBe(true);
|
|
151
|
-
expect(isGitStashNoChangesOutput('other error')).toBe(false);
|
|
152
|
-
});
|
|
153
|
-
it('detects stash failure before initial commit and returns an actionable message', () => {
|
|
154
|
-
expect(isGitStashMissingInitialCommitError('You do not have the initial commit yet\n')).toBe(true);
|
|
155
|
-
expect(isGitStashMissingInitialCommitError("fatal: bad revision 'HEAD'\n")).toBe(true);
|
|
156
|
-
expect(isGitStashMissingInitialCommitError("fatal: ambiguous argument 'HEAD': unknown revision\n")).toBe(true);
|
|
157
|
-
expect(isGitStashMissingInitialCommitError('No local changes to save')).toBe(false);
|
|
158
|
-
expect(createMissingInitialCommitSnapshotError()).toContain('请先提交一次初始提交');
|
|
159
|
-
});
|
|
160
|
-
it('detects conflict during pop', () => {
|
|
161
|
-
const hasConflict = (stdout, stderr) => stdout.includes('CONFLICT') || stderr.includes('CONFLICT');
|
|
162
|
-
expect(hasConflict('CONFLICT (content): Merge conflict', '')).toBe(true);
|
|
163
|
-
expect(hasConflict('clean merge', '')).toBe(false);
|
|
164
|
-
});
|
|
165
|
-
it('uses apply as the non-consuming restore command', () => {
|
|
166
|
-
const buildRestoreCommand = (mode, ref) => ['stash', mode, ref];
|
|
167
|
-
expect(buildRestoreCommand('apply', 'stash@{0}')).toEqual(['stash', 'apply', 'stash@{0}']);
|
|
168
|
-
expect(buildRestoreCommand('pop', 'stash@{0}')).toEqual(['stash', 'pop', 'stash@{0}']);
|
|
169
|
-
});
|
|
170
|
-
it('detects invalid stash reference', () => {
|
|
171
|
-
const isInvalidRef = (stderr) => stderr.includes('is not a valid reference');
|
|
172
|
-
expect(isInvalidRef('stash@{99} is not a valid reference')).toBe(true);
|
|
173
|
-
expect(isInvalidRef('other error')).toBe(false);
|
|
2
|
+
import { createScopedFilePathspecArgs, createScopedGitPathspecArgs, normalizeGitCommitMessage, parseGitStatusStagedPaths, } from './git.js';
|
|
3
|
+
describe('git commit helpers', () => {
|
|
4
|
+
it('normalizes commit messages before executing git commit', () => {
|
|
5
|
+
expect(normalizeGitCommitMessage(' chore: update dashboard ')).toBe('chore: update dashboard');
|
|
6
|
+
expect(normalizeGitCommitMessage(' ')).toBe('');
|
|
7
|
+
expect(normalizeGitCommitMessage(null)).toBe('');
|
|
8
|
+
});
|
|
9
|
+
it('creates pathspec args scoped to the selected workspace path', () => {
|
|
10
|
+
expect(createScopedGitPathspecArgs({ relativeWorkspacePath: 'packages/app' })).toEqual(['--', 'packages/app']);
|
|
11
|
+
expect(createScopedGitPathspecArgs({ relativeWorkspacePath: '' })).toEqual(['--', '.']);
|
|
12
|
+
});
|
|
13
|
+
it('creates file pathspec args relative to parent git repositories', () => {
|
|
14
|
+
expect(createScopedFilePathspecArgs('src/App.vue', { relativeWorkspacePath: 'packages/app' })).toEqual([
|
|
15
|
+
'--',
|
|
16
|
+
'packages/app/src/App.vue',
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
it('parses staged paths from porcelain status output', () => {
|
|
20
|
+
const stagedPaths = parseGitStatusStagedPaths('M staged.ts\n M unstaged.ts\nA added.ts\n?? untracked.ts\nR old.ts -> renamed.ts\n');
|
|
21
|
+
expect([...stagedPaths].sort()).toEqual(['added.ts', 'renamed.ts', 'staged.ts']);
|
|
174
22
|
});
|
|
175
23
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/routes/instance.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/routes/instance.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAoBrD,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAC/D,IAAI,CAEN;AAED,eAAO,MAAM,cAAc,4CAAW,CAAC;AAmGvC,wBAAgB,4BAA4B,CAAC,gBAAgB,EAAE,MAAM,GAAG;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE;QACJ,EAAE,EAAE,OAAO,CAAC;QACZ,aAAa,CAAC,EAAE,SAAS,CAAC;QAC1B,oBAAoB,EAAE,OAAO,CAAC;QAC9B,qBAAqB,EAAE,OAAO,CAAC;QAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH,CAqCA;AAED,MAAM,WAAW,4BAA4B;IAC3C,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,gBAAgB,CAAC;IAC/B,aAAa,EAAE,SAAS,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,iCAAiC;IACzC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,8BAA8B,CAAC;IACtC,YAAY,EAAE,gBAAgB,CAAC;IAC/B,aAAa,EAAE,SAAS,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,KAAK,EACZ,0BAA0B,EAAE,MAAM,GACjC,4BAA4B,CAiB9B;AAoDD;;;GAGG;AACH,wBAAgB,sCAAsC,CACpD,UAAU,EAAE,MAAM,EAAE,GACnB,iCAAiC,CAUnC;AAED;;;GAGG;AACH,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAQhF;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,cAAc,EAAE,MAAM,EAAE,EACxB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAC5C,MAAM,CAaR;AAED,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAC5C,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,WAAW,GAAG,SAAS,GAAG,YAAY,GAAG,OAAO,CAAC,CAAC,CAY3F"}
|
package/dist/routes/instance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import axios from "axios";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
5
|
import { validateToken } from "../middleware/auth.js";
|
|
5
6
|
import { resolveSchedulerBaseUrlFrom } from "../config.js";
|
|
@@ -20,6 +21,63 @@ export function setGracefulShutdownFn(fn) {
|
|
|
20
21
|
gracefulShutdownFn = fn;
|
|
21
22
|
}
|
|
22
23
|
export const instanceRouter = Router();
|
|
24
|
+
function resolveNpmExecutable() {
|
|
25
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
26
|
+
}
|
|
27
|
+
function truncateCommandOutput(output) {
|
|
28
|
+
const maxLength = 16_000;
|
|
29
|
+
return output.length > maxLength
|
|
30
|
+
? `${output.slice(output.length - maxLength)}\n[output truncated]`
|
|
31
|
+
: output;
|
|
32
|
+
}
|
|
33
|
+
function runCommand(command, args) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = spawn(command, args, {
|
|
36
|
+
shell: false,
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
});
|
|
39
|
+
let stdout = "";
|
|
40
|
+
let stderr = "";
|
|
41
|
+
child.stdout.on("data", (chunk) => {
|
|
42
|
+
stdout += chunk.toString("utf8");
|
|
43
|
+
});
|
|
44
|
+
child.stderr.on("data", (chunk) => {
|
|
45
|
+
stderr += chunk.toString("utf8");
|
|
46
|
+
});
|
|
47
|
+
child.on("error", reject);
|
|
48
|
+
child.on("close", (exitCode) => {
|
|
49
|
+
resolve({
|
|
50
|
+
command,
|
|
51
|
+
args,
|
|
52
|
+
exitCode,
|
|
53
|
+
stdout: truncateCommandOutput(stdout),
|
|
54
|
+
stderr: truncateCommandOutput(stderr),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function writeRestartSessionFlag(preserveSessions) {
|
|
60
|
+
const fs = await import("node:fs/promises");
|
|
61
|
+
const path = await import("node:path");
|
|
62
|
+
const os = await import("node:os");
|
|
63
|
+
const restartFlagFile = path.join(os.tmpdir(), "agentswork-runtime-bridge", ".preserve-sessions");
|
|
64
|
+
if (preserveSessions) {
|
|
65
|
+
await fs.mkdir(path.dirname(restartFlagFile), { recursive: true });
|
|
66
|
+
await fs.writeFile(restartFlagFile, JSON.stringify({
|
|
67
|
+
preserveSessions: true,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
}), "utf-8");
|
|
70
|
+
log.info(`[prepare-restart] 已创建保留会话标记文件: ${restartFlagFile}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await fs.unlink(restartFlagFile);
|
|
75
|
+
log.info("[prepare-restart] 已删除保留会话标记文件");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// 文件不存在时无需处理。
|
|
79
|
+
}
|
|
80
|
+
}
|
|
23
81
|
function md5Hex(value) {
|
|
24
82
|
return createHash("md5").update(value, "utf8").digest("hex");
|
|
25
83
|
}
|
|
@@ -488,30 +546,8 @@ instanceRouter.post("/prepare-restart", validateToken, async (req, res) => {
|
|
|
488
546
|
const { preserveSessions = true } = req.body || {};
|
|
489
547
|
try {
|
|
490
548
|
log.info(`[prepare-restart] 收到准备重启通知,preserveSessions=${preserveSessions}`);
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
const path = await import("path");
|
|
494
|
-
const os = await import("os");
|
|
495
|
-
const restartFlagFile = path.join(os.tmpdir(), "agentswork-runtime-bridge", ".preserve-sessions");
|
|
496
|
-
if (preserveSessions) {
|
|
497
|
-
// 创建标记文件,表示 aws-mcp-server 重启,需要保留会话
|
|
498
|
-
await fs.mkdir(path.dirname(restartFlagFile), { recursive: true });
|
|
499
|
-
await fs.writeFile(restartFlagFile, JSON.stringify({
|
|
500
|
-
preserveSessions: true,
|
|
501
|
-
timestamp: new Date().toISOString(),
|
|
502
|
-
}), "utf-8");
|
|
503
|
-
log.info(`[prepare-restart] 已创建保留会话标记文件: ${restartFlagFile}`);
|
|
504
|
-
}
|
|
505
|
-
else {
|
|
506
|
-
// 删除标记文件,表示 bridge 自己关闭,需要清理会话
|
|
507
|
-
try {
|
|
508
|
-
await fs.unlink(restartFlagFile);
|
|
509
|
-
log.info(`[prepare-restart] 已删除保留会话标记文件`);
|
|
510
|
-
}
|
|
511
|
-
catch {
|
|
512
|
-
// 文件不存在,忽略
|
|
513
|
-
}
|
|
514
|
-
}
|
|
549
|
+
// 记录重启模式到文件,供下次启动时判断。
|
|
550
|
+
await writeRestartSessionFlag(Boolean(preserveSessions));
|
|
515
551
|
res.json({
|
|
516
552
|
ok: true,
|
|
517
553
|
preserveSessions,
|
|
@@ -526,3 +562,69 @@ instanceRouter.post("/prepare-restart", validateToken, async (req, res) => {
|
|
|
526
562
|
res.status(500).json({ error: err.message });
|
|
527
563
|
}
|
|
528
564
|
});
|
|
565
|
+
/**
|
|
566
|
+
* POST /runtime/update-bridge
|
|
567
|
+
*
|
|
568
|
+
* 通过当前 Bridge 进程执行全局 npm 包更新,等价于 CLI 的
|
|
569
|
+
* `awsb update`,用于面板上的“更新实例 Bridge”。
|
|
570
|
+
*/
|
|
571
|
+
instanceRouter.post("/update-bridge", validateToken, async (_req, res) => {
|
|
572
|
+
const command = resolveNpmExecutable();
|
|
573
|
+
const args = ["install", "-g", "aws-runtime-bridge@latest"];
|
|
574
|
+
try {
|
|
575
|
+
log.info("[update-bridge] 正在更新 aws-runtime-bridge 到最新版本");
|
|
576
|
+
const result = await runCommand(command, args);
|
|
577
|
+
if (result.exitCode !== 0) {
|
|
578
|
+
res.status(500).json({
|
|
579
|
+
ok: false,
|
|
580
|
+
error: `更新失败,npm 退出码: ${result.exitCode ?? "unknown"}`,
|
|
581
|
+
result,
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
res.json({
|
|
586
|
+
ok: true,
|
|
587
|
+
message: "Bridge 更新完成,请重启实例 Bridge 以加载新版本。",
|
|
588
|
+
result,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
const err = error;
|
|
593
|
+
log.error("[update-bridge] Error:", err);
|
|
594
|
+
res.status(500).json({ error: err.message || "update bridge failed" });
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
/**
|
|
598
|
+
* POST /runtime/restart-bridge
|
|
599
|
+
*
|
|
600
|
+
* 写入保留会话标记后优雅关闭当前 Bridge 进程。真正的重新拉起由
|
|
601
|
+
* systemd、PM2、Docker 外部守护或用户手动启动完成。
|
|
602
|
+
*/
|
|
603
|
+
instanceRouter.post("/restart-bridge", validateToken, async (req, res) => {
|
|
604
|
+
const { preserveSessions = true } = req.body || {};
|
|
605
|
+
try {
|
|
606
|
+
log.info(`[restart-bridge] 收到实例 Bridge 重启请求,preserveSessions=${preserveSessions}`);
|
|
607
|
+
await writeRestartSessionFlag(Boolean(preserveSessions));
|
|
608
|
+
res.json({
|
|
609
|
+
ok: true,
|
|
610
|
+
preserveSessions: Boolean(preserveSessions),
|
|
611
|
+
message: "Bridge 正在优雅退出;若实例由 systemd/PM2 等守护管理,将自动重启,否则需要手动重新启动。",
|
|
612
|
+
});
|
|
613
|
+
setTimeout(() => {
|
|
614
|
+
if (gracefulShutdownFn) {
|
|
615
|
+
gracefulShutdownFn("PANEL_RESTART", Boolean(preserveSessions)).catch((error) => {
|
|
616
|
+
const err = error;
|
|
617
|
+
log.error("[restart-bridge] graceful shutdown failed:", err);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
process.exit(0);
|
|
623
|
+
}, 250);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const err = error;
|
|
627
|
+
log.error("[restart-bridge] Error:", err);
|
|
628
|
+
res.status(500).json({ error: err.message || "restart bridge failed" });
|
|
629
|
+
}
|
|
630
|
+
});
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Instance 路由单元测试
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
const spawnMock = vi.hoisted(() => vi.fn());
|
|
5
7
|
vi.mock('axios', () => ({
|
|
6
8
|
default: {
|
|
7
9
|
get: vi.fn(),
|
|
@@ -14,9 +16,36 @@ vi.mock('../services/auto-register.js', () => ({
|
|
|
14
16
|
vi.mock('../services/runtime-binding.js', () => ({
|
|
15
17
|
getRuntimeAccessToken: vi.fn(() => 'stale-runtime-token-123456'),
|
|
16
18
|
}));
|
|
19
|
+
vi.mock('node:child_process', async (importOriginal) => ({
|
|
20
|
+
...(await importOriginal()),
|
|
21
|
+
spawn: spawnMock,
|
|
22
|
+
}));
|
|
17
23
|
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
18
25
|
vi.clearAllMocks();
|
|
19
26
|
});
|
|
27
|
+
function mockSpawnResult(exitCode, stdoutText = '', stderrText = '') {
|
|
28
|
+
spawnMock.mockImplementationOnce(() => {
|
|
29
|
+
const child = new EventEmitter();
|
|
30
|
+
child.stdout = new EventEmitter();
|
|
31
|
+
child.stderr = new EventEmitter();
|
|
32
|
+
process.nextTick(() => {
|
|
33
|
+
if (stdoutText) {
|
|
34
|
+
child.stdout.emit('data', Buffer.from(stdoutText));
|
|
35
|
+
}
|
|
36
|
+
if (stderrText) {
|
|
37
|
+
child.stderr.emit('data', Buffer.from(stderrText));
|
|
38
|
+
}
|
|
39
|
+
child.emit('close', exitCode);
|
|
40
|
+
});
|
|
41
|
+
return child;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function createMockResponse() {
|
|
45
|
+
const json = vi.fn();
|
|
46
|
+
const status = vi.fn(() => ({ json }));
|
|
47
|
+
return { json, status };
|
|
48
|
+
}
|
|
20
49
|
describe('instance route validation', () => {
|
|
21
50
|
it('builds correct ping response when healthy', () => {
|
|
22
51
|
const buildPingResponse = (schedulerStatus, schedulerBaseUrl) => ({
|
|
@@ -292,4 +321,52 @@ describe('instance route validation', () => {
|
|
|
292
321
|
hint: expect.stringContaining('AWS_RUNTIME_SCHEDULER_BASE_URL'),
|
|
293
322
|
});
|
|
294
323
|
});
|
|
324
|
+
it('updates bridge package through npm global install route', async () => {
|
|
325
|
+
const { instanceRouter } = await import('./instance.js');
|
|
326
|
+
mockSpawnResult(0, 'updated');
|
|
327
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === '/update-bridge')?.route?.stack[1]?.handle;
|
|
328
|
+
expect(handler).toBeTypeOf('function');
|
|
329
|
+
const { json, status } = createMockResponse();
|
|
330
|
+
await handler?.({}, { json, status }, vi.fn());
|
|
331
|
+
expect(spawnMock).toHaveBeenCalledWith(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '-g', 'aws-runtime-bridge@latest'], { shell: false, windowsHide: true });
|
|
332
|
+
expect(status).not.toHaveBeenCalled();
|
|
333
|
+
expect(json).toHaveBeenCalledWith({
|
|
334
|
+
ok: true,
|
|
335
|
+
message: 'Bridge 更新完成,请重启实例 Bridge 以加载新版本。',
|
|
336
|
+
result: expect.objectContaining({ exitCode: 0, stdout: 'updated' }),
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
it('returns update failure details when npm exits non-zero', async () => {
|
|
340
|
+
const { instanceRouter } = await import('./instance.js');
|
|
341
|
+
mockSpawnResult(1, '', 'permission denied');
|
|
342
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === '/update-bridge')?.route?.stack[1]?.handle;
|
|
343
|
+
expect(handler).toBeTypeOf('function');
|
|
344
|
+
const { json, status } = createMockResponse();
|
|
345
|
+
await handler?.({}, { json, status }, vi.fn());
|
|
346
|
+
expect(status).toHaveBeenCalledWith(500);
|
|
347
|
+
expect(json).toHaveBeenCalledWith({
|
|
348
|
+
ok: false,
|
|
349
|
+
error: '更新失败,npm 退出码: 1',
|
|
350
|
+
result: expect.objectContaining({ exitCode: 1, stderr: 'permission denied' }),
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
it('responds before triggering graceful bridge restart', async () => {
|
|
354
|
+
vi.useFakeTimers();
|
|
355
|
+
const { instanceRouter, setGracefulShutdownFn } = await import('./instance.js');
|
|
356
|
+
const gracefulShutdown = vi.fn().mockResolvedValue(undefined);
|
|
357
|
+
setGracefulShutdownFn(gracefulShutdown);
|
|
358
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === '/restart-bridge')?.route?.stack[1]?.handle;
|
|
359
|
+
expect(handler).toBeTypeOf('function');
|
|
360
|
+
const { json, status } = createMockResponse();
|
|
361
|
+
await handler?.({ body: { preserveSessions: true } }, { json, status }, vi.fn());
|
|
362
|
+
expect(status).not.toHaveBeenCalled();
|
|
363
|
+
expect(json).toHaveBeenCalledWith({
|
|
364
|
+
ok: true,
|
|
365
|
+
preserveSessions: true,
|
|
366
|
+
message: 'Bridge 正在优雅退出;若实例由 systemd/PM2 等守护管理,将自动重启,否则需要手动重新启动。',
|
|
367
|
+
});
|
|
368
|
+
expect(gracefulShutdown).not.toHaveBeenCalled();
|
|
369
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
370
|
+
expect(gracefulShutdown).toHaveBeenCalledWith('PANEL_RESTART', true);
|
|
371
|
+
});
|
|
295
372
|
});
|