ai-project-manage-cli 6.0.53 → 6.0.54

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/index.js CHANGED
@@ -81,7 +81,7 @@ function buildAgentWsUrl(httpBase, apiKey) {
81
81
 
82
82
  // src/commands/init.ts
83
83
  import { join as join4 } from "path";
84
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
84
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
85
85
 
86
86
  // src/command-utils.ts
87
87
  import {
@@ -90,7 +90,8 @@ import {
90
90
  mkdirSync as mkdirSync2,
91
91
  readFileSync as readFileSync2,
92
92
  readdirSync,
93
- statSync
93
+ statSync,
94
+ writeFileSync as writeFileSync2
94
95
  } from "fs";
95
96
  import { basename, dirname, extname, join as join2, resolve as resolve2 } from "path";
96
97
  import { fileURLToPath } from "url";
@@ -183,6 +184,28 @@ function gitignoreIgnoresApm(line) {
183
184
  if (!pattern) return false;
184
185
  return APM_GITIGNORE_PATTERNS.some((re) => re.test(pattern));
185
186
  }
187
+ var APM_GITIGNORE_LINE = "**/.apm/**";
188
+ function ensureApmGitignoredInRepo(workdir) {
189
+ const gitignorePath = join2(workdir, ".gitignore");
190
+ const fsGitignorePath = toFsPath(gitignorePath);
191
+ if (!existsSync(fsGitignorePath)) {
192
+ writeFileSync2(fsGitignorePath, `${APM_GITIGNORE_LINE}
193
+ `, "utf8");
194
+ return true;
195
+ }
196
+ const content = readFileSync2(fsGitignorePath, "utf8");
197
+ if (content.split(/\r?\n/).some(gitignoreIgnoresApm)) {
198
+ return false;
199
+ }
200
+ const suffix = content.endsWith("\n") || content.length === 0 ? "" : "\n";
201
+ writeFileSync2(
202
+ fsGitignorePath,
203
+ `${content}${suffix}${APM_GITIGNORE_LINE}
204
+ `,
205
+ "utf8"
206
+ );
207
+ return true;
208
+ }
186
209
  function assertApmGitignoredInRepo(workdir) {
187
210
  const gitignorePath = join2(workdir, ".gitignore");
188
211
  const fsGitignorePath = toFsPath(gitignorePath);
@@ -346,7 +369,7 @@ async function copyTemplateFiles(targetDir, workdir = resolveWorkdirPath()) {
346
369
 
347
370
  // src/deployment-config-sync.ts
348
371
  import { join as join3 } from "path";
349
- import { writeFileSync as writeFileSync2 } from "fs";
372
+ import { writeFileSync as writeFileSync3 } from "fs";
350
373
 
351
374
  // src/api/client.ts
352
375
  import { createApiClient } from "listpage-http";
@@ -529,11 +552,11 @@ ${diagnostic ?? ""}
529
552
  }
530
553
  const targetApmDir = apmDir ?? workspaceApmDir(workdirPath);
531
554
  const apmConfigPath = toFsPath(join3(targetApmDir, "apm.config.json"));
532
- writeFileSync2(apmConfigPath, `${JSON.stringify(parsed, null, 2)}
555
+ writeFileSync3(apmConfigPath, `${JSON.stringify(parsed, null, 2)}
533
556
  `, "utf8");
534
557
  const deployDir = join3(targetApmDir, "deploy");
535
558
  await ensureDirExists(deployDir);
536
- writeFileSync2(
559
+ writeFileSync3(
537
560
  toFsPath(join3(deployDir, "README.md")),
538
561
  config.deploymentDoc ?? "",
539
562
  "utf8"
@@ -543,10 +566,75 @@ ${diagnostic ?? ""}
543
566
  return { synced: true, repositoryId, configName: config.name };
544
567
  }
545
568
 
569
+ // src/git-utils.ts
570
+ import { execFile as execFile2 } from "child_process";
571
+ import { promisify as promisify2 } from "util";
572
+ var execFileAsync2 = promisify2(execFile2);
573
+ async function execGit(cwd, args, quiet = false) {
574
+ try {
575
+ const { stdout, stderr } = await execFileAsync2("git", args, {
576
+ cwd,
577
+ encoding: "utf8",
578
+ maxBuffer: 10 * 1024 * 1024
579
+ });
580
+ if (!quiet && stderr.trim()) {
581
+ process.stderr.write(stderr);
582
+ }
583
+ return stdout;
584
+ } catch (err) {
585
+ const e = err;
586
+ const detail = (e.stderr ?? e.message ?? String(err)).trim();
587
+ throw new Error(
588
+ `[apm] git ${args.join(" ")} \u5931\u8D25${detail ? `: ${detail}` : ""}`
589
+ );
590
+ }
591
+ }
592
+ async function isGitRepo(cwd) {
593
+ try {
594
+ await execGit(cwd, ["rev-parse", "--git-dir"], true);
595
+ return true;
596
+ } catch {
597
+ return false;
598
+ }
599
+ }
600
+ async function hasUpstream(cwd) {
601
+ try {
602
+ await execGit(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"], true);
603
+ return true;
604
+ } catch {
605
+ return false;
606
+ }
607
+ }
608
+ var GITIGNORE_COMMIT_MESSAGE = "chore(apm): ignore .apm directory";
609
+ async function commitAndPushGitignore(workdir) {
610
+ if (!await isGitRepo(workdir)) {
611
+ console.log("[apm] \u5F53\u524D\u76EE\u5F55\u4E0D\u662F git \u4ED3\u5E93\uFF0C\u8BF7\u624B\u52A8\u63D0\u4EA4 .gitignore");
612
+ return;
613
+ }
614
+ await execGit(workdir, ["add", "--", ".gitignore"]);
615
+ await execGit(workdir, ["commit", "-m", GITIGNORE_COMMIT_MESSAGE]);
616
+ console.log(`[apm] \u5DF2\u63D0\u4EA4 .gitignore: ${GITIGNORE_COMMIT_MESSAGE}`);
617
+ const originUrl = await tryReadGitOriginUrl(workdir);
618
+ if (!originUrl) {
619
+ console.log("[apm] \u672A\u914D\u7F6E remote.origin\uFF0C\u8BF7\u7A0D\u540E\u624B\u52A8 push .gitignore");
620
+ return;
621
+ }
622
+ if (await hasUpstream(workdir)) {
623
+ await execGit(workdir, ["push"]);
624
+ } else {
625
+ await execGit(workdir, ["push", "-u", "origin", "HEAD"]);
626
+ }
627
+ console.log("[apm] \u5DF2\u63A8\u9001 .gitignore");
628
+ }
629
+
546
630
  // src/commands/init.ts
547
631
  async function runInit(name) {
548
632
  const workdir = resolveWorkdirPath();
549
633
  await ensureWorkspaceApmDirForInit(workdir);
634
+ if (ensureApmGitignoredInRepo(workdir)) {
635
+ console.log("[apm] \u5DF2\u5728 .gitignore \u4E2D\u6DFB\u52A0 **/.apm/**");
636
+ await commitAndPushGitignore(workdir);
637
+ }
550
638
  const apmDir = workspaceApmDir(workdir);
551
639
  await copyTemplateFiles(apmDir, workdir);
552
640
  const syncResult = await syncRemoteDeploymentConfig(workdir, apmDir);
@@ -556,7 +644,7 @@ async function runInit(name) {
556
644
  const config = readFileSync3(apmConfigPath, "utf8");
557
645
  const configJson = JSON.parse(config);
558
646
  configJson.name = trimmedName;
559
- writeFileSync3(
647
+ writeFileSync4(
560
648
  apmConfigPath,
561
649
  `${JSON.stringify(configJson, null, 2)}
562
650
  `,
@@ -635,9 +723,9 @@ async function runLogin(opts) {
635
723
  }
636
724
 
637
725
  // src/commands/branch.ts
638
- import { execFile as execFile2 } from "child_process";
639
- import { promisify as promisify2 } from "util";
640
- var execFileAsync2 = promisify2(execFile2);
726
+ import { execFile as execFile3 } from "child_process";
727
+ import { promisify as promisify3 } from "util";
728
+ var execFileAsync3 = promisify3(execFile3);
641
729
  var SESSION_BRANCH_PREFIX = "feat/session-";
642
730
  function branchNameForSession(sessionId) {
643
731
  const id = sessionId.trim();
@@ -659,9 +747,9 @@ function sessionIdFromBranchName(branch) {
659
747
  const sessionId = name.slice(SESSION_BRANCH_PREFIX.length).trim();
660
748
  return sessionId || null;
661
749
  }
662
- async function execGit(cwd, args, quiet) {
750
+ async function execGit2(cwd, args, quiet) {
663
751
  try {
664
- const { stdout, stderr } = await execFileAsync2("git", args, {
752
+ const { stdout, stderr } = await execFileAsync3("git", args, {
665
753
  cwd,
666
754
  encoding: "utf8",
667
755
  maxBuffer: 10 * 1024 * 1024
@@ -679,18 +767,18 @@ async function execGit(cwd, args, quiet) {
679
767
  }
680
768
  }
681
769
  async function ensureGitRepo(cwd) {
682
- await execGit(cwd, ["rev-parse", "--git-dir"], true);
770
+ await execGit2(cwd, ["rev-parse", "--git-dir"], true);
683
771
  }
684
772
  async function getCurrentBranch(cwd) {
685
- const name = (await execGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"], true)).trim();
773
+ const name = (await execGit2(cwd, ["rev-parse", "--abbrev-ref", "HEAD"], true)).trim();
686
774
  return name;
687
775
  }
688
776
  async function isWorkingTreeDirty(cwd) {
689
- const out = await execGit(cwd, ["status", "--porcelain"], true);
777
+ const out = await execGit2(cwd, ["status", "--porcelain"], true);
690
778
  return out.trim().length > 0;
691
779
  }
692
780
  async function remoteHeadBranchExists(cwd, branch) {
693
- const out = await execGit(
781
+ const out = await execGit2(
694
782
  cwd,
695
783
  ["ls-remote", "--heads", "origin", branch],
696
784
  true
@@ -699,7 +787,7 @@ async function remoteHeadBranchExists(cwd, branch) {
699
787
  }
700
788
  async function localBranchExists(cwd, branch) {
701
789
  try {
702
- await execGit(
790
+ await execGit2(
703
791
  cwd,
704
792
  ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
705
793
  true
@@ -715,8 +803,8 @@ async function commitWorkingTreeIfDirty(cwd, message) {
715
803
  return false;
716
804
  }
717
805
  const commitMessage = message?.trim() || `chore(apm): \u540C\u6B65\u5DE5\u4F5C\u533A (${await getCurrentBranch(cwd)})`;
718
- await execGit(cwd, ["add", "-A"]);
719
- await execGit(cwd, ["commit", "-m", commitMessage]);
806
+ await execGit2(cwd, ["add", "-A"]);
807
+ await execGit2(cwd, ["commit", "-m", commitMessage]);
720
808
  console.log(`[apm] \u5DF2\u63D0\u4EA4\u5DE5\u4F5C\u533A\u53D8\u66F4: ${commitMessage}`);
721
809
  return true;
722
810
  }
@@ -735,7 +823,7 @@ async function ensureFeatureBranch(branch, baselineBranch, options) {
735
823
  if (current === branch) {
736
824
  await commitWorkingTreeIfDirty(cwd, commitMessage);
737
825
  } else {
738
- await execGit(cwd, [
826
+ await execGit2(cwd, [
739
827
  "stash",
740
828
  "push",
741
829
  "-u",
@@ -746,32 +834,32 @@ async function ensureFeatureBranch(branch, baselineBranch, options) {
746
834
  }
747
835
  const onTargetBranch = await getCurrentBranch(cwd) === branch;
748
836
  if (onTargetBranch) {
749
- await execGit(cwd, ["fetch", "origin", baselineBranch]);
750
- await execGit(cwd, ["merge", `origin/${baselineBranch}`, "--no-edit"]);
837
+ await execGit2(cwd, ["fetch", "origin", baselineBranch]);
838
+ await execGit2(cwd, ["merge", `origin/${baselineBranch}`, "--no-edit"]);
751
839
  } else {
752
840
  const remoteExists = await remoteHeadBranchExists(cwd, branch);
753
841
  if (remoteExists) {
754
- await execGit(cwd, ["fetch", "origin", branch]);
755
- await execGit(cwd, ["checkout", "-B", branch, `origin/${branch}`]);
842
+ await execGit2(cwd, ["fetch", "origin", branch]);
843
+ await execGit2(cwd, ["checkout", "-B", branch, `origin/${branch}`]);
756
844
  } else if (await localBranchExists(cwd, branch)) {
757
845
  if (await getCurrentBranch(cwd) !== branch) {
758
- await execGit(cwd, ["checkout", branch]);
846
+ await execGit2(cwd, ["checkout", branch]);
759
847
  }
760
848
  console.log(`[apm] \u5206\u652F ${branch} \u5DF2\u5B58\u5728\uFF0C\u8DF3\u8FC7\u521B\u5EFA`);
761
849
  } else {
762
- await execGit(cwd, ["fetch", "origin", baselineBranch]);
850
+ await execGit2(cwd, ["fetch", "origin", baselineBranch]);
763
851
  try {
764
- await execGit(cwd, [
852
+ await execGit2(cwd, [
765
853
  "checkout",
766
854
  "-b",
767
855
  branch,
768
856
  `origin/${baselineBranch}`
769
857
  ]);
770
- await execGit(cwd, ["push", "-u", "origin", branch]);
858
+ await execGit2(cwd, ["push", "-u", "origin", branch]);
771
859
  } catch (err) {
772
860
  if (await localBranchExists(cwd, branch)) {
773
861
  if (await getCurrentBranch(cwd) !== branch) {
774
- await execGit(cwd, ["checkout", branch]);
862
+ await execGit2(cwd, ["checkout", branch]);
775
863
  }
776
864
  console.log(`[apm] \u5206\u652F ${branch} \u5DF2\u5B58\u5728\uFF0C\u8DF3\u8FC7\u521B\u5EFA`);
777
865
  } else {
@@ -811,12 +899,12 @@ async function runBranch(sessionId, options = {}) {
811
899
  }
812
900
 
813
901
  // src/commands/clean-branches.ts
814
- import { execFile as execFile3 } from "child_process";
815
- import { promisify as promisify3 } from "util";
816
- var execFileAsync3 = promisify3(execFile3);
817
- async function execGit2(cwd, args, quiet) {
902
+ import { execFile as execFile4 } from "child_process";
903
+ import { promisify as promisify4 } from "util";
904
+ var execFileAsync4 = promisify4(execFile4);
905
+ async function execGit3(cwd, args, quiet) {
818
906
  try {
819
- const { stdout, stderr } = await execFileAsync3("git", args, {
907
+ const { stdout, stderr } = await execFileAsync4("git", args, {
820
908
  cwd,
821
909
  encoding: "utf8",
822
910
  maxBuffer: 10 * 1024 * 1024
@@ -834,21 +922,21 @@ async function execGit2(cwd, args, quiet) {
834
922
  }
835
923
  }
836
924
  async function ensureGitRepo2(cwd) {
837
- await execGit2(cwd, ["rev-parse", "--git-dir"], true);
925
+ await execGit3(cwd, ["rev-parse", "--git-dir"], true);
838
926
  }
839
927
  async function getCurrentBranch2(cwd) {
840
- return (await execGit2(cwd, ["rev-parse", "--abbrev-ref", "HEAD"], true)).trim();
928
+ return (await execGit3(cwd, ["rev-parse", "--abbrev-ref", "HEAD"], true)).trim();
841
929
  }
842
930
  async function resolveDefaultBranch(cwd) {
843
931
  try {
844
- const ref = (await execGit2(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], true)).trim();
932
+ const ref = (await execGit3(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], true)).trim();
845
933
  const match = ref.match(/^refs\/remotes\/origin\/(.+)$/);
846
934
  if (match?.[1]) {
847
935
  return match[1];
848
936
  }
849
937
  } catch {
850
938
  }
851
- const out = await execGit2(cwd, ["remote", "show", "origin"], true);
939
+ const out = await execGit3(cwd, ["remote", "show", "origin"], true);
852
940
  const headLine = out.split(/\r?\n/).find((line) => line.includes("HEAD branch"));
853
941
  const branch = headLine?.split(":").pop()?.trim();
854
942
  if (branch) {
@@ -857,7 +945,7 @@ async function resolveDefaultBranch(cwd) {
857
945
  throw new Error("[apm] \u65E0\u6CD5\u89E3\u6790 origin \u9ED8\u8BA4\u5206\u652F\uFF0C\u8BF7\u5148\u6267\u884C git fetch origin");
858
946
  }
859
947
  async function listLocalSessionBranches(cwd) {
860
- const out = await execGit2(
948
+ const out = await execGit3(
861
949
  cwd,
862
950
  [
863
951
  "for-each-ref",
@@ -870,7 +958,7 @@ async function listLocalSessionBranches(cwd) {
870
958
  return out.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
871
959
  }
872
960
  async function listRemoteSessionBranches(cwd) {
873
- const out = await execGit2(
961
+ const out = await execGit3(
874
962
  cwd,
875
963
  [
876
964
  "for-each-ref",
@@ -884,7 +972,7 @@ async function listRemoteSessionBranches(cwd) {
884
972
  }
885
973
  async function localBranchExists2(cwd, branch) {
886
974
  try {
887
- await execGit2(
975
+ await execGit3(
888
976
  cwd,
889
977
  ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
890
978
  true
@@ -895,7 +983,7 @@ async function localBranchExists2(cwd, branch) {
895
983
  }
896
984
  }
897
985
  async function remoteBranchExists(cwd, branch) {
898
- const out = await execGit2(
986
+ const out = await execGit3(
899
987
  cwd,
900
988
  ["ls-remote", "--heads", "origin", branch],
901
989
  true
@@ -915,7 +1003,7 @@ async function runCleanBranches(options = {}) {
915
1003
  const cwd = options.cwd ?? process.cwd();
916
1004
  const dryRun = options.dryRun ?? false;
917
1005
  await ensureGitRepo2(cwd);
918
- await execGit2(cwd, ["fetch", "--prune", "origin"], true);
1006
+ await execGit3(cwd, ["fetch", "--prune", "origin"], true);
919
1007
  const cfg = await ensureLoggedConfig();
920
1008
  const api = createApmApiClient(cfg);
921
1009
  const { sessions } = await api.cli.listSessionsForBranchCleanup({});
@@ -956,15 +1044,15 @@ async function runCleanBranches(options = {}) {
956
1044
  }
957
1045
  if (currentBranch === branch) {
958
1046
  defaultBranch ??= await resolveDefaultBranch(cwd);
959
- await execGit2(cwd, ["checkout", defaultBranch], true);
1047
+ await execGit3(cwd, ["checkout", defaultBranch], true);
960
1048
  currentBranch = defaultBranch;
961
1049
  }
962
1050
  if (await localBranchExists2(cwd, branch)) {
963
- await execGit2(cwd, ["branch", "-D", branch], true);
1051
+ await execGit3(cwd, ["branch", "-D", branch], true);
964
1052
  console.log(`[apm] \u5DF2\u5220\u9664\u672C\u5730\u5206\u652F ${branch}`);
965
1053
  }
966
1054
  if (await remoteBranchExists(cwd, branch)) {
967
- await execGit2(cwd, ["push", "origin", "--delete", branch], true);
1055
+ await execGit3(cwd, ["push", "origin", "--delete", branch], true);
968
1056
  console.log(`[apm] \u5DF2\u5220\u9664\u8FDC\u7A0B\u5206\u652F origin/${branch}`);
969
1057
  }
970
1058
  }
@@ -976,7 +1064,7 @@ async function runCleanBranches(options = {}) {
976
1064
  }
977
1065
 
978
1066
  // src/commands/pull.ts
979
- import { writeFileSync as writeFileSync7 } from "fs";
1067
+ import { writeFileSync as writeFileSync8 } from "fs";
980
1068
  import { join as join8 } from "path";
981
1069
  import { stringify as yamlStringify } from "yaml";
982
1070
 
@@ -1010,7 +1098,7 @@ function formatSessionMessagesXml(sessionId, messages) {
1010
1098
  }
1011
1099
 
1012
1100
  // src/commands/sync-session-attachments.ts
1013
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1101
+ import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
1014
1102
  import { join as join5 } from "path";
1015
1103
  var MANIFEST_FILE = ".sync-manifest.json";
1016
1104
  async function downloadAttachment(cfg, attachmentId) {
@@ -1041,7 +1129,7 @@ function loadManifest(dir) {
1041
1129
  return { version: 1, attachments: {} };
1042
1130
  }
1043
1131
  function saveManifest(dir, manifest) {
1044
- writeFileSync4(
1132
+ writeFileSync5(
1045
1133
  join5(dir, MANIFEST_FILE),
1046
1134
  `${JSON.stringify(manifest, null, 2)}
1047
1135
  `,
@@ -1075,7 +1163,7 @@ async function syncSessionAttachments(cfg, sessionId, attachments, apmRoot) {
1075
1163
  continue;
1076
1164
  }
1077
1165
  const buffer = await downloadAttachment(cfg, item.id);
1078
- writeFileSync4(dest, buffer);
1166
+ writeFileSync5(dest, buffer);
1079
1167
  nextManifest.attachments[item.id] = {
1080
1168
  name: item.name,
1081
1169
  createdAt
@@ -1087,7 +1175,7 @@ async function syncSessionAttachments(cfg, sessionId, attachments, apmRoot) {
1087
1175
 
1088
1176
  // src/rules-sync.ts
1089
1177
  import { basename as basename2, extname as extname2, join as join7 } from "path";
1090
- import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
1178
+ import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
1091
1179
 
1092
1180
  // src/skills-sync.ts
1093
1181
  import {
@@ -1098,7 +1186,7 @@ import {
1098
1186
  readdirSync as readdirSync2,
1099
1187
  rmSync,
1100
1188
  statSync as statSync2,
1101
- writeFileSync as writeFileSync5
1189
+ writeFileSync as writeFileSync6
1102
1190
  } from "fs";
1103
1191
  import { join as join6 } from "path";
1104
1192
  var AGENTS_TEMPLATE_PATH = join6(CLI_TEMPLATE_DIR, "AGENTS.md");
@@ -1163,7 +1251,7 @@ function syncSupplementarySkills(skillsDir, list) {
1163
1251
  }
1164
1252
  const skillDir = join6(skillsDir, dirName);
1165
1253
  mkdirSync3(skillDir, { recursive: true });
1166
- writeFileSync5(join6(skillDir, "SKILL.md"), skill.content ?? "", "utf8");
1254
+ writeFileSync6(join6(skillDir, "SKILL.md"), skill.content ?? "", "utf8");
1167
1255
  written.push(dirName);
1168
1256
  }
1169
1257
  const removed = [];
@@ -1205,7 +1293,7 @@ function loadManifest2(rulesDir) {
1205
1293
  return { version: 1, rules: {} };
1206
1294
  }
1207
1295
  function saveManifest2(rulesDir, manifest) {
1208
- writeFileSync6(
1296
+ writeFileSync7(
1209
1297
  toFsPath(join7(rulesDir, MANIFEST_FILE2)),
1210
1298
  `${JSON.stringify(manifest, null, 2)}
1211
1299
  `,
@@ -1253,7 +1341,7 @@ async function syncPlatformRules(cfg, sessionId, workdirPath, apmRoot) {
1253
1341
  console.log(`[apm] \u89C4\u5219\u65E0\u53D8\u5316\uFF0C\u5DF2\u8DF3\u8FC7: rules/${fileName}`);
1254
1342
  continue;
1255
1343
  }
1256
- writeFileSync6(toFsPath(dest), rule.content ?? "", "utf8");
1344
+ writeFileSync7(toFsPath(dest), rule.content ?? "", "utf8");
1257
1345
  nextManifest.rules[rule.id] = { fileName, updatedAt };
1258
1346
  written.push(fileName);
1259
1347
  console.log(`[apm] \u5DF2\u540C\u6B65\u5E73\u53F0\u89C4\u5219: rules/${fileName}`);
@@ -1296,20 +1384,20 @@ async function runPull(sessionId, remoteWorkdir) {
1296
1384
  const dir = sessionDir(trimmedId, apmRoot);
1297
1385
  const docsDir = sessionDocsDir(trimmedId, apmRoot);
1298
1386
  await ensureDirExists(docsDir);
1299
- writeFileSync7(
1387
+ writeFileSync8(
1300
1388
  sessionRulePath(trimmedId, apmRoot),
1301
1389
  detail.description ?? "",
1302
1390
  "utf8"
1303
1391
  );
1304
- writeFileSync7(
1392
+ writeFileSync8(
1305
1393
  sessionTaskPath(trimmedId, apmRoot),
1306
1394
  detail.task.description ?? "",
1307
1395
  "utf8"
1308
1396
  );
1309
- writeFileSync7(sessionTodoPath(trimmedId, apmRoot), detail.todo ?? "", "utf8");
1397
+ writeFileSync8(sessionTodoPath(trimmedId, apmRoot), detail.todo ?? "", "utf8");
1310
1398
  for (const doc of documents) {
1311
1399
  const fileName = documentLocalFileName(doc.name);
1312
- writeFileSync7(join8(docsDir, fileName), doc.content ?? "", "utf8");
1400
+ writeFileSync8(join8(docsDir, fileName), doc.content ?? "", "utf8");
1313
1401
  }
1314
1402
  const sessionYaml = yamlStringify(
1315
1403
  {
@@ -1326,13 +1414,13 @@ async function runPull(sessionId, remoteWorkdir) {
1326
1414
  },
1327
1415
  { lineWidth: 0 }
1328
1416
  );
1329
- writeFileSync7(
1417
+ writeFileSync8(
1330
1418
  sessionYamlPath(trimmedId, apmRoot),
1331
1419
  sessionYaml.endsWith("\n") ? sessionYaml : `${sessionYaml}
1332
1420
  `,
1333
1421
  "utf8"
1334
1422
  );
1335
- writeFileSync7(
1423
+ writeFileSync8(
1336
1424
  sessionMessagesXmlPath(trimmedId, apmRoot),
1337
1425
  formatSessionMessagesXml(trimmedId, messages),
1338
1426
  "utf8"
@@ -1976,7 +2064,7 @@ ${JSON.stringify(event, null, 2)}
1976
2064
  }
1977
2065
 
1978
2066
  // src/commands/connect/agent-session-registry.ts
1979
- import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "node:fs";
2067
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync9 } from "node:fs";
1980
2068
  import { dirname as dirname3, resolve as resolve3 } from "node:path";
1981
2069
  function registryPath(workdir, sessionId) {
1982
2070
  return resolve3(workdir, ".apm", "sessions", sessionId, "cursor-agents.json");
@@ -2004,7 +2092,7 @@ function readRegistry(path10) {
2004
2092
  }
2005
2093
  function writeRegistry(path10, registry) {
2006
2094
  mkdirSync5(dirname3(path10), { recursive: true });
2007
- writeFileSync8(path10, `${JSON.stringify(registry, null, 2)}
2095
+ writeFileSync9(path10, `${JSON.stringify(registry, null, 2)}
2008
2096
  `, "utf8");
2009
2097
  }
2010
2098
  function loadSessionAgentId(workdir, sessionId, user) {
@@ -2333,7 +2421,7 @@ function markPullDone(sessionId, workdir) {
2333
2421
  }
2334
2422
 
2335
2423
  // src/commands/connect/run-slot-pool.ts
2336
- var DEFAULT_MAX_CONCURRENT = 2;
2424
+ var DEFAULT_MAX_CONCURRENT = 5;
2337
2425
  function createRunSlotPool(maxConcurrent = DEFAULT_MAX_CONCURRENT) {
2338
2426
  let active = 0;
2339
2427
  const waiters = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-project-manage-cli",
3
- "version": "6.0.53",
3
+ "version": "6.0.54",
4
4
  "description": "命令行工具:后续用于调用平台后端 API 完成运维与自动化操作",
5
5
  "type": "module",
6
6
  "private": false,
@@ -4,12 +4,12 @@
4
4
  部署线上环境: `npm run deploy:online`
5
5
 
6
6
  ## 后端部署(不区分正式环境和测试环境)
7
- `python scripts/deploy.py`
7
+ `python .apm/deploy/deploy.py`
8
8
 
9
9
  ## 前端产物下载地址
10
10
 
11
11
  http://<服务器地址>/dist.zip
12
- http://<服务器地址>/backend_update_jar.zip
12
+ http://<服务器地址>/{name}.jar.zip
13
13
 
14
14
  ## 测试地址
15
- http://<测试环境>
15
+ http://<服务器地址>
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env python3
2
+ """自动部署脚本"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import posixpath
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import zipfile
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import paramiko
21
+
22
+ SCRIPT_DIR = Path(__file__).resolve().parent
23
+ PROJECT_ROOT = SCRIPT_DIR.parent.parent
24
+ APM_CONFIG_PATH = PROJECT_ROOT / ".apm" / "apm.config.json"
25
+ DEPLOY_CACHE_DIR = SCRIPT_DIR / ".deploy_cache"
26
+ MANIFEST_FILE = DEPLOY_CACHE_DIR / "manifest.json"
27
+ SPRINGBOOT_SCRIPT = "springboot.sh"
28
+ MAVEN_MODULE = "jeecg-module-system/jeecg-system-start"
29
+ MAVEN_PROFILE = "dev"
30
+
31
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
32
+ sys.stdout.reconfigure(encoding="utf-8")
33
+ sys.stderr.reconfigure(encoding="utf-8")
34
+
35
+
36
+ def log(message: str) -> None:
37
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
38
+
39
+
40
+ def fail(message: str, code: int = 1) -> None:
41
+ log(f"ERROR: {message}")
42
+ sys.exit(code)
43
+
44
+
45
+ def expand_path(path_str: str) -> Path:
46
+ return Path(path_str).expanduser().resolve()
47
+
48
+
49
+ def load_apm_config(apm_path: Path) -> dict[str, Any]:
50
+ with apm_path.open("r", encoding="utf-8") as handle:
51
+ data = json.load(handle)
52
+
53
+ name = str(data["name"]).strip()
54
+ deploy = data["wisdomDeploy"]
55
+ health = data["healthCheck"]
56
+
57
+ jar_path = str(deploy["jarPath"]).strip()
58
+ remote_app_dir = posixpath.dirname(jar_path)
59
+
60
+ return {
61
+ "project_name": name,
62
+ "host": deploy["host"],
63
+ "port": deploy["port"],
64
+ "username": deploy["username"],
65
+ "password": deploy["password"],
66
+ "remote_vue_dist_dir": str(deploy["remotePath"]).strip(),
67
+ "remote_app_dir": remote_app_dir,
68
+ "remote_lib_dir": posixpath.join(remote_app_dir, "lib"),
69
+ "startup_jar": posixpath.basename(jar_path),
70
+ "package_name": f"{name}.jar.zip",
71
+ "maven_local_repo": str(deploy["mavenLocalRepo"]).strip(),
72
+ "health_check_port": health["port"],
73
+ "health_check_context": health["context"],
74
+ "health_check_timeout": health["timeout"],
75
+ }
76
+
77
+
78
+ def load_config() -> dict[str, Any]:
79
+ return load_apm_config(APM_CONFIG_PATH)
80
+
81
+
82
+ def get_target_dir() -> Path:
83
+ return PROJECT_ROOT / MAVEN_MODULE / "target"
84
+
85
+
86
+ def get_maven_local_repo(config: dict[str, Any]) -> Path:
87
+ return expand_path(str(config["maven_local_repo"]).strip())
88
+
89
+
90
+ @dataclass
91
+ class UpdateEntry:
92
+ path: Path
93
+ arcname: str
94
+ reason: str
95
+
96
+
97
+ def relative_key(path: Path) -> str:
98
+ return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
99
+
100
+
101
+ def file_signature(path: Path) -> dict[str, float | int]:
102
+ stat = path.stat()
103
+ return {"size": int(stat.st_size), "mtime": float(stat.st_mtime)}
104
+
105
+
106
+ def load_manifest() -> dict[str, dict[str, float | int]]:
107
+ if not MANIFEST_FILE.exists():
108
+ return {}
109
+ with MANIFEST_FILE.open("r", encoding="utf-8") as handle:
110
+ return json.load(handle)
111
+
112
+
113
+ def save_manifest(manifest: dict[str, dict[str, float | int]]) -> None:
114
+ DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
115
+ with MANIFEST_FILE.open("w", encoding="utf-8") as handle:
116
+ json.dump(manifest, handle, ensure_ascii=False, indent=2)
117
+
118
+
119
+ def is_project_lib_jar(jar_name: str) -> bool:
120
+ return jar_name.startswith("jeecg-")
121
+
122
+
123
+ def list_lib_files_to_upload(
124
+ local_lib_dir: Path,
125
+ remote_stats: dict[str, paramiko.SFTPAttributes],
126
+ *,
127
+ manifest: dict[str, dict[str, float | int]] | None = None,
128
+ ) -> list[UpdateEntry]:
129
+ entries: list[UpdateEntry] = []
130
+ for jar_file in sorted(local_lib_dir.glob("*.jar")):
131
+ remote_attr = remote_stats.get(jar_file.name)
132
+ should_upload, reason = should_upload_lib_file(
133
+ jar_file,
134
+ remote_attr,
135
+ manifest,
136
+ )
137
+ if should_upload:
138
+ entries.append(
139
+ UpdateEntry(
140
+ path=jar_file,
141
+ arcname=jar_file.name,
142
+ reason=reason,
143
+ )
144
+ )
145
+ return entries
146
+
147
+
148
+ def create_update_package(entries: list[UpdateEntry], package_name: str) -> Path:
149
+ DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
150
+ zip_path = DEPLOY_CACHE_DIR / package_name
151
+
152
+ log(f"创建更新包: {zip_path.name}({len(entries)} 个文件)")
153
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
154
+ for entry in entries:
155
+ archive.write(entry.path, arcname=entry.arcname)
156
+ log(f" 打包: {entry.arcname} ({entry.reason})")
157
+
158
+ return zip_path
159
+
160
+
161
+ def upload_update_package(
162
+ sftp: paramiko.SFTPClient,
163
+ zip_path: Path,
164
+ config: dict[str, Any],
165
+ ) -> str:
166
+ remote_dir = config["remote_vue_dist_dir"]
167
+ remote_path = f"{remote_dir}/{zip_path.name}"
168
+
169
+ log(f"上传更新包 -> {remote_path}")
170
+
171
+ try:
172
+ sftp.put(str(zip_path), remote_path)
173
+ log("更新包上传成功")
174
+ except OSError as exc:
175
+ fail(f"更新包上传失败: {exc}")
176
+
177
+ return remote_path
178
+
179
+
180
+ def update_manifest_entries(
181
+ manifest: dict[str, dict[str, float | int]],
182
+ entries: list[UpdateEntry],
183
+ ) -> dict[str, dict[str, float | int]]:
184
+ for entry in entries:
185
+ manifest[relative_key(entry.path)] = file_signature(entry.path)
186
+ return manifest
187
+
188
+
189
+ def get_remote_file_stats(
190
+ sftp: paramiko.SFTPClient,
191
+ remote_dir: str,
192
+ ) -> dict[str, paramiko.SFTPAttributes]:
193
+ stats: dict[str, paramiko.SFTPAttributes] = {}
194
+ try:
195
+ for attr in sftp.listdir_attr(remote_dir):
196
+ if attr.filename.endswith(".jar"):
197
+ stats[attr.filename] = attr
198
+ except FileNotFoundError:
199
+ pass
200
+ return stats
201
+
202
+
203
+ def should_upload_lib_file(
204
+ local_path: Path,
205
+ remote_attr: paramiko.SFTPAttributes | None,
206
+ manifest: dict[str, dict[str, float | int]] | None = None,
207
+ ) -> tuple[bool, str]:
208
+ if remote_attr is None:
209
+ return False, "远程不存在,跳过"
210
+
211
+ local_size = int(local_path.stat().st_size)
212
+ remote_size = int(remote_attr.st_size)
213
+ if local_size != remote_size:
214
+ return True, f"大小变化 {remote_size} -> {local_size}"
215
+
216
+ if is_project_lib_jar(local_path.name) and manifest is not None:
217
+ key = relative_key(local_path)
218
+ current = file_signature(local_path)
219
+ previous = manifest.get(key)
220
+ if previous is None:
221
+ return True, "项目模块未记录"
222
+ if int(previous["size"]) != current["size"]:
223
+ return True, "项目模块大小变化"
224
+ if float(previous["mtime"]) < current["mtime"]:
225
+ return True, "项目模块重新构建"
226
+
227
+ return False, "大小一致,跳过"
228
+
229
+
230
+ def get_mvn_executable() -> str:
231
+ candidates = ("mvn.cmd", "mvn.bat", "mvn") if sys.platform == "win32" else ("mvn",)
232
+ for name in candidates:
233
+ found = shutil.which(name)
234
+ if found:
235
+ return found
236
+ fail("未找到 mvn 命令,请确认 Maven 已安装并加入 PATH")
237
+
238
+
239
+ def run_maven_build(config: dict[str, Any]) -> None:
240
+ profile = MAVEN_PROFILE
241
+ maven_repo = get_maven_local_repo(config)
242
+ cmd = [
243
+ get_mvn_executable(),
244
+ "clean",
245
+ "package",
246
+ f"-P{profile}",
247
+ f"-Dmaven.repo.local={maven_repo}",
248
+ "-DskipTests",
249
+ ]
250
+
251
+ log(f"开始 Maven 构建: {' '.join(cmd)}")
252
+ log(f"Maven 本地仓库: {maven_repo}")
253
+ result = subprocess.run(
254
+ cmd,
255
+ cwd=PROJECT_ROOT,
256
+ )
257
+ if result.returncode != 0:
258
+ fail(f"Maven 构建失败,退出码: {result.returncode}")
259
+
260
+
261
+ def locate_lib_dir() -> Path:
262
+ target_dir = get_target_dir()
263
+ if not target_dir.exists():
264
+ fail(f"构建产物目录不存在: {target_dir}")
265
+
266
+ lib_dir = target_dir / "lib"
267
+ if not lib_dir.is_dir():
268
+ fail(f"lib 目录不存在: {lib_dir}")
269
+
270
+ lib_jars = list(lib_dir.glob("*.jar"))
271
+ if not lib_jars:
272
+ fail(f"lib 目录下没有依赖 JAR: {lib_dir}")
273
+
274
+ log(f"定位 lib 产物: {len(lib_jars)} 个")
275
+ return lib_dir
276
+
277
+
278
+ def connect_ssh(config: dict[str, Any]) -> paramiko.SSHClient:
279
+ client = paramiko.SSHClient()
280
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
281
+
282
+ log(f"连接服务器 {config['username']}@{config['host']}:{config['port']}")
283
+ connect_kwargs: dict[str, Any] = {
284
+ "hostname": config["host"],
285
+ "port": int(config["port"]),
286
+ "username": config["username"],
287
+ "password": config["password"],
288
+ "timeout": 30,
289
+ "allow_agent": False,
290
+ "look_for_keys": False,
291
+ }
292
+
293
+ try:
294
+ client.connect(**connect_kwargs)
295
+ except Exception as exc:
296
+ fail(f"SSH 连接失败: {exc}")
297
+
298
+ return client
299
+
300
+
301
+ def read_channel_stream(channel: paramiko.Channel) -> str:
302
+ """流式读取 SSH 通道输出,避免长时间命令无回显。"""
303
+ chunks: list[str] = []
304
+ while not channel.closed:
305
+ if channel.recv_ready():
306
+ data = channel.recv(4096)
307
+ if not data:
308
+ break
309
+ text = data.decode("utf-8", errors="replace")
310
+ chunks.append(text)
311
+ print(text, end="", flush=True)
312
+ elif channel.exit_status_ready():
313
+ while channel.recv_ready():
314
+ data = channel.recv(4096)
315
+ if data:
316
+ text = data.decode("utf-8", errors="replace")
317
+ chunks.append(text)
318
+ print(text, end="", flush=True)
319
+ break
320
+ else:
321
+ time.sleep(0.2)
322
+
323
+ combined = "".join(chunks)
324
+ if combined and not combined.endswith("\n"):
325
+ print()
326
+ return combined.strip()
327
+
328
+
329
+ def run_remote_command(
330
+ client: paramiko.SSHClient,
331
+ command: str,
332
+ *,
333
+ check: bool = True,
334
+ get_pty: bool = False,
335
+ stream: bool = False,
336
+ timeout_secs: float | None = None,
337
+ ) -> tuple[int, str, str]:
338
+ log(f"远程执行: {command}")
339
+ _, stdout, stderr = client.exec_command(command, get_pty=get_pty)
340
+ channel = stdout.channel
341
+ if timeout_secs is not None:
342
+ channel.settimeout(timeout_secs)
343
+
344
+ if stream:
345
+ out = read_channel_stream(channel)
346
+ err = ""
347
+ else:
348
+ out = stdout.read().decode("utf-8", errors="replace").strip()
349
+ err = "" if get_pty else stderr.read().decode("utf-8", errors="replace").strip()
350
+
351
+ exit_code = channel.recv_exit_status()
352
+
353
+ if not stream:
354
+ if out:
355
+ print(out)
356
+ if err:
357
+ print(err, file=sys.stderr)
358
+
359
+ if check and exit_code != 0:
360
+ fail(f"远程命令失败 (exit {exit_code}): {command}")
361
+
362
+ return exit_code, out, err
363
+
364
+
365
+ def extract_update_package_on_remote(
366
+ client: paramiko.SSHClient,
367
+ config: dict[str, Any],
368
+ remote_zip_path: str,
369
+ ) -> int:
370
+ """解压更新包到 lib 目录,仅覆盖远程已存在的 JAR。"""
371
+ remote_lib_dir = config["remote_lib_dir"]
372
+ quoted_zip = shlex.quote(remote_zip_path)
373
+ quoted_lib = shlex.quote(remote_lib_dir)
374
+
375
+ script = f"""
376
+ set -e
377
+ TMP=$(mktemp -d)
378
+ trap 'rm -rf "$TMP"' EXIT
379
+ unzip -oq {quoted_zip} -d "$TMP"
380
+ updated=0
381
+ while IFS= read -r -d '' src; do
382
+ name=$(basename "$src")
383
+ dest={quoted_lib}/"$name"
384
+ if [ -f "$dest" ]; then
385
+ cp -f "$src" "$dest"
386
+ echo "覆盖: $name"
387
+ updated=$((updated + 1))
388
+ else
389
+ echo "跳过(远程不存在): $name"
390
+ fi
391
+ done < <(find "$TMP" -name '*.jar' -type f -print0)
392
+ echo "UPDATED_COUNT=$updated"
393
+ """
394
+
395
+ _, out, _ = run_remote_command(client, script)
396
+ match = re.search(r"UPDATED_COUNT=(\d+)", out)
397
+ if not match:
398
+ fail(f"远程解压失败,未获取更新数量\n输出: {out or '(空)'}")
399
+
400
+ updated = int(match.group(1))
401
+ log(f"lib 解压完成: 覆盖 {updated} 个")
402
+ return updated
403
+
404
+
405
+ def springboot_output_indicates_success(action: str, combined: str) -> bool:
406
+ """springboot.sh 经 SSH 执行时 exit code 不可靠,需结合输出判断。"""
407
+ lower = combined.lower()
408
+ if action == "health":
409
+ return "健康检查通过" in combined
410
+ if action in {"start", "restart"}:
411
+ return "is starting" in combined or "is running" in lower
412
+ if action == "stop":
413
+ return (
414
+ "is stopping" in combined
415
+ or "not running" in lower
416
+ or "please check it" in lower
417
+ )
418
+ if action == "status":
419
+ return "running" in lower or "not running" in lower
420
+ return True
421
+
422
+
423
+ def run_springboot_action(
424
+ client: paramiko.SSHClient,
425
+ config: dict[str, Any],
426
+ action: str,
427
+ *args: str,
428
+ ) -> str:
429
+ if action in {"start", "restart"}:
430
+ if not args:
431
+ fail(f"远程 {action} 缺少 jar 参数")
432
+ jar = args[0].strip().splitlines()[0].strip()
433
+ if not jar:
434
+ fail(f"无效的 jar 名称: {args[0]!r}")
435
+
436
+ command = " && ".join(
437
+ [
438
+ f"cd {shlex.quote(config['remote_app_dir'])}",
439
+ " ".join(
440
+ [f"./{SPRINGBOOT_SCRIPT}", *map(shlex.quote, (action, *args))]
441
+ ),
442
+ ]
443
+ )
444
+ exit_code, out, err = run_remote_command(
445
+ client,
446
+ command,
447
+ check=False,
448
+ )
449
+ combined = f"{out}\n{err}".strip()
450
+ output_ok = springboot_output_indicates_success(action, combined)
451
+ if action == "health":
452
+ if exit_code != 0 or not output_ok:
453
+ fail(
454
+ f"健康检查失败\n"
455
+ f"命令: {command}\n输出: {combined or '(空)'}"
456
+ )
457
+ return combined
458
+ if exit_code != 0 and not output_ok:
459
+ fail(f"远程 {action} 失败: {' '.join(args)}\n{combined}")
460
+ if action in {"start", "restart"} and not output_ok:
461
+ fail(
462
+ f"远程 {action} 未成功: {' '.join(args)}\n"
463
+ f"命令: {command}\n输出: {combined or '(空)'}"
464
+ )
465
+ return combined
466
+
467
+
468
+ def get_running_jar(
469
+ client: paramiko.SSHClient,
470
+ app_dir: str,
471
+ config: dict[str, Any],
472
+ ) -> str | None:
473
+ combined = run_springboot_action(
474
+ client, config, "status", config["startup_jar"]
475
+ )
476
+ text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", combined).strip().lower()
477
+ if "not running" in text:
478
+ return None
479
+ if "running" in text:
480
+ return config["startup_jar"]
481
+ return None
482
+
483
+
484
+ def health_check_service(
485
+ client: paramiko.SSHClient,
486
+ config: dict[str, Any],
487
+ ) -> None:
488
+ port = str(int(config["health_check_port"]))
489
+ context = str(config["health_check_context"]).strip()
490
+ timeout = str(int(config["health_check_timeout"]))
491
+ log(f"健康检查: springboot.sh health {port} {context} {timeout}")
492
+ run_springboot_action(client, config, "health", port, context, timeout)
493
+
494
+
495
+ def main() -> None:
496
+ config = load_config()
497
+
498
+ log(f"=== 自动部署: {config['project_name']} ===")
499
+ log(f"配置文件: {APM_CONFIG_PATH}")
500
+ log(f"项目根目录: {PROJECT_ROOT}")
501
+
502
+ run_maven_build(config)
503
+
504
+ lib_dir = locate_lib_dir()
505
+ manifest = load_manifest()
506
+
507
+ client = connect_ssh(config)
508
+ need_restart = False
509
+ lib_upload_entries: list[UpdateEntry] = []
510
+ try:
511
+ sftp = client.open_sftp()
512
+ try:
513
+ app_dir = config["remote_app_dir"]
514
+ remote_lib_stats = get_remote_file_stats(sftp, config["remote_lib_dir"])
515
+
516
+ log("收集 JAR 更新...")
517
+ lib_upload_entries = list_lib_files_to_upload(
518
+ lib_dir,
519
+ remote_lib_stats,
520
+ manifest=manifest,
521
+ )
522
+
523
+ updated = 0
524
+ if lib_upload_entries:
525
+ zip_path = create_update_package(
526
+ lib_upload_entries,
527
+ config["package_name"],
528
+ )
529
+ remote_zip_path = upload_update_package(sftp, zip_path, config)
530
+ log("远程解压 lib 目录(仅覆盖已有 JAR)...")
531
+ updated = extract_update_package_on_remote(
532
+ client,
533
+ config,
534
+ remote_zip_path,
535
+ )
536
+ else:
537
+ log("无 JAR 需要更新,跳过更新包上传")
538
+
539
+ running_jar = get_running_jar(client, app_dir, config)
540
+ need_restart = updated > 0
541
+ if not need_restart and not running_jar:
542
+ log("服务未运行,需要启动")
543
+ need_restart = True
544
+ elif not need_restart:
545
+ log("没有文件需要更新,跳过重启")
546
+
547
+ if need_restart:
548
+ log("重启服务...")
549
+ run_springboot_action(client, config, "restart", config["startup_jar"])
550
+
551
+ health_check_service(client, config)
552
+
553
+ if lib_upload_entries:
554
+ save_manifest(update_manifest_entries(manifest, lib_upload_entries))
555
+ finally:
556
+ sftp.close()
557
+ finally:
558
+ client.close()
559
+
560
+ log("部署完成")
561
+
562
+
563
+ if __name__ == "__main__":
564
+ main()