ai-project-manage-cli 6.0.52 → 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 +151 -63
- package/package.json +1 -1
- package/template/deploy/README.md +3 -3
- package/template/deploy/deploy.py +564 -0
- package/template/rules/write_doc.md +8 -1
- package/template/skills/apm-dev/SKILL.md +30 -4
- package/template/skills/apm-diff-review/SKILL.md +8 -7
- package/template/skills/apm-write-plan/SKILL.md +2 -1
- package/template/skills/apm-write-plan/plan-template.md +13 -11
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
|
|
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
|
|
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
|
-
|
|
555
|
+
writeFileSync3(apmConfigPath, `${JSON.stringify(parsed, null, 2)}
|
|
533
556
|
`, "utf8");
|
|
534
557
|
const deployDir = join3(targetApmDir, "deploy");
|
|
535
558
|
await ensureDirExists(deployDir);
|
|
536
|
-
|
|
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
|
-
|
|
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
|
|
639
|
-
import { promisify as
|
|
640
|
-
var
|
|
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
|
|
750
|
+
async function execGit2(cwd, args, quiet) {
|
|
663
751
|
try {
|
|
664
|
-
const { stdout, stderr } = await
|
|
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
|
|
770
|
+
await execGit2(cwd, ["rev-parse", "--git-dir"], true);
|
|
683
771
|
}
|
|
684
772
|
async function getCurrentBranch(cwd) {
|
|
685
|
-
const name = (await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
719
|
-
await
|
|
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
|
|
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
|
|
750
|
-
await
|
|
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
|
|
755
|
-
await
|
|
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
|
|
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
|
|
850
|
+
await execGit2(cwd, ["fetch", "origin", baselineBranch]);
|
|
763
851
|
try {
|
|
764
|
-
await
|
|
852
|
+
await execGit2(cwd, [
|
|
765
853
|
"checkout",
|
|
766
854
|
"-b",
|
|
767
855
|
branch,
|
|
768
856
|
`origin/${baselineBranch}`
|
|
769
857
|
]);
|
|
770
|
-
await
|
|
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
|
|
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
|
|
815
|
-
import { promisify as
|
|
816
|
-
var
|
|
817
|
-
async function
|
|
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
|
|
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
|
|
925
|
+
await execGit3(cwd, ["rev-parse", "--git-dir"], true);
|
|
838
926
|
}
|
|
839
927
|
async function getCurrentBranch2(cwd) {
|
|
840
|
-
return (await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1047
|
+
await execGit3(cwd, ["checkout", defaultBranch], true);
|
|
960
1048
|
currentBranch = defaultBranch;
|
|
961
1049
|
}
|
|
962
1050
|
if (await localBranchExists2(cwd, branch)) {
|
|
963
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1387
|
+
writeFileSync8(
|
|
1300
1388
|
sessionRulePath(trimmedId, apmRoot),
|
|
1301
1389
|
detail.description ?? "",
|
|
1302
1390
|
"utf8"
|
|
1303
1391
|
);
|
|
1304
|
-
|
|
1392
|
+
writeFileSync8(
|
|
1305
1393
|
sessionTaskPath(trimmedId, apmRoot),
|
|
1306
1394
|
detail.task.description ?? "",
|
|
1307
1395
|
"utf8"
|
|
1308
1396
|
);
|
|
1309
|
-
|
|
1397
|
+
writeFileSync8(sessionTodoPath(trimmedId, apmRoot), detail.todo ?? "", "utf8");
|
|
1310
1398
|
for (const doc of documents) {
|
|
1311
1399
|
const fileName = documentLocalFileName(doc.name);
|
|
1312
|
-
|
|
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
|
-
|
|
1417
|
+
writeFileSync8(
|
|
1330
1418
|
sessionYamlPath(trimmedId, apmRoot),
|
|
1331
1419
|
sessionYaml.endsWith("\n") ? sessionYaml : `${sessionYaml}
|
|
1332
1420
|
`,
|
|
1333
1421
|
"utf8"
|
|
1334
1422
|
);
|
|
1335
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
部署线上环境: `npm run deploy:online`
|
|
5
5
|
|
|
6
6
|
## 后端部署(不区分正式环境和测试环境)
|
|
7
|
-
`python
|
|
7
|
+
`python .apm/deploy/deploy.py`
|
|
8
8
|
|
|
9
9
|
## 前端产物下载地址
|
|
10
10
|
|
|
11
11
|
http://<服务器地址>/dist.zip
|
|
12
|
-
http://<服务器地址>/
|
|
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()
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
保存位置: .apm/sessions/<会话 ID>/docs/<文件名>
|
|
17
17
|
文档内容格式: 根据你的主题来,不限制,禁止记流水账。
|
|
18
18
|
|
|
19
|
+
## SQL 变更文档(仅后端)
|
|
20
|
+
|
|
21
|
+
固定文件名: `SQL.md`
|
|
22
|
+
保存位置: `.apm/sessions/<会话 ID>/docs/SQL.md`
|
|
23
|
+
适用场景: 后端开发涉及 SQL 改动(DDL/DML、表结构、Mapper/XML 中 SQL 等)时必须产出或追加更新,详见 `.apm/skills/apm-dev/SKILL.md` 中「后端 SQL 变更文档」章节。
|
|
24
|
+
格式要求: 待执行的 SQL 语句须放在 ` ```sql ` 代码块中,按执行顺序逐条列出。
|
|
25
|
+
|
|
19
26
|
## 文档同步
|
|
20
27
|
|
|
21
28
|
保存到 `docs/` 后,`apm connect` 会在每轮 Agent 结束时自动推送到平台,无需额外操作。
|
|
@@ -24,5 +31,5 @@
|
|
|
24
31
|
|
|
25
32
|
1. 文档内容可以被其他团队成员看到,要有价值,禁止记流水账
|
|
26
33
|
2. 你可以根据你的名字查到自己写的文档,并且禁止更改其他人写的文档
|
|
27
|
-
3.
|
|
34
|
+
3. 不要随便创建新文档,可以在已有文档的基础上添加内容(`SQL.md` 为后端 SQL 变更的固定交付文档,除外)
|
|
28
35
|
4. 文档不可删除,不可更新文档名称,在写入时要谨慎
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- 「假设」仍有未确认项:**Read** `.apm/sessions/<会话ID>/messages.xml`,查找项目经理是否已回复确认;
|
|
11
11
|
- 项目经理已回复:先按 `.apm/skills/apm-write-plan/SKILL.md` 步骤 5 把确认结果**回填进计划文档并同步**(确认的假设移入「依据」,否定的修订实现步骤与白名单),然后再开发;
|
|
12
12
|
- 项目经理未回复:`@项目经理` 列出待确认假设,**停止本次开发**。
|
|
13
|
-
|
|
13
|
+
严禁带着未回填的澄清直接开发——项目经理在聊天里给过的口径若没落进计划,开发与 diff 评审都不会认。
|
|
14
14
|
|
|
15
15
|
### 步骤 2: 明确开发模式
|
|
16
16
|
|
|
@@ -36,15 +36,41 @@
|
|
|
36
36
|
- 按计划直接改代码;遵守本仓库构建与依赖约定(AGENTS.md)。
|
|
37
37
|
- **白名单约束**:只改计划白名单内的文件。开发中确需新增文件或改动白名单外文件,先更新计划文档的白名单(写明原因),再动手;**禁止悄悄越界**。
|
|
38
38
|
- **Git**:实现与自洽验收通过后,若有代码改动,**立即 `git add` + `git commit` 一次**(Quick 通常为单次交付,**一次实现 = 一个 commit**;勿拆成无意义碎 commit)。提交信息建议包含 `sessionId`(可从 `session.yaml` 获取)与需求摘要。
|
|
39
|
-
-
|
|
39
|
+
- **后端 SQL 文档**(仅后端):改动涉及 SQL 时,须 **Write** `docs/SQL.md`(见下文「后端 SQL 变更文档」)。
|
|
40
|
+
- 完成后在返回中说明:改了哪些路径、与白名单的对账结果(逐文件列出)、是否产出/更新 `SQL.md`、是否通过本地可执行的检查(若子 Agent 跑了构建/测试则写明结果);若有 commit,写明 **short-sha** 与 **subject**,无代码改动则注明跳过 commit。
|
|
40
41
|
|
|
41
42
|
### 步骤 4: 如果前一步判定为 **Spec 开发** 才(在子 Agent 中)执行本步骤,否则执行下一步:
|
|
42
43
|
|
|
43
44
|
1. 父 Agent **Read** `.apm/skills/apm-propose/SKILL.md` 和 `.apm/skills/apm-apply-change/SKILL.md`
|
|
44
45
|
2. **子 Agent A(规划)**:Task `generalPurpose`,提示其自行 **Read** `.apm/skills/apm-propose/SKILL.md` 并完整遵循:在 `plans/` 下生成 **proposal、design、specs、tasks** 等工件。
|
|
45
|
-
3. **子 Agent B(实现)**:待 A 成功落盘后,再 Task `generalPurpose`,提示其自行 **Read** `.apm/skills/apm-apply-change/SKILL.md` 并完整遵循:按 **`plans/tasks.md`** 驱动实现与勾选;遵守该技能中的停止条件与 commit
|
|
46
|
+
3. **子 Agent B(实现)**:待 A 成功落盘后,再 Task `generalPurpose`,提示其自行 **Read** `.apm/skills/apm-apply-change/SKILL.md` 并完整遵循:按 **`plans/tasks.md`** 驱动实现与勾选;遵守该技能中的停止条件与 commit 约定;同样遵守计划「改动文件白名单」;**后端**改动涉及 SQL 时须产出 `docs/SQL.md`(见下文「后端 SQL 变更文档」)。
|
|
46
47
|
4. 若 **apm-propose** 未产出可用 **`plans/tasks.md`**,不得强行进入 **apm-apply-change**;表格中标记阻塞原因。
|
|
47
48
|
|
|
49
|
+
### 后端 SQL 变更文档(仅后端工程师)
|
|
50
|
+
|
|
51
|
+
若本次改动涉及 SQL(DDL/DML、表结构、索引、数据修复、Mapper/XML 中新增或修改 SQL 语句等),开发阶段必须 **Write** `.apm/sessions/<会话ID>/docs/SQL.md`,内容包括:
|
|
52
|
+
|
|
53
|
+
- 变更摘要(改了什么表/数据、为什么)
|
|
54
|
+
- 完整 SQL 语句(按执行顺序排列;**每条须放在 ` ```sql ` 代码块中**,便于复制执行)
|
|
55
|
+
- 执行环境说明(测试/生产是否一致、是否需人工执行)
|
|
56
|
+
- 回滚方案(如适用;回滚 SQL 同样用 ` ```sql ` 代码块)
|
|
57
|
+
|
|
58
|
+
示例:
|
|
59
|
+
|
|
60
|
+
````markdown
|
|
61
|
+
## 变更摘要
|
|
62
|
+
|
|
63
|
+
为 inspection_class 表新增 is_project_add 字段。
|
|
64
|
+
|
|
65
|
+
## 执行 SQL
|
|
66
|
+
|
|
67
|
+
```sql
|
|
68
|
+
ALTER TABLE inspection_class ADD COLUMN is_project_add VARCHAR(1) DEFAULT '0';
|
|
69
|
+
```
|
|
70
|
+
````
|
|
71
|
+
|
|
72
|
+
`BACKEND-PLAN.md` 中不写大段 SQL,只可在实现步骤中注明「须产出 SQL.md」。若会话 `docs/` 下已有 `SQL.md`,在其上追加本次变更,勿另建其他 SQL 文档。
|
|
73
|
+
|
|
48
74
|
### 步骤 5: 提交并 push 代码,保证工作区干净
|
|
49
75
|
|
|
50
76
|
- Quick / Spec 子 Agent 完成且本地已有 commit 时,父 Agent **立即 `git push`**(当前分支首次 push 用 `git push -u origin HEAD`)。
|
|
@@ -55,7 +81,7 @@
|
|
|
55
81
|
开发完成的定义是以下三项**全部满足**,缺一不可:
|
|
56
82
|
|
|
57
83
|
1. **构建通过**:执行本仓库的构建/检查命令(见 AGENTS.md 或部署文档),失败必须修复后重试。
|
|
58
|
-
2. **发布测试环境**:**Read** `.apm/skills/apm-deploy/SKILL.md`
|
|
84
|
+
2. **发布测试环境**:**Read** `.apm/skills/apm-deploy/SKILL.md` 并按其流程部署;**后端**涉及 SQL 变更时,须在回复中引用 `docs/SQL.md`,并写明待执行的 SQL 文件名或执行顺序。
|
|
59
85
|
3. **白名单对账**:在回复中逐文件列出本次改动与计划白名单的对应关系。
|
|
60
86
|
|
|
61
87
|
**注意:不做联调。** 前后端各自按 API 契约交付,接口对不上属于契约或实现问题,由 diff 评审与人工验收暴露后打回修复;禁止自行发起「联调」「接口实测」类的开放式动作。
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
2. 在工作目录执行 `git log --oneline -20`,找到本任务相关的 commit(提交信息中含会话 ID 或需求关键词)。
|
|
17
17
|
3. `git diff <基线>..HEAD --stat` 与 `git diff <基线>..HEAD` 查看完整改动。
|
|
18
18
|
|
|
19
|
-
### 步骤 2
|
|
20
|
-
|
|
21
|
-
| 检查项 | 判定
|
|
22
|
-
| -------------- |
|
|
23
|
-
| **白名单对账** | diff 中出现白名单之外的文件,且计划未更新说明 → **不通过**
|
|
24
|
-
| **需求相关性** | 存在与本需求无关的改动(顺手重构、改格式、动了无关逻辑)→ **不通过**
|
|
25
|
-
| **计划落实** | 计划「实现步骤」中的关键点在 diff 中找不到对应实现 → **不通过**
|
|
19
|
+
### 步骤 2:四项检查
|
|
20
|
+
|
|
21
|
+
| 检查项 | 判定 |
|
|
22
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| **白名单对账** | diff 中出现白名单之外的文件,且计划未更新说明 → **不通过** |
|
|
24
|
+
| **需求相关性** | 存在与本需求无关的改动(顺手重构、改格式、动了无关逻辑)→ **不通过** |
|
|
25
|
+
| **计划落实** | 计划「实现步骤」中的关键点在 diff 中找不到对应实现 → **不通过** |
|
|
26
|
+
| **SQL 文档** | **仅后端**:diff 涉及 SQL 改动(DDL/DML、表结构、Mapper/XML 中 SQL 等),但 `docs/SQL.md` 缺失或与改动不一致 → **不通过** |
|
|
26
27
|
|
|
27
28
|
注意事项:
|
|
28
29
|
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
1. **依据与假设**:每条关键口径标注来源(需求原文第几条 / 现有代码行为 / 已有文档);标不出来源的就是「假设」,单独列出。
|
|
36
36
|
2. **改动文件白名单**:本次允许改动的文件完整列表。后续开发与 diff 评审都以此为准,**开发时改了白名单之外的文件会被打回**。
|
|
37
37
|
3. **后端专属——API 契约**:给前端看的接口定义(Path、参数、响应示例、错误码)。前端开发以契约为准,不等后端部署完成。
|
|
38
|
+
4. **后端专属——SQL 变更声明**(仅当涉及表结构/数据变更时):在「实现步骤」中注明开发须产出 `docs/SQL.md`;计划本身不写大段 SQL,完整语句由后端开发阶段写入该文档。
|
|
38
39
|
|
|
39
40
|
### 步骤 4:回复
|
|
40
41
|
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
|
|
63
64
|
## 写作要求
|
|
64
65
|
|
|
65
|
-
- 篇幅 **40 ~ 100 行**,宁可少写;不要伪代码、不要大段 SQL
|
|
66
|
+
- 篇幅 **40 ~ 100 行**,宁可少写;不要伪代码、不要大段 SQL(完整语句写入 `docs/SQL.md`,由后端开发阶段产出)。
|
|
66
67
|
- 用产品语言描述行为,文件路径只出现在「改动文件白名单」。
|
|
67
68
|
- 前后端可同轮并行编写计划,不互相阻塞。
|
|
68
69
|
|
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
|
|
9
9
|
## 1. 需求理解
|
|
10
10
|
|
|
11
|
-
用 3~5 行复述本端要做什么(产品语言),不复制需求原文。
|
|
11
|
+
用 3 ~ 5 行复述本端要做什么(产品语言),不复制需求原文。
|
|
12
12
|
|
|
13
13
|
## 2. 依据与假设
|
|
14
14
|
|
|
15
15
|
### 依据(口径 + 来源)
|
|
16
16
|
|
|
17
|
-
| #
|
|
18
|
-
|
|
19
|
-
| 1
|
|
20
|
-
| 2
|
|
21
|
-
| 3
|
|
17
|
+
| # | 口径 | 来源 |
|
|
18
|
+
| --- | ------------------------------ | ----------------------------- |
|
|
19
|
+
| 1 | 「是否加分」与「是否扣分」互斥 | 需求原文第 1 条 |
|
|
20
|
+
| 2 | 加分项不汇总进分类分值 | 需求原文第 2 条 |
|
|
21
|
+
| 3 | 分值字段现状为 varchar | 代码现状:inspection_class 表 |
|
|
22
22
|
|
|
23
23
|
### 假设(待项目经理确认,确认前不开发)
|
|
24
24
|
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
|
|
32
32
|
1. 第一步做什么(对应哪条口径)
|
|
33
33
|
2. 第二步做什么
|
|
34
|
-
3. ...(通常 3~6 步,不写代码细节)
|
|
34
|
+
3. ...(通常 3 ~ 6 步,不写代码细节)
|
|
35
|
+
|
|
36
|
+
> **后端**:若涉及表结构或数据变更,在步骤中注明「开发须产出 `docs/SQL.md`」,SQL 语句不写在本计划内。
|
|
35
37
|
|
|
36
38
|
## 4. 改动文件白名单
|
|
37
39
|
|
|
@@ -49,13 +51,13 @@
|
|
|
49
51
|
- Path:`POST /inspection/project/save`
|
|
50
52
|
- 新增入参:
|
|
51
53
|
|
|
52
|
-
| 字段
|
|
53
|
-
|
|
54
|
-
| isProjectAdd | string | 否
|
|
54
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
55
|
+
| ------------ | ------ | ---- | -------------------------------- |
|
|
56
|
+
| isProjectAdd | string | 否 | "1"=加分项;与 isProjectDed 互斥 |
|
|
55
57
|
|
|
56
58
|
- 响应示例:
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
{ "success": true, "result": { "id": "xxx" } }
|
|
59
61
|
|
|
60
62
|
- 错误码:互斥冲突返回 `success=false, message="加分与扣分不可同时勾选"`
|
|
61
63
|
|