create-yss-spec 3.1.0 → 3.1.3
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/README.md +117 -91
- package/bin/create-yss-spec.js +11 -3
- package/package.json +9 -2
- package/src/api/_sync-service.js +191 -0
- package/src/api/index.js +22 -0
- package/src/api/project-diff.js +9 -0
- package/src/api/project-doctor.js +10 -0
- package/src/api/template-apply.js +10 -0
- package/src/api/template-plan.js +9 -0
- package/src/cli/args.js +60 -0
- package/src/cli/error-output.js +52 -0
- package/src/cli/flags.js +9 -0
- package/src/cli/help.js +95 -0
- package/src/cli/index.js +82 -0
- package/src/cli/plan-output.js +61 -0
- package/src/cli/prompts.js +91 -0
- package/src/cli/router.js +41 -0
- package/src/cli.js +5 -2253
- package/src/commands/attach.js +280 -0
- package/src/commands/diff.js +23 -0
- package/src/commands/doctor.js +417 -0
- package/src/commands/init.js +193 -0
- package/src/commands/sync.js +146 -0
- package/src/commands/update.js +14 -0
- package/src/contracts/apply-result-v1.json +45 -0
- package/src/contracts/customization-policy-v1.json +28 -0
- package/src/contracts/doctor-report-v1.json +29 -0
- package/src/contracts/error-envelope-v1.json +24 -0
- package/src/contracts/generator-policy-v1.json +24 -0
- package/src/contracts/ownership-policy-v1.json +40 -0
- package/src/contracts/plan-schema-v1.json +115 -0
- package/src/family-identity.js +365 -0
- package/src/family-runtime.js +23 -0
- package/src/filesystem/apply-plan.js +44 -0
- package/src/filesystem/copy-path.js +28 -0
- package/src/filesystem/path-utils.js +67 -0
- package/src/filesystem/transaction-runner.js +41 -0
- package/src/filesystem/transaction.js +153 -0
- package/src/git/submodule.js +115 -0
- package/src/git/worktree.js +69 -0
- package/src/template/attach-planner-runtime.js +51 -0
- package/src/template/attach-planner.js +160 -0
- package/src/template/instance-runtime.js +463 -0
- package/src/template/lifecycle-metadata.js +99 -0
- package/src/template/lifecycle-policy.js +129 -0
- package/src/template/lifecycle-runtime.js +90 -0
- package/src/template/migration-planner.js +95 -0
- package/src/template/migration-runtime.js +247 -0
- package/src/template/ownership-metadata.js +79 -0
- package/src/template/ownership-policy.js +149 -0
- package/src/template/ownership-runtime.js +109 -0
- package/src/template/plan-schema.js +36 -0
- package/src/template/sync-planner-runtime.js +57 -0
- package/src/template/sync-planner.js +206 -0
- package/src/template/verification-runtime.js +154 -0
- package/src/validation/identity.js +41 -0
- package/src/validation/metadata.js +105 -0
- package/src/validation/security.js +67 -0
- package/src/validation/snapshot.js +47 -0
- package/template/.agents/skills/.strategic-design-skills-manifest.json +1 -1
- package/template/.agents/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.agents/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.agents/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.agents/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.agents/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.agents/skills/yss-ui/SKILL.md +2 -0
- package/template/.claude/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.claude/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.claude/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.claude/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.claude/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.claude/skills/yss-ui/SKILL.md +2 -0
- package/template/.codex/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.codex/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.codex/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.codex/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.codex/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.codex/skills/yss-ui/SKILL.md +2 -0
- package/template/.cursor/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.cursor/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.cursor/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.cursor/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.cursor/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.cursor/skills/yss-ui/SKILL.md +2 -0
- package/template/.pi/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.pi/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.pi/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.pi/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.pi/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.pi/skills/yss-ui/SKILL.md +2 -0
- package/template/.qoder/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.qoder/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.qoder/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.qoder/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.qoder/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.qoder/skills/yss-ui/SKILL.md +2 -0
- package/template/.trae/skills/yss-frontend-scaffold-generator/SKILL.md +2 -0
- package/template/.trae/skills/yss-implementation-contract-compiler/SKILL.md +2 -0
- package/template/.trae/skills/yss-implementation-contract-compiler/references/compiler-contract.yaml +6 -0
- package/template/.trae/skills/yss-implementation-contract-compiler/references/slice-implementation-contract.md +2 -0
- package/template/.trae/skills/yss-product-lifecycle/SKILL.md +2 -0
- package/template/.trae/skills/yss-ui/SKILL.md +2 -0
- package/template/CONTEXT.md +2 -0
- package/template/README.md +4 -0
- package/template/docs/agents/digital-human-roles.yaml +1 -0
- package/template/docs/process/frontend-backend-delivery.md +72 -0
- package/template/docs/process/implementation-repo-integration.md +2 -0
- package/template/docs/process/lifecycle-artifact-map.md +1 -0
- package/template/docs/process/lifecycle-registry-baseline.json +2 -1
- package/template/docs/process/lifecycle-registry.yaml +5 -0
- package/template/docs/process/schemas/backend-delivery-verification.schema.json +113 -0
- package/template/docs/process/schemas/backend-delivery.schema.json +301 -0
- package/template/docs/process/schemas/digital-human-task-package.schema.json +8 -0
- package/template/docs/process/schemas/frontend-delivery-acceptance.schema.json +200 -0
- package/template/docs/process/schemas/frontend-implementation-evidence.schema.json +3 -0
- package/template/docs/process/template-verification-profiles.yaml +6 -0
- package/template/docs/user-guide//346/210/230/346/234/257/350/256/276/350/256/241/345/255/220/351/241/271/347/233/256/347/224/250/346/210/267/346/211/213/345/206/214.md +20 -206
- package/template/docs/user-guide//347/224/250/346/210/267/346/211/213/345/206/214.md +63 -963
- package/template/docs/user-guide//347/224/250/346/210/267/346/211/213/345/206/214/347/264/242/345/274/225.md +11 -14
- package/template/docs/user-guide//350/256/276/345/244/207/345/200/237/347/224/250/350/264/257/347/251/277/346/241/210/344/276/213.md +127 -0
- package/template/scripts/backend-delivery +14 -0
- package/template/scripts/fixtures/backend-delivery/revision-server.mjs +11 -0
- package/template/scripts/instantiate-harness +69 -0
- package/template/scripts/lib/backend-delivery.mjs +164 -0
- package/template/scripts/lib/frontend-delivery-boundary.mjs +49 -0
- package/template/scripts/lib/frontend-delivery.mjs +73 -0
- package/template/scripts/lib/harness-execution-scope.mjs +52 -0
- package/template/scripts/lib/implementation-contract-compiler.mjs +12 -2
- package/template/scripts/lib/lifecycle-transition.mjs +7 -0
- package/template/scripts/lib/strategic-handoff-consumption.mjs +14 -7
- package/template/scripts/lib/strategic-handoff.mjs +1 -1
- package/template/scripts/lib/task-package.mjs +7 -0
- package/template/scripts/sync-strategic-handoff-tools +3 -1
- package/template/scripts/verify-frontend-delivery +9 -0
- package/template/scripts/verify-frontend-delivery-scenarios +131 -0
- package/template/scripts/verify-frontend-implementation-evidence +2 -0
- package/template/skills-lock.json +11 -11
- package/template.manifest.json +43 -1
- package/template.snapshot.json +5 -5
package/src/cli.js
CHANGED
|
@@ -1,2254 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
const fs = require("node:fs");
|
|
3
|
-
const os = require("node:os");
|
|
4
|
-
const path = require("node:path");
|
|
5
|
-
const readline = require("node:readline/promises");
|
|
6
|
-
const { spawnSync } = require("node:child_process");
|
|
7
|
-
const { treeHash } = require("./template-hash");
|
|
8
|
-
const { UPDATE_COMMANDS, runUpdate } = require("./self-update");
|
|
1
|
+
"use strict";
|
|
9
2
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
);
|
|
14
|
-
const BUNDLED_TEMPLATE_ROOT = path.join(PACKAGE_ROOT, "template");
|
|
15
|
-
const BUNDLED_MANIFEST_PATH = path.join(PACKAGE_ROOT, "template.manifest.json");
|
|
16
|
-
const BUNDLED_SNAPSHOT_PATH = path.join(PACKAGE_ROOT, "template.snapshot.json");
|
|
17
|
-
const TEMPLATE_MANIFEST_TEXT = fs.readFileSync(BUNDLED_MANIFEST_PATH, "utf8");
|
|
18
|
-
const TEMPLATE_MANIFEST = JSON.parse(TEMPLATE_MANIFEST_TEXT);
|
|
19
|
-
const ROOT_EXCLUDED_ENTRIES = new Set(TEMPLATE_MANIFEST.excludeRootEntries);
|
|
20
|
-
const ROOT_EXCLUDED_FILES = new Set(TEMPLATE_MANIFEST.excludeRootFiles);
|
|
21
|
-
const EXCLUDED_RELATIVE_PATHS = new Set(TEMPLATE_MANIFEST.excludePaths);
|
|
22
|
-
const ALLOWED_RELATIVE_PATHS = new Set(TEMPLATE_MANIFEST.allowFiles || []);
|
|
23
|
-
const INIT_EXCLUDED_ROOT_ENTRIES = new Set(
|
|
24
|
-
TEMPLATE_MANIFEST.initExcludeRootEntries || [],
|
|
25
|
-
);
|
|
26
|
-
const INIT_EXCLUDED_ROOT_FILES = new Set(
|
|
27
|
-
TEMPLATE_MANIFEST.initExcludeRootFiles || [],
|
|
28
|
-
);
|
|
29
|
-
const INIT_EXCLUDED_RELATIVE_PATHS = new Set(
|
|
30
|
-
TEMPLATE_MANIFEST.initExcludePaths || [],
|
|
31
|
-
);
|
|
32
|
-
const RENDERED_RELATIVE_PATHS = new Set(TEMPLATE_MANIFEST.renderPaths);
|
|
33
|
-
const EXAMPLE_DOC_PATHS = new Set(TEMPLATE_MANIFEST.exampleDocPaths);
|
|
34
|
-
const TEMPLATE_METADATA_FILENAME = ".yss-template.json";
|
|
35
|
-
const TEMPLATE_MANIFEST_VERSION = sha256(TEMPLATE_MANIFEST_TEXT);
|
|
36
|
-
const TEMPLATE_SOURCE = "github:iloveZzz/yss-spec-project-template";
|
|
37
|
-
const METADATA_SCHEMA_VERSION = 2;
|
|
38
|
-
const HELP_FLAGS = new Set(["--help", "-h", "-help"]);
|
|
39
|
-
const VERSION_FLAGS = new Set(["--version", "-v", "-version"]);
|
|
40
|
-
const AGENT_SKILL_ROOTS = [
|
|
41
|
-
".agents/skills",
|
|
42
|
-
".claude/skills",
|
|
43
|
-
".codex/skills",
|
|
44
|
-
".cursor/skills",
|
|
45
|
-
".pi/skills",
|
|
46
|
-
".qoder/skills",
|
|
47
|
-
".trae/skills",
|
|
48
|
-
];
|
|
49
|
-
const GITLINK_MODE = "160000";
|
|
50
|
-
const UNMANAGED_USER_PATHS = new Set([".gitmodules"]);
|
|
51
|
-
const GITLINK_PATH_CACHE = new Map();
|
|
52
|
-
const INSTANCE_FORBIDDEN_PATHS = [
|
|
53
|
-
".template-source",
|
|
54
|
-
".github",
|
|
55
|
-
".cursor/environment.json",
|
|
56
|
-
"wiki",
|
|
57
|
-
"docs/reviews",
|
|
58
|
-
];
|
|
59
|
-
const LEGACY_SKILL_MAPPINGS = [
|
|
60
|
-
["to-prd", "to-spec"],
|
|
61
|
-
["to-issues", "to-tickets"],
|
|
62
|
-
];
|
|
63
|
-
const LEGACY_FILE_MAPPINGS = [
|
|
64
|
-
["docs/templates/prd-template.md", "docs/templates/spec-template.md"],
|
|
65
|
-
[
|
|
66
|
-
"docs/templates/vertical-slice-issue-template.md",
|
|
67
|
-
"docs/templates/vertical-slice-ticket-template.md",
|
|
68
|
-
],
|
|
69
|
-
];
|
|
70
|
-
let BUNDLED_PATH_TO_LOGICAL_PATH = null;
|
|
71
|
-
|
|
72
|
-
function sha256(value) {
|
|
73
|
-
return crypto.createHash("sha256").update(value).digest("hex");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function nowIsoString() {
|
|
77
|
-
return new Date().toISOString();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function readTemplateSnapshot() {
|
|
81
|
-
if (!fs.existsSync(BUNDLED_SNAPSHOT_PATH)) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
"缺少模板快照元数据,请先运行 npm run sync-template;正式发布不得使用浮动模板引用",
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let snapshot;
|
|
88
|
-
try {
|
|
89
|
-
snapshot = JSON.parse(fs.readFileSync(BUNDLED_SNAPSHOT_PATH, "utf8"));
|
|
90
|
-
} catch (error) {
|
|
91
|
-
throw new Error(`模板快照元数据无法解析:${error.message}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!/^[0-9a-f]{40}$/.test(snapshot.templateCommit || "")) {
|
|
95
|
-
throw new Error("模板快照必须绑定 40 位不可变 templateCommit");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!/^[0-9a-f]{64}$/.test(snapshot.snapshotHash || "")) {
|
|
99
|
-
throw new Error("模板快照必须包含 64 位 snapshotHash");
|
|
100
|
-
}
|
|
101
|
-
if (snapshot.manifestHash !== TEMPLATE_MANIFEST_VERSION) {
|
|
102
|
-
throw new Error("模板快照与当前 template.manifest.json 不一致,请重新构建 CLI 包");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
snapshot.encodedPaths !== undefined &&
|
|
107
|
-
(typeof snapshot.encodedPaths !== "object" ||
|
|
108
|
-
snapshot.encodedPaths === null ||
|
|
109
|
-
Array.isArray(snapshot.encodedPaths))
|
|
110
|
-
) {
|
|
111
|
-
throw new Error("模板快照 encodedPaths 必须是 JSON 对象");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const encodedTargets = new Set();
|
|
115
|
-
for (const [logicalPath, encodedPath] of Object.entries(snapshot.encodedPaths || {})) {
|
|
116
|
-
for (const value of [logicalPath, encodedPath]) {
|
|
117
|
-
const normalized = normalizeRelativePath(value);
|
|
118
|
-
if (
|
|
119
|
-
!normalized ||
|
|
120
|
-
normalized === "." ||
|
|
121
|
-
normalized.startsWith("../") ||
|
|
122
|
-
normalized.includes("/../") ||
|
|
123
|
-
path.posix.isAbsolute(normalized)
|
|
124
|
-
) {
|
|
125
|
-
throw new Error(`模板快照包含越界路径:${value}`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
if (encodedTargets.has(normalizeRelativePath(encodedPath))) {
|
|
129
|
-
throw new Error(`模板快照 encodedPaths 存在重复目标:${encodedPath}`);
|
|
130
|
-
}
|
|
131
|
-
encodedTargets.add(normalizeRelativePath(encodedPath));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (snapshot.snapshotHash !== treeHash(BUNDLED_TEMPLATE_ROOT)) {
|
|
135
|
-
throw new Error("模板快照内容 hash 不匹配,请重新构建 CLI 包");
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return snapshot;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function logicalTemplatePath(bundledPath) {
|
|
142
|
-
if (BUNDLED_PATH_TO_LOGICAL_PATH === null) {
|
|
143
|
-
const snapshot = readTemplateSnapshot();
|
|
144
|
-
BUNDLED_PATH_TO_LOGICAL_PATH = new Map(
|
|
145
|
-
Object.entries(snapshot.encodedPaths || {}).map(
|
|
146
|
-
([logicalPath, encodedPath]) => [
|
|
147
|
-
normalizeRelativePath(encodedPath),
|
|
148
|
-
normalizeRelativePath(logicalPath),
|
|
149
|
-
],
|
|
150
|
-
),
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
return BUNDLED_PATH_TO_LOGICAL_PATH.get(normalizeRelativePath(bundledPath)) ||
|
|
154
|
-
normalizeRelativePath(bundledPath);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function getTemplateSource() {
|
|
158
|
-
return TEMPLATE_SOURCE;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function parseArgs(argv) {
|
|
162
|
-
const options = {};
|
|
163
|
-
|
|
164
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
165
|
-
const current = argv[index];
|
|
166
|
-
const next = argv[index + 1];
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
[
|
|
170
|
-
"--project-name",
|
|
171
|
-
"--business-domain",
|
|
172
|
-
"--team-size",
|
|
173
|
-
"--target-dir",
|
|
174
|
-
"--issue-tracker",
|
|
175
|
-
].includes(current)
|
|
176
|
-
) {
|
|
177
|
-
if (!next || next.startsWith("--")) {
|
|
178
|
-
throw new Error(`${current} 需要一个值`);
|
|
179
|
-
}
|
|
180
|
-
const key = {
|
|
181
|
-
"--project-name": "projectName",
|
|
182
|
-
"--business-domain": "businessDomain",
|
|
183
|
-
"--team-size": "teamSize",
|
|
184
|
-
"--target-dir": "targetDir",
|
|
185
|
-
"--issue-tracker": "issueTracker",
|
|
186
|
-
}[current];
|
|
187
|
-
options[key] = next;
|
|
188
|
-
index += 1;
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (current === "--dry-run") {
|
|
193
|
-
options.dryRun = true;
|
|
194
|
-
} else if (current === "--apply") {
|
|
195
|
-
options.apply = true;
|
|
196
|
-
} else if (current === "--force") {
|
|
197
|
-
options.force = true;
|
|
198
|
-
} else if (current === "--git-init") {
|
|
199
|
-
options.gitInit = true;
|
|
200
|
-
} else if (current === "--include-example-docs") {
|
|
201
|
-
options.includeExampleDocs = true;
|
|
202
|
-
} else if (current === "--no-example-docs") {
|
|
203
|
-
options.includeExampleDocs = false;
|
|
204
|
-
} else if (HELP_FLAGS.has(current)) {
|
|
205
|
-
options.help = true;
|
|
206
|
-
} else if (VERSION_FLAGS.has(current)) {
|
|
207
|
-
options.version = true;
|
|
208
|
-
} else {
|
|
209
|
-
throw new Error(`不支持的参数:${current}`);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return options;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function promptForMissingOptions(options) {
|
|
217
|
-
if (!process.stdin.isTTY) {
|
|
218
|
-
return promptFromBufferedInput(options);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const rl = readline.createInterface({
|
|
222
|
-
input: process.stdin,
|
|
223
|
-
output: process.stdout,
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
const projectName =
|
|
228
|
-
options.projectName || (await rl.question("项目名称: ")).trim();
|
|
229
|
-
const businessDomain =
|
|
230
|
-
options.businessDomain || (await rl.question("业务领域: ")).trim();
|
|
231
|
-
const teamSizeInput =
|
|
232
|
-
options.teamSize !== undefined
|
|
233
|
-
? options.teamSize
|
|
234
|
-
: await rl.question("团队规模(可留空): ");
|
|
235
|
-
const targetDir =
|
|
236
|
-
options.targetDir || (await rl.question("目标目录: ")).trim();
|
|
237
|
-
|
|
238
|
-
return normalizeInteractiveOptions(options, {
|
|
239
|
-
projectName,
|
|
240
|
-
businessDomain,
|
|
241
|
-
teamSize: (teamSizeInput || "").trim() || "待补充",
|
|
242
|
-
targetDir,
|
|
243
|
-
});
|
|
244
|
-
} finally {
|
|
245
|
-
rl.close();
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function promptFromBufferedInput(options) {
|
|
250
|
-
const chunks = [];
|
|
251
|
-
|
|
252
|
-
for await (const chunk of process.stdin) {
|
|
253
|
-
chunks.push(chunk);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const answers = Buffer.concat(chunks).toString("utf8").split(/\r?\n/);
|
|
257
|
-
let answerIndex = 0;
|
|
258
|
-
const ask = (label) => {
|
|
259
|
-
process.stdout.write(`${label}: `);
|
|
260
|
-
const value = answers[answerIndex] ?? "";
|
|
261
|
-
answerIndex += 1;
|
|
262
|
-
return value.trim();
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const projectName = options.projectName || ask("项目名称");
|
|
266
|
-
const businessDomain = options.businessDomain || ask("业务领域");
|
|
267
|
-
const teamSizeInput =
|
|
268
|
-
options.teamSize !== undefined ? options.teamSize : ask("团队规模(可留空)");
|
|
269
|
-
const targetDir = options.targetDir || ask("目标目录");
|
|
270
|
-
|
|
271
|
-
return normalizeInteractiveOptions(options, {
|
|
272
|
-
projectName,
|
|
273
|
-
businessDomain,
|
|
274
|
-
teamSize: teamSizeInput || "待补充",
|
|
275
|
-
targetDir,
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function normalizeInteractiveOptions(options, values) {
|
|
280
|
-
return {
|
|
281
|
-
...values,
|
|
282
|
-
issueTracker: options.issueTracker || "github",
|
|
283
|
-
dryRun: Boolean(options.dryRun),
|
|
284
|
-
force: Boolean(options.force),
|
|
285
|
-
gitInit: Boolean(options.gitInit),
|
|
286
|
-
includeExampleDocs:
|
|
287
|
-
options.includeExampleDocs === undefined
|
|
288
|
-
? true
|
|
289
|
-
: Boolean(options.includeExampleDocs),
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function assertRequiredOptions(options, command = "init") {
|
|
294
|
-
if (!options.projectName) {
|
|
295
|
-
throw new Error(`${command} 需要 --project-name,项目名称不能为空`);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (!options.businessDomain) {
|
|
299
|
-
throw new Error(`${command} 需要 --business-domain,业务领域不能为空`);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (!options.targetDir) {
|
|
303
|
-
throw new Error(`${command} 需要 --target-dir,目标目录不能为空`);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function normalizeTargetDir(targetDir) {
|
|
308
|
-
return path.resolve(process.cwd(), targetDir);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function isInsideTemplateRoot(targetDir) {
|
|
312
|
-
const relativePath = path.relative(BUNDLED_TEMPLATE_ROOT, targetDir);
|
|
313
|
-
return (
|
|
314
|
-
relativePath === "" ||
|
|
315
|
-
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function posixRelative(from, to) {
|
|
320
|
-
return path.relative(from, to).split(path.sep).join("/");
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function collectGitRoots(start) {
|
|
324
|
-
const roots = [];
|
|
325
|
-
if (!start) {
|
|
326
|
-
return roots;
|
|
327
|
-
}
|
|
328
|
-
let current = path.resolve(start);
|
|
329
|
-
while (true) {
|
|
330
|
-
const gitPath = path.join(current, ".git");
|
|
331
|
-
let validGitEntry = false;
|
|
332
|
-
try {
|
|
333
|
-
const gitStat = fs.lstatSync(gitPath);
|
|
334
|
-
validGitEntry = gitStat.isDirectory()
|
|
335
|
-
? fs.readdirSync(gitPath).length > 0
|
|
336
|
-
: gitStat.isFile();
|
|
337
|
-
} catch (error) {
|
|
338
|
-
if (error.code !== "ENOENT") {
|
|
339
|
-
throw error;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (
|
|
343
|
-
validGitEntry ||
|
|
344
|
-
fs.existsSync(path.join(current, ".gitmodules"))
|
|
345
|
-
) {
|
|
346
|
-
roots.push(current);
|
|
347
|
-
}
|
|
348
|
-
const parent = path.dirname(current);
|
|
349
|
-
if (parent === current) {
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
current = parent;
|
|
353
|
-
}
|
|
354
|
-
return roots;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function parseGitmodules(content) {
|
|
358
|
-
const modules = [];
|
|
359
|
-
if (typeof content !== "string" || content.length === 0) {
|
|
360
|
-
return modules;
|
|
361
|
-
}
|
|
362
|
-
let current = null;
|
|
363
|
-
for (const rawLine of content.split(/\r?\n/)) {
|
|
364
|
-
const line = rawLine.trim();
|
|
365
|
-
const name = line.match(/^\[submodule "(.+)"\]$/);
|
|
366
|
-
if (name) {
|
|
367
|
-
current = { name: name[1], path: "", url: "" };
|
|
368
|
-
modules.push(current);
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
if (!current) {
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const pathMatch = line.match(/^path\s*=\s*(.+)$/);
|
|
375
|
-
if (pathMatch) {
|
|
376
|
-
current.path = pathMatch[1].trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
377
|
-
}
|
|
378
|
-
const urlMatch = line.match(/^url\s*=\s*(.+)$/);
|
|
379
|
-
if (urlMatch) {
|
|
380
|
-
current.url = urlMatch[1].trim();
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return modules;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function readGitmodules(repoRoot) {
|
|
387
|
-
const file = path.join(repoRoot, ".gitmodules");
|
|
388
|
-
if (!fs.existsSync(file) || pathKind(file) !== "file") {
|
|
389
|
-
return [];
|
|
390
|
-
}
|
|
391
|
-
return parseGitmodules(fs.readFileSync(file, "utf8"));
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function gitLsFilesStage(repoRoot, relativePath) {
|
|
395
|
-
const result = spawnSync(
|
|
396
|
-
"git",
|
|
397
|
-
["-C", repoRoot, "ls-files", "--stage", "--", relativePath],
|
|
398
|
-
{ encoding: "utf8", timeout: 5000 },
|
|
399
|
-
);
|
|
400
|
-
if (result.status !== 0) {
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
const line = (result.stdout || "").trim().split(/\r?\n/).filter(Boolean)[0];
|
|
404
|
-
if (!line) {
|
|
405
|
-
return null;
|
|
406
|
-
}
|
|
407
|
-
const match = line.match(/^(\d+)\s+([0-9a-f]+)\s+(\d+)\s+(.+)$/i);
|
|
408
|
-
if (!match) {
|
|
409
|
-
return null;
|
|
410
|
-
}
|
|
411
|
-
return { mode: match[1], sha: match[2], stage: match[3], path: match[4] };
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function gitlinkPaths(repoRoot) {
|
|
415
|
-
const resolvedRoot = path.resolve(repoRoot);
|
|
416
|
-
if (GITLINK_PATH_CACHE.has(resolvedRoot)) {
|
|
417
|
-
return GITLINK_PATH_CACHE.get(resolvedRoot);
|
|
418
|
-
}
|
|
419
|
-
const result = spawnSync(
|
|
420
|
-
"git",
|
|
421
|
-
["-C", resolvedRoot, "ls-files", "--stage", "-z"],
|
|
422
|
-
{ encoding: "utf8", timeout: 5000 },
|
|
423
|
-
);
|
|
424
|
-
const paths = new Set();
|
|
425
|
-
if (result.status === 0) {
|
|
426
|
-
for (const record of (result.stdout || "").split("\0")) {
|
|
427
|
-
const match = record.match(/^(160000)\s+[0-9a-f]+\s+\d+\s+(.+)$/i);
|
|
428
|
-
if (match) {
|
|
429
|
-
paths.add(match[2].replaceAll("\\", "/"));
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
GITLINK_PATH_CACHE.set(resolvedRoot, paths);
|
|
434
|
-
return paths;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function gitShowSuperproject(cwd) {
|
|
438
|
-
const result = spawnSync(
|
|
439
|
-
"git",
|
|
440
|
-
["-C", cwd, "rev-parse", "--show-superproject-working-tree"],
|
|
441
|
-
{ encoding: "utf8", timeout: 5000 },
|
|
442
|
-
);
|
|
443
|
-
if (result.status !== 0) {
|
|
444
|
-
return "";
|
|
445
|
-
}
|
|
446
|
-
return (result.stdout || "").trim();
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function gitAbbrevRef(cwd) {
|
|
450
|
-
const result = spawnSync(
|
|
451
|
-
"git",
|
|
452
|
-
["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
453
|
-
{ encoding: "utf8", timeout: 5000 },
|
|
454
|
-
);
|
|
455
|
-
if (result.status !== 0) {
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
return (result.stdout || "").trim();
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function isEmptyDir(dir) {
|
|
462
|
-
if (!fs.existsSync(dir)) {
|
|
463
|
-
return true;
|
|
464
|
-
}
|
|
465
|
-
try {
|
|
466
|
-
return fs.readdirSync(dir).filter((name) => name !== "." && name !== "..")
|
|
467
|
-
.length === 0;
|
|
468
|
-
} catch {
|
|
469
|
-
return false;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function isGitSubmoduleMount(repoRoot, targetPathValue) {
|
|
474
|
-
const relative = posixRelative(repoRoot, path.resolve(targetPathValue));
|
|
475
|
-
if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
476
|
-
return false;
|
|
477
|
-
}
|
|
478
|
-
if (readGitmodules(repoRoot).some((item) => item.path === relative)) {
|
|
479
|
-
return true;
|
|
480
|
-
}
|
|
481
|
-
return gitlinkPaths(repoRoot).has(relative);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function inspectCheckoutState(repoRoot, targetPathValue) {
|
|
485
|
-
const resolved = path.resolve(targetPathValue);
|
|
486
|
-
const relative = posixRelative(path.resolve(repoRoot), resolved);
|
|
487
|
-
const inRepo = Boolean(relative) && !relative.startsWith("..");
|
|
488
|
-
const staged = inRepo && gitlinkPaths(repoRoot).has(relative)
|
|
489
|
-
? { mode: GITLINK_MODE }
|
|
490
|
-
: null;
|
|
491
|
-
const listed = inRepo
|
|
492
|
-
? readGitmodules(repoRoot).some((item) => item.path === relative)
|
|
493
|
-
: false;
|
|
494
|
-
const superproject = fs.existsSync(path.join(resolved, ".git"))
|
|
495
|
-
? gitShowSuperproject(resolved)
|
|
496
|
-
: "";
|
|
497
|
-
const isMount = staged?.mode === GITLINK_MODE || listed || Boolean(superproject);
|
|
498
|
-
const hasGit = fs.existsSync(path.join(resolved, ".git"));
|
|
499
|
-
if (isMount && !hasGit) {
|
|
500
|
-
return isEmptyDir(resolved) ? "empty-gitlink" : "uninitialized";
|
|
501
|
-
}
|
|
502
|
-
if (isMount && hasGit) {
|
|
503
|
-
const ref = gitAbbrevRef(resolved);
|
|
504
|
-
if (ref === "HEAD") {
|
|
505
|
-
return "detached-head";
|
|
506
|
-
}
|
|
507
|
-
return "attached-branch";
|
|
508
|
-
}
|
|
509
|
-
return null;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function gitlinkWriteViolation(targetDir, { force = false } = {}) {
|
|
513
|
-
const resolved = path.resolve(targetDir);
|
|
514
|
-
const roots = collectGitRoots(resolved);
|
|
515
|
-
for (const root of roots) {
|
|
516
|
-
if (path.resolve(root) === PACKAGE_ROOT) {
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
let current = resolved;
|
|
520
|
-
while (true) {
|
|
521
|
-
if (isGitSubmoduleMount(root, current)) {
|
|
522
|
-
const checkout = inspectCheckoutState(root, current);
|
|
523
|
-
if (checkout === "empty-gitlink" || checkout === "uninitialized") {
|
|
524
|
-
return "空 gitlink 不得当成普通目录写入;gitlink 不得由 CLI 覆盖";
|
|
525
|
-
}
|
|
526
|
-
if (checkout === "detached-head") {
|
|
527
|
-
return "detached HEAD 不得当成普通目录写入;gitlink 不得由 CLI 覆盖";
|
|
528
|
-
}
|
|
529
|
-
if (path.resolve(current) === resolved) {
|
|
530
|
-
return force
|
|
531
|
-
? "--force 不得把 git-submodule 挂载点当成普通目录覆盖"
|
|
532
|
-
: "git-submodule gitlink 不得由 CLI 覆盖;先 git submodule update --init,在子仓附加分支的工作树内操作,或改用独立目录";
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
if (path.resolve(current) === path.resolve(root)) {
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
const parent = path.dirname(current);
|
|
539
|
-
if (parent === current) {
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
current = parent;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function assertTargetWorkingTreeWritable(targetDir, { force = false } = {}) {
|
|
549
|
-
const violation = gitlinkWriteViolation(targetDir, { force });
|
|
550
|
-
if (violation) {
|
|
551
|
-
throw new Error(violation);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
function unmanagedPathReason(targetDir, relativePath) {
|
|
556
|
-
const normalized = normalizeRelativePath(relativePath);
|
|
557
|
-
if (UNMANAGED_USER_PATHS.has(normalized) || normalized.split("/")[0] === ".gitmodules") {
|
|
558
|
-
return ".gitmodules 是用户资产,不属于受管模板文件";
|
|
559
|
-
}
|
|
560
|
-
const absolutePath = path.resolve(targetDir, normalized);
|
|
561
|
-
const roots = collectGitRoots(targetDir);
|
|
562
|
-
for (const root of roots) {
|
|
563
|
-
if (path.resolve(root) === PACKAGE_ROOT) {
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
let current = absolutePath;
|
|
567
|
-
while (true) {
|
|
568
|
-
if (isGitSubmoduleMount(root, current)) {
|
|
569
|
-
return "gitlink / apps 挂载工作树是用户资产,不属于受管模板文件";
|
|
570
|
-
}
|
|
571
|
-
if (path.resolve(current) === path.resolve(root)) {
|
|
572
|
-
break;
|
|
573
|
-
}
|
|
574
|
-
const parent = path.dirname(current);
|
|
575
|
-
if (parent === current) {
|
|
576
|
-
break;
|
|
577
|
-
}
|
|
578
|
-
current = parent;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function normalizeRelativePath(relativePath) {
|
|
585
|
-
return relativePath.split(path.sep).join("/");
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function targetPath(targetDir, relativePath) {
|
|
589
|
-
const normalized = normalizeRelativePath(relativePath);
|
|
590
|
-
const resolved = path.resolve(targetDir, normalized);
|
|
591
|
-
const relative = path.relative(targetDir, resolved);
|
|
592
|
-
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
593
|
-
throw new Error(`模板路径越界:${relativePath}`);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (pathKind(targetDir) === "directory") {
|
|
597
|
-
let current = targetDir;
|
|
598
|
-
const segments = relative.split(path.sep).filter(Boolean);
|
|
599
|
-
segments.forEach((segment, index) => {
|
|
600
|
-
current = path.join(current, segment);
|
|
601
|
-
const kind = pathKind(current);
|
|
602
|
-
if (kind === "missing") {
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
const stat = fs.lstatSync(current);
|
|
606
|
-
if (stat.isSymbolicLink() && index < segments.length - 1) {
|
|
607
|
-
throw new Error(
|
|
608
|
-
`模板目标路径包含中间符号链接,无法安全写入:${relativePath}`,
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
if (index < segments.length - 1 && kind !== "directory") {
|
|
612
|
-
throw new Error(`模板目标父路径不是目录:${relativePath}`);
|
|
613
|
-
}
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
return resolved;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function shouldExcludeRelativePath(relativePath, mode = "managed") {
|
|
620
|
-
const normalized = normalizeRelativePath(relativePath);
|
|
621
|
-
if ([...ALLOWED_RELATIVE_PATHS].some(
|
|
622
|
-
(allowedPath) => allowedPath === normalized || allowedPath.startsWith(`${normalized}/`),
|
|
623
|
-
)) {
|
|
624
|
-
return false;
|
|
625
|
-
}
|
|
626
|
-
return EXCLUDED_RELATIVE_PATHS.has(normalized) ||
|
|
627
|
-
(mode === "init" &&
|
|
628
|
-
(INIT_EXCLUDED_RELATIVE_PATHS.has(normalized) ||
|
|
629
|
-
INIT_EXCLUDED_ROOT_FILES.has(normalized)));
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function shouldSkipRootEntry(entryName, mode = "managed") {
|
|
633
|
-
return ROOT_EXCLUDED_ENTRIES.has(entryName) ||
|
|
634
|
-
ROOT_EXCLUDED_FILES.has(entryName) ||
|
|
635
|
-
(mode === "init" &&
|
|
636
|
-
(INIT_EXCLUDED_ROOT_ENTRIES.has(entryName) ||
|
|
637
|
-
INIT_EXCLUDED_ROOT_FILES.has(entryName)));
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function parseRepositoryIdentity(content) {
|
|
641
|
-
const fields = {};
|
|
642
|
-
|
|
643
|
-
for (const [index, line] of content.split(/\r?\n/).entries()) {
|
|
644
|
-
const trimmed = line.trim();
|
|
645
|
-
if (!trimmed || trimmed.startsWith("#")) {
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
const match = line.match(/^([a-z_][a-z0-9_-]*):\s*([^\s#]+)\s*$/);
|
|
649
|
-
if (!match) {
|
|
650
|
-
throw new Error(`yss-project.yaml 第 ${index + 1} 行格式非法`);
|
|
651
|
-
}
|
|
652
|
-
const [, key, value] = match;
|
|
653
|
-
if (fields[key] !== undefined) {
|
|
654
|
-
throw new Error(`yss-project.yaml 不允许重复字段:${key}`);
|
|
655
|
-
}
|
|
656
|
-
fields[key] = value;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
const keys = Object.keys(fields).sort();
|
|
660
|
-
if (keys.join(",") !== "repository_mode,schema_version") {
|
|
661
|
-
throw new Error("yss-project.yaml 只能包含 schema_version 和 repository_mode");
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (fields.schema_version !== "1") {
|
|
665
|
-
throw new Error("yss-project.yaml 的 schema_version 必须为 1");
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
if (![
|
|
669
|
-
"template-source",
|
|
670
|
-
"project-instance",
|
|
671
|
-
].includes(fields.repository_mode)) {
|
|
672
|
-
throw new Error(
|
|
673
|
-
"yss-project.yaml 的 repository_mode 必须是 template-source 或 project-instance",
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
return fields;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function readTargetIdentity(targetDir) {
|
|
681
|
-
const identityPath = targetPath(targetDir, "yss-project.yaml");
|
|
682
|
-
const identityKind = pathKind(identityPath);
|
|
683
|
-
if (identityKind === "missing") {
|
|
684
|
-
return { state: "missing", path: identityPath, content: null };
|
|
685
|
-
}
|
|
686
|
-
if (identityKind !== "file") {
|
|
687
|
-
throw new Error("yss-project.yaml 不能是符号链接或其他特殊文件");
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const content = fs.readFileSync(identityPath, "utf8");
|
|
691
|
-
return {
|
|
692
|
-
state: "valid",
|
|
693
|
-
path: identityPath,
|
|
694
|
-
content,
|
|
695
|
-
fields: parseRepositoryIdentity(content),
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function renderTemplateFile(relativePath, content, variables) {
|
|
700
|
-
if (relativePath === "yss-project.yaml") {
|
|
701
|
-
const identity = parseRepositoryIdentity(content);
|
|
702
|
-
if (identity.repository_mode !== "template-source") {
|
|
703
|
-
throw new Error(
|
|
704
|
-
"模板 yss-project.yaml 必须声明 repository_mode: template-source",
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const renderedContent = content.replace(
|
|
709
|
-
/^repository_mode:\s*template-source$/m,
|
|
710
|
-
"repository_mode: project-instance",
|
|
711
|
-
);
|
|
712
|
-
const renderedIdentity = parseRepositoryIdentity(renderedContent);
|
|
713
|
-
if (renderedIdentity.repository_mode !== "project-instance") {
|
|
714
|
-
throw new Error("生成项目 yss-project.yaml 未转换为 project-instance");
|
|
715
|
-
}
|
|
716
|
-
return renderedContent;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if (relativePath === "AGENTS.md") {
|
|
720
|
-
return content
|
|
721
|
-
.replace(/(\*\*项目名称:\*\*\s*)\[填写\]/, (_, prefix) => `${prefix}${variables.projectName}`)
|
|
722
|
-
.replace(/(\*\*业务领域:\*\*\s*)\[填写\]/, (_, prefix) => `${prefix}${variables.businessDomain}`)
|
|
723
|
-
.replace(/(\*\*团队规模:\*\*\s*)\[填写\]/, (_, prefix) => `${prefix}${variables.teamSize}`);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
if (relativePath === "README.md") {
|
|
727
|
-
const renderedContent = content
|
|
728
|
-
.replace(/^# YSS Spec Project Template/m, `# ${variables.projectName}`)
|
|
729
|
-
.replace(
|
|
730
|
-
/^> Matt Pocock Engineering Skills/m,
|
|
731
|
-
`> 默认 Issue Tracker:${variables.issueTracker}\n>\n> Matt Pocock Engineering Skills`,
|
|
732
|
-
);
|
|
733
|
-
|
|
734
|
-
if (!variables.includeExampleDocs) {
|
|
735
|
-
return renderedContent.replace(
|
|
736
|
-
/^\| \[docs\/discovery\/IDEATION\.md\]\(\.\/docs\/discovery\/IDEATION\.md\) \|.*\r?\n/m,
|
|
737
|
-
"",
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
return renderedContent;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
return content;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function buildCopyPlan(
|
|
748
|
-
sourceDir,
|
|
749
|
-
targetDir,
|
|
750
|
-
variables,
|
|
751
|
-
relativeDir = "",
|
|
752
|
-
mode = "managed",
|
|
753
|
-
) {
|
|
754
|
-
const operations = [];
|
|
755
|
-
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
756
|
-
|
|
757
|
-
for (const entry of entries) {
|
|
758
|
-
if (!relativeDir && shouldSkipRootEntry(entry.name, mode)) {
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const bundledRelativePath = relativeDir
|
|
763
|
-
? path.posix.join(relativeDir, entry.name)
|
|
764
|
-
: entry.name;
|
|
765
|
-
const relativePath = logicalTemplatePath(bundledRelativePath);
|
|
766
|
-
|
|
767
|
-
if (shouldExcludeRelativePath(relativePath, mode)) {
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (!variables.includeExampleDocs && EXAMPLE_DOC_PATHS.has(relativePath)) {
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const sourcePath = path.join(sourceDir, entry.name);
|
|
776
|
-
const targetPathValue = targetPath(targetDir, relativePath);
|
|
777
|
-
|
|
778
|
-
if (entry.isDirectory()) {
|
|
779
|
-
operations.push({ type: "mkdir", relativePath, targetPath: targetPathValue });
|
|
780
|
-
operations.push(
|
|
781
|
-
...buildCopyPlan(
|
|
782
|
-
sourcePath,
|
|
783
|
-
targetDir,
|
|
784
|
-
variables,
|
|
785
|
-
bundledRelativePath,
|
|
786
|
-
mode,
|
|
787
|
-
),
|
|
788
|
-
);
|
|
789
|
-
continue;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
if (!entry.isFile()) {
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
operations.push({
|
|
797
|
-
type: RENDERED_RELATIVE_PATHS.has(relativePath) ? "render" : "copy",
|
|
798
|
-
relativePath,
|
|
799
|
-
sourcePath,
|
|
800
|
-
targetPath: targetPathValue,
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
return operations;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function fileHash(filePath) {
|
|
808
|
-
return sha256(fs.readFileSync(filePath));
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
function pathKind(absolutePath) {
|
|
812
|
-
let stat;
|
|
813
|
-
try {
|
|
814
|
-
stat = fs.lstatSync(absolutePath);
|
|
815
|
-
} catch (error) {
|
|
816
|
-
if (error.code === "ENOENT") {
|
|
817
|
-
return "missing";
|
|
818
|
-
}
|
|
819
|
-
throw error;
|
|
820
|
-
}
|
|
821
|
-
if (stat.isDirectory()) {
|
|
822
|
-
return "directory";
|
|
823
|
-
}
|
|
824
|
-
if (stat.isFile()) {
|
|
825
|
-
return "file";
|
|
826
|
-
}
|
|
827
|
-
return "other";
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function pathContentHash(absolutePath) {
|
|
831
|
-
const kind = pathKind(absolutePath);
|
|
832
|
-
if (kind === "file") {
|
|
833
|
-
return fileHash(absolutePath);
|
|
834
|
-
}
|
|
835
|
-
if (kind === "directory") {
|
|
836
|
-
return treeHash(absolutePath);
|
|
837
|
-
}
|
|
838
|
-
if (kind === "missing") {
|
|
839
|
-
return null;
|
|
840
|
-
}
|
|
841
|
-
return sha256(`${kind}:${fs.realpathSync(absolutePath)}`);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function pathsEqual(leftPath, rightPath) {
|
|
845
|
-
const leftKind = pathKind(leftPath);
|
|
846
|
-
const rightKind = pathKind(rightPath);
|
|
847
|
-
return leftKind === rightKind &&
|
|
848
|
-
leftKind !== "missing" &&
|
|
849
|
-
pathContentHash(leftPath) === pathContentHash(rightPath);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function buildDesiredManagedFile(operation, metadata) {
|
|
853
|
-
const variables = buildSyncVariables(metadata);
|
|
854
|
-
if (operation.type === "render") {
|
|
855
|
-
const desiredContent = renderTemplateFile(
|
|
856
|
-
operation.relativePath,
|
|
857
|
-
fs.readFileSync(operation.sourcePath, "utf8"),
|
|
858
|
-
variables,
|
|
859
|
-
);
|
|
860
|
-
return {
|
|
861
|
-
...operation,
|
|
862
|
-
desiredContent,
|
|
863
|
-
desiredHash: sha256(desiredContent),
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
return {
|
|
868
|
-
...operation,
|
|
869
|
-
desiredContent: fs.readFileSync(operation.sourcePath),
|
|
870
|
-
desiredHash: fileHash(operation.sourcePath),
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function buildSyncVariables(metadata) {
|
|
875
|
-
const variables = metadata.variables || {};
|
|
876
|
-
return {
|
|
877
|
-
projectName: variables.projectName || "待补充",
|
|
878
|
-
businessDomain: variables.businessDomain || "待补充",
|
|
879
|
-
teamSize: variables.teamSize || "待补充",
|
|
880
|
-
issueTracker: variables.issueTracker || "github",
|
|
881
|
-
includeExampleDocs:
|
|
882
|
-
variables.includeExampleDocs === undefined
|
|
883
|
-
? true
|
|
884
|
-
: Boolean(variables.includeExampleDocs),
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
function buildDesiredManagedOperations(targetDir, variables, mode = "managed") {
|
|
889
|
-
return buildCopyPlan(BUNDLED_TEMPLATE_ROOT, targetDir, variables, "", mode)
|
|
890
|
-
.filter((operation) => operation.type === "copy" || operation.type === "render")
|
|
891
|
-
.map((operation) => buildDesiredManagedFile(operation, { variables }));
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function buildSyncDesiredOperations(targetDir, variables, identity) {
|
|
895
|
-
return buildDesiredManagedOperations(targetDir, variables, "init").map((operation) => {
|
|
896
|
-
if (
|
|
897
|
-
operation.relativePath !== "yss-project.yaml" ||
|
|
898
|
-
identity.state !== "valid" ||
|
|
899
|
-
identity.fields.repository_mode !== "template-source"
|
|
900
|
-
) {
|
|
901
|
-
return operation;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
const desiredContent = identity.content.replace(
|
|
905
|
-
/^repository_mode:\s*template-source$/m,
|
|
906
|
-
"repository_mode: project-instance",
|
|
907
|
-
);
|
|
908
|
-
parseRepositoryIdentity(desiredContent);
|
|
909
|
-
return {
|
|
910
|
-
...operation,
|
|
911
|
-
desiredContent,
|
|
912
|
-
desiredHash: sha256(desiredContent),
|
|
913
|
-
identityConversion: true,
|
|
914
|
-
identitySourceHash: sha256(identity.content),
|
|
915
|
-
};
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
function buildAttachDesiredOperations(targetDir, variables, identity) {
|
|
920
|
-
return buildDesiredManagedOperations(targetDir, variables).map((operation) => {
|
|
921
|
-
if (operation.relativePath !== "yss-project.yaml" || identity.state === "missing") {
|
|
922
|
-
return operation;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
if (identity.fields.repository_mode === "project-instance") {
|
|
926
|
-
return {
|
|
927
|
-
...operation,
|
|
928
|
-
desiredContent: identity.content,
|
|
929
|
-
desiredHash: sha256(identity.content),
|
|
930
|
-
identityPreserved: true,
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const desiredContent = identity.content.replace(
|
|
935
|
-
/^repository_mode:\s*template-source$/m,
|
|
936
|
-
"repository_mode: project-instance",
|
|
937
|
-
);
|
|
938
|
-
parseRepositoryIdentity(desiredContent);
|
|
939
|
-
return {
|
|
940
|
-
...operation,
|
|
941
|
-
desiredContent,
|
|
942
|
-
desiredHash: sha256(desiredContent),
|
|
943
|
-
identityConversion: true,
|
|
944
|
-
};
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
function managedPathPrefix(relativePath, desiredPathSet) {
|
|
949
|
-
return desiredPathSet.has(relativePath) ||
|
|
950
|
-
[...desiredPathSet].some((candidate) => candidate.startsWith(`${relativePath}/`));
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function addMigrationDestination(from, to, reason, desiredPathSet, plan) {
|
|
954
|
-
const fromPath = from;
|
|
955
|
-
const toPath = to;
|
|
956
|
-
const source = targetPath(plan.targetDir, fromPath);
|
|
957
|
-
const destination = targetPath(plan.targetDir, toPath);
|
|
958
|
-
if (pathKind(source) === "missing") {
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
if ([pathKind(source), pathKind(destination)].includes("other")) {
|
|
963
|
-
plan.unsafe.push({
|
|
964
|
-
path: fromPath,
|
|
965
|
-
reason: `${reason}:源或目标路径是符号链接或其他不可安全判断的类型`,
|
|
966
|
-
});
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
if (managedPathPrefix(toPath, desiredPathSet)) {
|
|
971
|
-
plan.operations.push({ kind: "remove", path: fromPath, reason });
|
|
972
|
-
plan.legacy.push({ action: "replace-with-template", from: fromPath, to: toPath, reason });
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
if (!fs.existsSync(destination)) {
|
|
977
|
-
plan.operations.push({ kind: "move", from: fromPath, to: toPath, reason });
|
|
978
|
-
plan.legacy.push({ action: "move", from: fromPath, to: toPath, reason });
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
if (pathsEqual(source, destination)) {
|
|
983
|
-
plan.operations.push({ kind: "remove", path: fromPath, reason: `${reason}(新旧内容一致)` });
|
|
984
|
-
plan.legacy.push({ action: "remove-duplicate", from: fromPath, to: toPath, reason });
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
plan.conflicts.push({ from: fromPath, to: toPath, reason: `${reason}:迁移目标已存在且内容不一致` });
|
|
989
|
-
plan.legacy.push({ action: "conflict", from: fromPath, to: toPath, reason });
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function addDirectoryMigration(from, to, reason, desiredPathSet, plan) {
|
|
993
|
-
const source = targetPath(plan.targetDir, from);
|
|
994
|
-
const destination = targetPath(plan.targetDir, to);
|
|
995
|
-
const sourceKind = pathKind(source);
|
|
996
|
-
const destinationKind = pathKind(destination);
|
|
997
|
-
|
|
998
|
-
if (sourceKind === "missing") {
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
if (sourceKind !== "directory" || destinationKind === "other") {
|
|
1002
|
-
plan.unsafe.push({
|
|
1003
|
-
path: from,
|
|
1004
|
-
reason: `${reason}:源或目标路径不是可安全遍历的目录`,
|
|
1005
|
-
});
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
if (destinationKind === "file") {
|
|
1009
|
-
plan.conflicts.push({
|
|
1010
|
-
from,
|
|
1011
|
-
to,
|
|
1012
|
-
reason: `${reason}:迁移目标是文件而不是目录`,
|
|
1013
|
-
});
|
|
1014
|
-
plan.legacy.push({ action: "conflict", from, to, reason });
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
const entries = fs.readdirSync(source).filter((entry) => entry !== ".DS_Store");
|
|
1019
|
-
if (entries.length === 0) {
|
|
1020
|
-
plan.operations.push({ kind: "remove", path: from, reason });
|
|
1021
|
-
plan.legacy.push({ action: "remove-empty", from, to, reason });
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
for (const entry of entries) {
|
|
1026
|
-
const childFrom = `${from}/${entry}`;
|
|
1027
|
-
const childTo = `${to}/${entry}`;
|
|
1028
|
-
if (pathKind(targetPath(plan.targetDir, childFrom)) === "directory") {
|
|
1029
|
-
addDirectoryMigration(childFrom, childTo, reason, desiredPathSet, plan);
|
|
1030
|
-
} else {
|
|
1031
|
-
addMigrationDestination(childFrom, childTo, reason, desiredPathSet, plan);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
plan.operations.push({ kind: "remove", path: from, reason });
|
|
1036
|
-
plan.legacy.push({ action: "remove-migrated-directory", from, to, reason });
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
function buildLegacyMigrationPlan(
|
|
1040
|
-
targetDir,
|
|
1041
|
-
desiredOperations,
|
|
1042
|
-
{ checkFlatTickets = true } = {},
|
|
1043
|
-
) {
|
|
1044
|
-
const desiredPathSet = new Set(desiredOperations.map((operation) => operation.relativePath));
|
|
1045
|
-
const plan = {
|
|
1046
|
-
targetDir,
|
|
1047
|
-
operations: [],
|
|
1048
|
-
legacy: [],
|
|
1049
|
-
conflicts: [],
|
|
1050
|
-
unsafe: [],
|
|
1051
|
-
};
|
|
1052
|
-
|
|
1053
|
-
for (const [oldName, newName] of LEGACY_SKILL_MAPPINGS) {
|
|
1054
|
-
for (const agentRoot of AGENT_SKILL_ROOTS) {
|
|
1055
|
-
addMigrationDestination(
|
|
1056
|
-
`${agentRoot}/${oldName}`,
|
|
1057
|
-
`${agentRoot}/${newName}`,
|
|
1058
|
-
`旧 skill ${oldName} 迁移为 ${newName}`,
|
|
1059
|
-
desiredPathSet,
|
|
1060
|
-
plan,
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
for (const [oldPath, newPath] of LEGACY_FILE_MAPPINGS) {
|
|
1066
|
-
addMigrationDestination(oldPath, newPath, "旧模板路径迁移", desiredPathSet, plan);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
addDirectoryMigration(
|
|
1070
|
-
"docs/requirements/issues",
|
|
1071
|
-
"docs/requirements/tickets",
|
|
1072
|
-
"旧 Ticket 目录迁移",
|
|
1073
|
-
desiredPathSet,
|
|
1074
|
-
plan,
|
|
1075
|
-
);
|
|
1076
|
-
|
|
1077
|
-
const requirementsPath = targetPath(targetDir, "docs/requirements");
|
|
1078
|
-
if (fs.existsSync(requirementsPath) && pathKind(requirementsPath) === "directory") {
|
|
1079
|
-
for (const entry of fs.readdirSync(requirementsPath, { withFileTypes: true })) {
|
|
1080
|
-
if (entry.isFile() && entry.name.endsWith("-prd.md")) {
|
|
1081
|
-
const oldPath = `docs/requirements/${entry.name}`;
|
|
1082
|
-
const newPath = `docs/requirements/${entry.name.replace(/-prd\.md$/, "-spec.md")}`;
|
|
1083
|
-
addMigrationDestination(oldPath, newPath, "旧规格文件名迁移", desiredPathSet, plan);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
const legacyScratch = targetPath(targetDir, ".scratch");
|
|
1089
|
-
if (fs.existsSync(legacyScratch) && pathKind(legacyScratch) === "directory") {
|
|
1090
|
-
for (const entry of fs.readdirSync(legacyScratch, { withFileTypes: true })) {
|
|
1091
|
-
if (entry.name === ".DS_Store") {
|
|
1092
|
-
continue;
|
|
1093
|
-
}
|
|
1094
|
-
const oldPath = `.scratch/${entry.name}`;
|
|
1095
|
-
const newPath = `docs/.scratch/${entry.name}`;
|
|
1096
|
-
addMigrationDestination(oldPath, newPath, "根 scratch 目录迁移", desiredPathSet, plan);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
for (const legacyDirectory of checkFlatTickets ? ["docs/requirements/tickets"] : []) {
|
|
1101
|
-
const directoryPath = targetPath(targetDir, legacyDirectory);
|
|
1102
|
-
if (!fs.existsSync(directoryPath) || pathKind(directoryPath) !== "directory") {
|
|
1103
|
-
continue;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
|
1107
|
-
const unsafeEntries = entries.filter(
|
|
1108
|
-
(entry) => entry.name !== ".gitkeep" && entry.name !== ".DS_Store",
|
|
1109
|
-
);
|
|
1110
|
-
if (unsafeEntries.length > 0) {
|
|
1111
|
-
for (const entry of unsafeEntries) {
|
|
1112
|
-
plan.unsafe.push({
|
|
1113
|
-
path: `${legacyDirectory}/${entry.name}`,
|
|
1114
|
-
reason: "扁平 Ticket 无法可靠推断功能归属,不能自动迁移",
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
if (desiredPathSet.has(`${legacyDirectory}/.gitkeep`)) {
|
|
1121
|
-
continue;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
plan.operations.push({
|
|
1125
|
-
kind: "remove",
|
|
1126
|
-
path: legacyDirectory,
|
|
1127
|
-
reason: "删除空的旧 Ticket 目录",
|
|
1128
|
-
});
|
|
1129
|
-
plan.legacy.push({ action: "remove-empty", from: legacyDirectory, to: null, reason: "旧 Ticket 目录为空" });
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
return plan;
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
function classifyAttachPlan(targetDir, variables) {
|
|
1136
|
-
const identity = readTargetIdentity(targetDir);
|
|
1137
|
-
const desiredOperations = buildAttachDesiredOperations(targetDir, variables, identity);
|
|
1138
|
-
const plan = {
|
|
1139
|
-
targetDir,
|
|
1140
|
-
identityInfo: identity,
|
|
1141
|
-
desiredOperations,
|
|
1142
|
-
missing: [],
|
|
1143
|
-
matched: [],
|
|
1144
|
-
identity: [],
|
|
1145
|
-
conflicts: [],
|
|
1146
|
-
unsafe: [],
|
|
1147
|
-
migration: buildLegacyMigrationPlan(targetDir, desiredOperations),
|
|
1148
|
-
};
|
|
1149
|
-
|
|
1150
|
-
for (const operation of desiredOperations) {
|
|
1151
|
-
const unmanagedReason = unmanagedPathReason(targetDir, operation.relativePath);
|
|
1152
|
-
if (unmanagedReason) {
|
|
1153
|
-
plan.unsafe.push({
|
|
1154
|
-
...operation,
|
|
1155
|
-
reason: unmanagedReason,
|
|
1156
|
-
});
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
|
-
const kind = pathKind(operation.targetPath);
|
|
1160
|
-
if (kind === "missing") {
|
|
1161
|
-
plan.missing.push(operation);
|
|
1162
|
-
continue;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
if (kind !== "file") {
|
|
1166
|
-
plan.unsafe.push({
|
|
1167
|
-
...operation,
|
|
1168
|
-
reason: `目标路径类型为 ${kind},无法安全判断是否可以替换`,
|
|
1169
|
-
});
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const currentHash = fileHash(operation.targetPath);
|
|
1174
|
-
if (currentHash === operation.desiredHash) {
|
|
1175
|
-
plan.matched.push(operation);
|
|
1176
|
-
} else if (operation.identityConversion) {
|
|
1177
|
-
plan.identity.push(operation);
|
|
1178
|
-
} else {
|
|
1179
|
-
plan.conflicts.push({
|
|
1180
|
-
...operation,
|
|
1181
|
-
reason: "目标受管文件已存在且内容不一致",
|
|
1182
|
-
});
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
return plan;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
function classifySyncPlan(targetDir, metadata, identity = readTargetIdentity(targetDir)) {
|
|
1190
|
-
const managedFiles = metadata.managedFiles || {};
|
|
1191
|
-
const variables = buildSyncVariables(metadata);
|
|
1192
|
-
const desiredOperations = buildSyncDesiredOperations(targetDir, variables, identity);
|
|
1193
|
-
const updated = [];
|
|
1194
|
-
const added = [];
|
|
1195
|
-
const unchanged = [];
|
|
1196
|
-
const skipped = [];
|
|
1197
|
-
const conflicts = [];
|
|
1198
|
-
const forceableConflicts = [];
|
|
1199
|
-
const unsafe = [];
|
|
1200
|
-
|
|
1201
|
-
for (const operation of desiredOperations) {
|
|
1202
|
-
const unmanagedReason = unmanagedPathReason(targetDir, operation.relativePath);
|
|
1203
|
-
if (unmanagedReason) {
|
|
1204
|
-
unsafe.push({
|
|
1205
|
-
...operation,
|
|
1206
|
-
reason: unmanagedReason,
|
|
1207
|
-
});
|
|
1208
|
-
continue;
|
|
1209
|
-
}
|
|
1210
|
-
const existingRecord = managedFiles[operation.relativePath];
|
|
1211
|
-
const existingKind = pathKind(operation.targetPath);
|
|
1212
|
-
|
|
1213
|
-
if (existingKind === "missing") {
|
|
1214
|
-
added.push(operation);
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
if (existingKind !== "file") {
|
|
1219
|
-
unsafe.push({
|
|
1220
|
-
...operation,
|
|
1221
|
-
reason: `目标路径类型为 ${existingKind},无法安全判断是否可以替换`,
|
|
1222
|
-
});
|
|
1223
|
-
continue;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
const currentHash = fileHash(operation.targetPath);
|
|
1227
|
-
if (
|
|
1228
|
-
operation.identityConversion &&
|
|
1229
|
-
currentHash === operation.identitySourceHash
|
|
1230
|
-
) {
|
|
1231
|
-
updated.push(operation);
|
|
1232
|
-
continue;
|
|
1233
|
-
}
|
|
1234
|
-
if (!existingRecord) {
|
|
1235
|
-
if (currentHash === operation.desiredHash) {
|
|
1236
|
-
unchanged.push(operation);
|
|
1237
|
-
} else {
|
|
1238
|
-
const conflict = {
|
|
1239
|
-
...operation,
|
|
1240
|
-
reason: "文件已存在,但不在受管模板文件基线中",
|
|
1241
|
-
};
|
|
1242
|
-
conflicts.push(conflict);
|
|
1243
|
-
skipped.push(conflict);
|
|
1244
|
-
}
|
|
1245
|
-
continue;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
if (currentHash === operation.desiredHash) {
|
|
1249
|
-
unchanged.push(operation);
|
|
1250
|
-
continue;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
if (currentHash !== existingRecord.contentHash) {
|
|
1254
|
-
const conflict = {
|
|
1255
|
-
...operation,
|
|
1256
|
-
reason: "检测到本地已修改的受管文件",
|
|
1257
|
-
};
|
|
1258
|
-
conflicts.push(conflict);
|
|
1259
|
-
forceableConflicts.push(conflict);
|
|
1260
|
-
skipped.push(conflict);
|
|
1261
|
-
continue;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
updated.push(operation);
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
const desiredPathSet = new Set(desiredOperations.map((operation) => operation.relativePath));
|
|
1268
|
-
const removed = Object.keys(managedFiles).filter((relativePath) => !desiredPathSet.has(relativePath));
|
|
1269
|
-
|
|
1270
|
-
return {
|
|
1271
|
-
updated,
|
|
1272
|
-
added,
|
|
1273
|
-
unchanged,
|
|
1274
|
-
skipped,
|
|
1275
|
-
conflicts,
|
|
1276
|
-
forceableConflicts,
|
|
1277
|
-
unsafe,
|
|
1278
|
-
removed,
|
|
1279
|
-
desiredOperations,
|
|
1280
|
-
};
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function collectManagedFiles(desiredOperations, targetDir) {
|
|
1284
|
-
const managedFiles = {};
|
|
1285
|
-
for (const operation of desiredOperations) {
|
|
1286
|
-
if (pathKind(operation.targetPath) !== "file") {
|
|
1287
|
-
continue;
|
|
1288
|
-
}
|
|
1289
|
-
managedFiles[operation.relativePath] = {
|
|
1290
|
-
type: operation.type,
|
|
1291
|
-
contentHash: fileHash(operation.targetPath),
|
|
1292
|
-
};
|
|
1293
|
-
}
|
|
1294
|
-
return managedFiles;
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
function buildMetadata(variables, desiredOperations, targetDir, timestamp = nowIsoString()) {
|
|
1298
|
-
return {
|
|
1299
|
-
metadataSchemaVersion: METADATA_SCHEMA_VERSION,
|
|
1300
|
-
templateName: PACKAGE_MANIFEST.name,
|
|
1301
|
-
cliVersion: PACKAGE_MANIFEST.version,
|
|
1302
|
-
templateVersion: PACKAGE_MANIFEST.version,
|
|
1303
|
-
templateSource: getTemplateSource(),
|
|
1304
|
-
templateCommit: readTemplateSnapshot().templateCommit,
|
|
1305
|
-
initializedAt: timestamp,
|
|
1306
|
-
lastSyncedAt: timestamp,
|
|
1307
|
-
managedFilesManifestVersion: TEMPLATE_MANIFEST_VERSION,
|
|
1308
|
-
variables: {
|
|
1309
|
-
projectName: variables.projectName,
|
|
1310
|
-
businessDomain: variables.businessDomain,
|
|
1311
|
-
teamSize: variables.teamSize,
|
|
1312
|
-
issueTracker: variables.issueTracker,
|
|
1313
|
-
includeExampleDocs: variables.includeExampleDocs,
|
|
1314
|
-
},
|
|
1315
|
-
managedFiles: collectManagedFiles(desiredOperations, targetDir),
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
function writeTemplateMetadata(targetDir, metadata, transaction = null) {
|
|
1320
|
-
const metadataPath = targetPath(targetDir, TEMPLATE_METADATA_FILENAME);
|
|
1321
|
-
const content = `${JSON.stringify(metadata, null, 2)}\n`;
|
|
1322
|
-
if (transaction) {
|
|
1323
|
-
transaction.writeFile(metadataPath, content);
|
|
1324
|
-
return;
|
|
1325
|
-
}
|
|
1326
|
-
fs.writeFileSync(metadataPath, content, "utf8");
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
function inspectTargetDir(targetDir, force) {
|
|
1330
|
-
if (isInsideTemplateRoot(targetDir)) {
|
|
1331
|
-
throw new Error("目标目录不能位于模板源仓库内部");
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
assertTargetWorkingTreeWritable(targetDir, { force });
|
|
1335
|
-
|
|
1336
|
-
if (!fs.existsSync(targetDir)) {
|
|
1337
|
-
return { exists: false, clearEntries: false };
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
if (pathKind(targetDir) !== "directory") {
|
|
1341
|
-
throw new Error("目标目录必须是目录");
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
const entries = fs.readdirSync(targetDir);
|
|
1345
|
-
if (entries.length > 0 && !force) {
|
|
1346
|
-
throw new Error("目标目录非空,当前主路径不支持覆盖已有内容");
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
return { exists: true, clearEntries: entries.length > 0 && force };
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
function inspectExistingTargetDir(targetDir, { force = false } = {}) {
|
|
1353
|
-
if (isInsideTemplateRoot(targetDir)) {
|
|
1354
|
-
throw new Error("目标目录不能位于模板源仓库内部");
|
|
1355
|
-
}
|
|
1356
|
-
if (!fs.existsSync(targetDir) || pathKind(targetDir) !== "directory") {
|
|
1357
|
-
throw new Error("attach 目标目录必须是已经存在的项目目录");
|
|
1358
|
-
}
|
|
1359
|
-
assertTargetWorkingTreeWritable(targetDir, { force });
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
function prepareTargetDir(targetDir, targetState) {
|
|
1363
|
-
if (!targetState.exists) {
|
|
1364
|
-
fs.mkdirSync(targetDir, { recursive: true });
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
if (!targetState.clearEntries) {
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
for (const entry of fs.readdirSync(targetDir)) {
|
|
1373
|
-
fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
function writeFileWithMode(targetFile, content, mode = null) {
|
|
1378
|
-
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
|
1379
|
-
fs.writeFileSync(targetFile, content);
|
|
1380
|
-
if (mode !== null) {
|
|
1381
|
-
fs.chmodSync(targetFile, mode & 0o777);
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
function executePlan(operations, variables) {
|
|
1386
|
-
for (const operation of operations) {
|
|
1387
|
-
if (operation.type === "mkdir") {
|
|
1388
|
-
fs.mkdirSync(operation.targetPath, { recursive: true });
|
|
1389
|
-
continue;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
if (operation.type === "render") {
|
|
1393
|
-
const renderedContent = renderTemplateFile(
|
|
1394
|
-
operation.relativePath,
|
|
1395
|
-
fs.readFileSync(operation.sourcePath, "utf8"),
|
|
1396
|
-
variables,
|
|
1397
|
-
);
|
|
1398
|
-
writeFileWithMode(
|
|
1399
|
-
operation.targetPath,
|
|
1400
|
-
renderedContent,
|
|
1401
|
-
fs.statSync(operation.sourcePath).mode,
|
|
1402
|
-
);
|
|
1403
|
-
continue;
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
fs.mkdirSync(path.dirname(operation.targetPath), { recursive: true });
|
|
1407
|
-
fs.copyFileSync(operation.sourcePath, operation.targetPath);
|
|
1408
|
-
fs.chmodSync(operation.targetPath, fs.statSync(operation.sourcePath).mode & 0o777);
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
function initializeGitRepository(targetDir) {
|
|
1413
|
-
const result = spawnSync("git", ["init"], {
|
|
1414
|
-
cwd: targetDir,
|
|
1415
|
-
encoding: "utf8",
|
|
1416
|
-
});
|
|
1417
|
-
if (result.status !== 0) {
|
|
1418
|
-
throw new Error(result.stderr.trim() || "git init 执行失败");
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
function runTemplateVerification(targetDir, scriptPath, args = ["--check"]) {
|
|
1423
|
-
const commandPath = targetPath(targetDir, scriptPath);
|
|
1424
|
-
const result = spawnSync(commandPath, args, {
|
|
1425
|
-
cwd: targetDir,
|
|
1426
|
-
encoding: "utf8",
|
|
1427
|
-
});
|
|
1428
|
-
const output = [result.stdout, result.stderr].filter(Boolean).join("");
|
|
1429
|
-
if (output) {
|
|
1430
|
-
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
|
|
1431
|
-
}
|
|
1432
|
-
if (result.status !== 0) {
|
|
1433
|
-
const detail = result.error?.message || output.trim();
|
|
1434
|
-
throw new Error(`生成项目校验失败:${scriptPath}${detail ? `\n${detail}` : ""}`);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
function runTemplateVerificationWithGit(targetDir, scriptPath, args = []) {
|
|
1439
|
-
const gitPath = targetPath(targetDir, ".git");
|
|
1440
|
-
const probe = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
1441
|
-
cwd: targetDir,
|
|
1442
|
-
encoding: "utf8",
|
|
1443
|
-
});
|
|
1444
|
-
if (probe.status === 0 && probe.stdout.trim() === "true") {
|
|
1445
|
-
runTemplateVerification(targetDir, scriptPath, args);
|
|
1446
|
-
return;
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
let backupRoot;
|
|
1450
|
-
let backupGitPath;
|
|
1451
|
-
try {
|
|
1452
|
-
try {
|
|
1453
|
-
fs.lstatSync(gitPath);
|
|
1454
|
-
backupRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-yss-spec-git-backup-"));
|
|
1455
|
-
backupGitPath = path.join(backupRoot, ".git");
|
|
1456
|
-
fs.renameSync(gitPath, backupGitPath);
|
|
1457
|
-
} catch (error) {
|
|
1458
|
-
if (error.code !== "ENOENT") throw error;
|
|
1459
|
-
}
|
|
1460
|
-
initializeGitRepository(targetDir);
|
|
1461
|
-
runTemplateVerification(targetDir, scriptPath, args);
|
|
1462
|
-
} finally {
|
|
1463
|
-
fs.rmSync(gitPath, { recursive: true, force: true });
|
|
1464
|
-
if (backupGitPath) {
|
|
1465
|
-
fs.renameSync(backupGitPath, gitPath);
|
|
1466
|
-
}
|
|
1467
|
-
if (backupRoot) {
|
|
1468
|
-
fs.rmSync(backupRoot, { recursive: true, force: true });
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
function verifyGeneratedTemplate(targetDir, mode = "managed") {
|
|
1474
|
-
if (mode === "init" || mode === "sync") {
|
|
1475
|
-
verifyGeneratedInstance(targetDir, { checkForbiddenPaths: mode === "init" });
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
runTemplateVerification(targetDir, "scripts/sync-skills");
|
|
1480
|
-
runTemplateVerification(targetDir, "scripts/update-skill-lock");
|
|
1481
|
-
runTemplateVerificationWithGit(targetDir, "scripts/verify-template");
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
function verifyGeneratedInstance(targetDir, { checkForbiddenPaths = true } = {}) {
|
|
1485
|
-
if (checkForbiddenPaths) {
|
|
1486
|
-
const forbiddenPaths = [
|
|
1487
|
-
...INSTANCE_FORBIDDEN_PATHS,
|
|
1488
|
-
...[...INIT_EXCLUDED_ROOT_ENTRIES],
|
|
1489
|
-
...[...INIT_EXCLUDED_ROOT_FILES],
|
|
1490
|
-
...[...INIT_EXCLUDED_RELATIVE_PATHS],
|
|
1491
|
-
];
|
|
1492
|
-
for (const relativePath of [...new Set(forbiddenPaths)]) {
|
|
1493
|
-
if (pathKind(targetPath(targetDir, relativePath)) !== "missing") {
|
|
1494
|
-
throw new Error(`初始化结果包含禁止分发的模板源资产:${relativePath}`);
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
const identity = readTargetIdentity(targetDir);
|
|
1500
|
-
if (
|
|
1501
|
-
identity.state !== "valid" ||
|
|
1502
|
-
identity.fields.repository_mode !== "project-instance"
|
|
1503
|
-
) {
|
|
1504
|
-
throw new Error("初始化结果的 yss-project.yaml 必须是 project-instance");
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
const agentsContent = fs.readFileSync(targetPath(targetDir, "AGENTS.md"), "utf8");
|
|
1508
|
-
const readmeContent = fs.readFileSync(targetPath(targetDir, "README.md"), "utf8");
|
|
1509
|
-
if (agentsContent.includes("[填写]") || readmeContent.includes("[填写]")) {
|
|
1510
|
-
throw new Error("初始化结果仍包含模板占位信息");
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
function loadTemplateMetadata(targetDir) {
|
|
1515
|
-
const metadataPath = targetPath(targetDir, TEMPLATE_METADATA_FILENAME);
|
|
1516
|
-
const metadataKind = pathKind(metadataPath);
|
|
1517
|
-
if (metadataKind === "missing") {
|
|
1518
|
-
throw new Error(
|
|
1519
|
-
`当前目录不是受支持的模板实例仓库,缺少模板元数据文件 ${TEMPLATE_METADATA_FILENAME};请先使用 attach`,
|
|
1520
|
-
);
|
|
1521
|
-
}
|
|
1522
|
-
if (metadataKind !== "file") {
|
|
1523
|
-
throw new Error("模板元数据不能是符号链接或其他特殊文件");
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
let metadata;
|
|
1527
|
-
try {
|
|
1528
|
-
metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
1529
|
-
} catch (error) {
|
|
1530
|
-
throw new Error(`模板元数据无法解析:${error.message}`);
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
1534
|
-
throw new Error("模板元数据必须是 JSON 对象");
|
|
1535
|
-
}
|
|
1536
|
-
if (
|
|
1537
|
-
metadata.metadataSchemaVersion !== undefined &&
|
|
1538
|
-
(!Number.isInteger(metadata.metadataSchemaVersion) ||
|
|
1539
|
-
metadata.metadataSchemaVersion < 1)
|
|
1540
|
-
) {
|
|
1541
|
-
throw new Error("模板元数据 metadataSchemaVersion 必须是正整数");
|
|
1542
|
-
}
|
|
1543
|
-
if (metadata.metadataSchemaVersion > METADATA_SCHEMA_VERSION) {
|
|
1544
|
-
throw new Error(`不支持的模板元数据版本:${metadata.metadataSchemaVersion}`);
|
|
1545
|
-
}
|
|
1546
|
-
if (
|
|
1547
|
-
metadata.managedFiles !== undefined &&
|
|
1548
|
-
(typeof metadata.managedFiles !== "object" ||
|
|
1549
|
-
metadata.managedFiles === null ||
|
|
1550
|
-
Array.isArray(metadata.managedFiles))
|
|
1551
|
-
) {
|
|
1552
|
-
throw new Error("模板元数据 managedFiles 必须是 JSON 对象");
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
if (metadata.metadataSchemaVersion === METADATA_SCHEMA_VERSION) {
|
|
1556
|
-
if (metadata.templateName !== PACKAGE_MANIFEST.name) {
|
|
1557
|
-
throw new Error("模板元数据 templateName 与当前 CLI 不匹配");
|
|
1558
|
-
}
|
|
1559
|
-
if (typeof metadata.cliVersion !== "string" || !metadata.cliVersion) {
|
|
1560
|
-
throw new Error("模板元数据 cliVersion 缺失或非法");
|
|
1561
|
-
}
|
|
1562
|
-
if (metadata.templateSource !== TEMPLATE_SOURCE) {
|
|
1563
|
-
throw new Error("模板元数据 templateSource 缺失或非法");
|
|
1564
|
-
}
|
|
1565
|
-
if (!/^[0-9a-f]{40}$/.test(metadata.templateCommit || "")) {
|
|
1566
|
-
throw new Error("模板元数据必须包含 40 位不可变 templateCommit");
|
|
1567
|
-
}
|
|
1568
|
-
if (!/^[0-9a-f]{64}$/.test(metadata.managedFilesManifestVersion || "")) {
|
|
1569
|
-
throw new Error("模板元数据 managedFilesManifestVersion 缺失或非法");
|
|
1570
|
-
}
|
|
1571
|
-
if (
|
|
1572
|
-
typeof metadata.variables !== "object" ||
|
|
1573
|
-
metadata.variables === null ||
|
|
1574
|
-
Array.isArray(metadata.variables)
|
|
1575
|
-
) {
|
|
1576
|
-
throw new Error("模板元数据 variables 必须是 JSON 对象");
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
return { metadataPath, metadata };
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
function gitDirtyWarning(targetDir) {
|
|
1584
|
-
const probe = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
1585
|
-
cwd: targetDir,
|
|
1586
|
-
encoding: "utf8",
|
|
1587
|
-
timeout: 5000,
|
|
1588
|
-
});
|
|
1589
|
-
if (probe.status !== 0 || probe.stdout.trim() !== "true") {
|
|
1590
|
-
return null;
|
|
1591
|
-
}
|
|
1592
|
-
const status = spawnSync("git", ["status", "--porcelain"], {
|
|
1593
|
-
cwd: targetDir,
|
|
1594
|
-
encoding: "utf8",
|
|
1595
|
-
timeout: 5000,
|
|
1596
|
-
});
|
|
1597
|
-
if (status.status === 0 && status.stdout.trim()) {
|
|
1598
|
-
return "警告:目标 Git worktree 存在未提交改动;CLI 不会自动 stash 或提交,请在结果后检查 git diff / git status";
|
|
1599
|
-
}
|
|
1600
|
-
return null;
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
function shellQuote(value) {
|
|
1604
|
-
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
function copyPath(sourcePath, destinationPath) {
|
|
1608
|
-
const kind = pathKind(sourcePath);
|
|
1609
|
-
if (kind === "other") {
|
|
1610
|
-
throw new Error(`拒绝复制符号链接或特殊文件:${sourcePath}`);
|
|
1611
|
-
}
|
|
1612
|
-
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
1613
|
-
if (kind === "directory") {
|
|
1614
|
-
fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
|
|
1615
|
-
return;
|
|
1616
|
-
}
|
|
1617
|
-
fs.copyFileSync(sourcePath, destinationPath);
|
|
1618
|
-
if (kind === "file") {
|
|
1619
|
-
fs.chmodSync(destinationPath, fs.statSync(sourcePath).mode & 0o777);
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
class Transaction {
|
|
1624
|
-
constructor(targetDir) {
|
|
1625
|
-
this.targetDir = targetDir;
|
|
1626
|
-
this.backupRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-yss-spec-backup-"));
|
|
1627
|
-
this.backups = [];
|
|
1628
|
-
this.mutated = [];
|
|
1629
|
-
this.createdDirectories = [];
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
relative(absolutePath) {
|
|
1633
|
-
return normalizeRelativePath(path.relative(this.targetDir, absolutePath));
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
backupPath(relativePath) {
|
|
1637
|
-
return path.join(this.backupRoot, relativePath);
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
prepare(relativePaths) {
|
|
1641
|
-
const candidates = [...new Set(relativePaths)]
|
|
1642
|
-
.map((relativePath) => normalizeRelativePath(relativePath))
|
|
1643
|
-
.filter((relativePath) => relativePath && pathKind(targetPath(this.targetDir, relativePath)) !== "missing")
|
|
1644
|
-
.sort((left, right) => left.split("/").length - right.split("/").length);
|
|
1645
|
-
|
|
1646
|
-
for (const relativePath of candidates) {
|
|
1647
|
-
if (this.backups.some((backup) =>
|
|
1648
|
-
relativePath === backup.relativePath || relativePath.startsWith(`${backup.relativePath}/`))) {
|
|
1649
|
-
continue;
|
|
1650
|
-
}
|
|
1651
|
-
const source = targetPath(this.targetDir, relativePath);
|
|
1652
|
-
const destination = this.backupPath(relativePath);
|
|
1653
|
-
copyPath(source, destination);
|
|
1654
|
-
this.backups.push({ relativePath, destination });
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
ensureParent(absolutePath) {
|
|
1659
|
-
const missing = [];
|
|
1660
|
-
let current = path.dirname(absolutePath);
|
|
1661
|
-
while (current !== this.targetDir && !fs.existsSync(current)) {
|
|
1662
|
-
missing.push(current);
|
|
1663
|
-
current = path.dirname(current);
|
|
1664
|
-
}
|
|
1665
|
-
if (current !== this.targetDir && pathKind(current) !== "directory") {
|
|
1666
|
-
throw new Error(`目标父路径不是目录:${this.relative(current)}`);
|
|
1667
|
-
}
|
|
1668
|
-
for (const directory of missing.reverse()) {
|
|
1669
|
-
fs.mkdirSync(directory);
|
|
1670
|
-
this.createdDirectories.push(this.relative(directory));
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
mark(absolutePath) {
|
|
1675
|
-
this.mutated.push(this.relative(absolutePath));
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
writeFile(absolutePath, content) {
|
|
1679
|
-
this.ensureParent(absolutePath);
|
|
1680
|
-
fs.writeFileSync(absolutePath, content, "utf8");
|
|
1681
|
-
this.mark(absolutePath);
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
copyFile(sourcePath, destinationPath) {
|
|
1685
|
-
this.ensureParent(destinationPath);
|
|
1686
|
-
copyPath(sourcePath, destinationPath);
|
|
1687
|
-
this.mark(destinationPath);
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
remove(absolutePath) {
|
|
1691
|
-
if (!fs.existsSync(absolutePath)) {
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
fs.rmSync(absolutePath, { recursive: true, force: true });
|
|
1695
|
-
this.mark(absolutePath);
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
move(sourcePath, destinationPath) {
|
|
1699
|
-
this.ensureParent(destinationPath);
|
|
1700
|
-
copyPath(sourcePath, destinationPath);
|
|
1701
|
-
this.mark(destinationPath);
|
|
1702
|
-
this.remove(sourcePath);
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
rollback() {
|
|
1706
|
-
for (const relativePath of [...new Set(this.mutated)].sort(
|
|
1707
|
-
(left, right) => right.split("/").length - left.split("/").length,
|
|
1708
|
-
)) {
|
|
1709
|
-
fs.rmSync(targetPath(this.targetDir, relativePath), { recursive: true, force: true });
|
|
1710
|
-
}
|
|
1711
|
-
for (const relativePath of [...new Set(this.createdDirectories)].sort(
|
|
1712
|
-
(left, right) => right.split("/").length - left.split("/").length,
|
|
1713
|
-
)) {
|
|
1714
|
-
const directory = targetPath(this.targetDir, relativePath);
|
|
1715
|
-
if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) {
|
|
1716
|
-
fs.rmdirSync(directory);
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
for (const backup of this.backups.sort(
|
|
1720
|
-
(left, right) => left.relativePath.split("/").length - right.relativePath.split("/").length,
|
|
1721
|
-
)) {
|
|
1722
|
-
copyPath(backup.destination, targetPath(this.targetDir, backup.relativePath));
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
finish() {
|
|
1727
|
-
if (this.backups.length === 0) {
|
|
1728
|
-
fs.rmSync(this.backupRoot, { recursive: true, force: true });
|
|
1729
|
-
return null;
|
|
1730
|
-
}
|
|
1731
|
-
return this.backupRoot;
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
function applyMigrationPlan(migrationPlan, transaction) {
|
|
1736
|
-
for (const operation of migrationPlan.operations) {
|
|
1737
|
-
if (operation.kind === "remove") {
|
|
1738
|
-
transaction.remove(targetPath(migrationPlan.targetDir, operation.path));
|
|
1739
|
-
} else if (operation.kind === "move") {
|
|
1740
|
-
transaction.move(
|
|
1741
|
-
targetPath(migrationPlan.targetDir, operation.from),
|
|
1742
|
-
targetPath(migrationPlan.targetDir, operation.to),
|
|
1743
|
-
);
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
function applyManagedOperation(operation, transaction) {
|
|
1749
|
-
if (operation.type === "render") {
|
|
1750
|
-
transaction.ensureParent(operation.targetPath);
|
|
1751
|
-
writeFileWithMode(
|
|
1752
|
-
operation.targetPath,
|
|
1753
|
-
operation.desiredContent,
|
|
1754
|
-
fs.statSync(operation.sourcePath).mode,
|
|
1755
|
-
);
|
|
1756
|
-
transaction.mark(operation.targetPath);
|
|
1757
|
-
return;
|
|
1758
|
-
}
|
|
1759
|
-
transaction.copyFile(operation.sourcePath, operation.targetPath);
|
|
1760
|
-
fs.chmodSync(operation.targetPath, fs.statSync(operation.sourcePath).mode & 0o777);
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
function printMigrationPlan(migrationPlan) {
|
|
1764
|
-
for (const item of migrationPlan.legacy) {
|
|
1765
|
-
const destination = item.to ? ` -> ${item.to}` : "";
|
|
1766
|
-
console.log(`legacy: ${item.action} ${item.from}${destination}`);
|
|
1767
|
-
}
|
|
1768
|
-
for (const item of migrationPlan.unsafe) {
|
|
1769
|
-
console.log(`unsafe: ${item.path} (${item.reason})`);
|
|
1770
|
-
}
|
|
1771
|
-
for (const item of migrationPlan.conflicts) {
|
|
1772
|
-
console.log(`conflict: ${item.from} -> ${item.to} (${item.reason})`);
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
function printLimitedOperations(operations, formatter, limit = 40) {
|
|
1777
|
-
const prioritized = [...operations].sort((left, right) => {
|
|
1778
|
-
const leftPath = typeof left === "string" ? left : left.relativePath;
|
|
1779
|
-
const rightPath = typeof right === "string" ? right : right.relativePath;
|
|
1780
|
-
const leftIsRoot = !leftPath.includes("/");
|
|
1781
|
-
const rightIsRoot = !rightPath.includes("/");
|
|
1782
|
-
if (leftIsRoot !== rightIsRoot) {
|
|
1783
|
-
return leftIsRoot ? -1 : 1;
|
|
1784
|
-
}
|
|
1785
|
-
return leftPath.localeCompare(rightPath);
|
|
1786
|
-
});
|
|
1787
|
-
for (const operation of prioritized.slice(0, limit)) {
|
|
1788
|
-
console.log(formatter(operation));
|
|
1789
|
-
}
|
|
1790
|
-
if (operations.length > limit) {
|
|
1791
|
-
console.log(`... 其余 ${operations.length - limit} 项省略,可用 manifest / git diff 查看完整清单`);
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
function printAttachDryRun(plan, targetDir) {
|
|
1796
|
-
console.log("attach dry-run 预览");
|
|
1797
|
-
console.log(`目标目录:${targetDir}`);
|
|
1798
|
-
printLimitedOperations(plan.missing, (operation) => `add: ${operation.relativePath}`);
|
|
1799
|
-
printLimitedOperations(plan.identity, (operation) => `identity: ${operation.relativePath}(规范化为 project-instance)`);
|
|
1800
|
-
printLimitedOperations(plan.conflicts, (operation) => `conflict: ${operation.relativePath} (${operation.reason})`);
|
|
1801
|
-
printLimitedOperations(plan.unsafe, (operation) => `unsafe: ${operation.relativePath} (${operation.reason})`);
|
|
1802
|
-
if (plan.matched.length > 0) {
|
|
1803
|
-
console.log(`matched: ${plan.matched.length} 项内容一致,已纳入 managed baseline`);
|
|
1804
|
-
}
|
|
1805
|
-
printMigrationPlan(plan.migration);
|
|
1806
|
-
console.log(`统计:新增 ${plan.missing.length},一致 ${plan.matched.length},身份转换 ${plan.identity.length},冲突 ${plan.conflicts.length},unsafe ${plan.unsafe.length}`);
|
|
1807
|
-
if (plan.conflicts.length > 0) {
|
|
1808
|
-
console.log("提示:apply 需要显式传入 --force 才能覆盖冲突受管文件");
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
function printSyncDryRun(targetDir, metadata, syncPlan, migrationPlan) {
|
|
1813
|
-
console.log("sync dry-run 预览");
|
|
1814
|
-
console.log(`目标目录:${targetDir}`);
|
|
1815
|
-
console.log(
|
|
1816
|
-
`模板版本:${metadata.templateVersion || metadata.cliVersion || "unknown"} -> ${PACKAGE_MANIFEST.version}`,
|
|
1817
|
-
);
|
|
1818
|
-
printLimitedOperations(syncPlan.updated, (operation) => `update: ${operation.relativePath}`);
|
|
1819
|
-
printLimitedOperations(syncPlan.added, (operation) => `add: ${operation.relativePath}`);
|
|
1820
|
-
printLimitedOperations(syncPlan.skipped, (operation) => `conflict: ${operation.relativePath} (${operation.reason})`);
|
|
1821
|
-
printLimitedOperations(syncPlan.unsafe, (operation) => `unsafe: ${operation.relativePath} (${operation.reason})`);
|
|
1822
|
-
printLimitedOperations(syncPlan.removed, (relativePath) => `remove-report: ${relativePath}`);
|
|
1823
|
-
console.log(`unchanged: ${syncPlan.unchanged.length}`);
|
|
1824
|
-
printMigrationPlan(migrationPlan);
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
function buildNextSyncMetadata(metadata, syncPlan, targetDir) {
|
|
1828
|
-
const nextManagedFiles = { ...(metadata.managedFiles || {}) };
|
|
1829
|
-
for (const relativePath of syncPlan.removed) {
|
|
1830
|
-
delete nextManagedFiles[relativePath];
|
|
1831
|
-
}
|
|
1832
|
-
for (const operation of syncPlan.desiredOperations) {
|
|
1833
|
-
if (pathKind(operation.targetPath) !== "file") {
|
|
1834
|
-
continue;
|
|
1835
|
-
}
|
|
1836
|
-
const currentHash = fileHash(operation.targetPath);
|
|
1837
|
-
if (currentHash === operation.desiredHash) {
|
|
1838
|
-
nextManagedFiles[operation.relativePath] = {
|
|
1839
|
-
type: operation.type,
|
|
1840
|
-
contentHash: operation.desiredHash,
|
|
1841
|
-
};
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
return {
|
|
1846
|
-
...metadata,
|
|
1847
|
-
metadataSchemaVersion: METADATA_SCHEMA_VERSION,
|
|
1848
|
-
templateName: PACKAGE_MANIFEST.name,
|
|
1849
|
-
cliVersion: PACKAGE_MANIFEST.version,
|
|
1850
|
-
templateVersion: PACKAGE_MANIFEST.version,
|
|
1851
|
-
templateSource: getTemplateSource(),
|
|
1852
|
-
templateCommit: readTemplateSnapshot().templateCommit,
|
|
1853
|
-
lastSyncedAt: nowIsoString(),
|
|
1854
|
-
managedFilesManifestVersion: TEMPLATE_MANIFEST_VERSION,
|
|
1855
|
-
managedFiles: nextManagedFiles,
|
|
1856
|
-
};
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
function affectedPathsForManagedOperations(operations) {
|
|
1860
|
-
return operations.map((operation) => normalizeRelativePath(operation.relativePath));
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
function affectedPathsForMigration(migrationPlan) {
|
|
1864
|
-
return migrationPlan.operations.flatMap((operation) =>
|
|
1865
|
-
operation.kind === "move" ? [operation.from, operation.to] : [operation.path],
|
|
1866
|
-
);
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
function attachVariables(options) {
|
|
1870
|
-
return {
|
|
1871
|
-
projectName: options.projectName,
|
|
1872
|
-
businessDomain: options.businessDomain,
|
|
1873
|
-
teamSize: options.teamSize || "待补充",
|
|
1874
|
-
issueTracker: options.issueTracker || "github",
|
|
1875
|
-
includeExampleDocs:
|
|
1876
|
-
options.includeExampleDocs === undefined
|
|
1877
|
-
? true
|
|
1878
|
-
: Boolean(options.includeExampleDocs),
|
|
1879
|
-
};
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
function runInit(argv = []) {
|
|
1883
|
-
const options = parseArgs(argv);
|
|
1884
|
-
if (options.help) {
|
|
1885
|
-
printHelp();
|
|
1886
|
-
return;
|
|
1887
|
-
}
|
|
1888
|
-
if (options.version) {
|
|
1889
|
-
printVersion();
|
|
1890
|
-
return;
|
|
1891
|
-
}
|
|
1892
|
-
return promptForMissingOptions(options).then((promptedOptions) => {
|
|
1893
|
-
assertRequiredOptions(promptedOptions, "init");
|
|
1894
|
-
readTemplateSnapshot();
|
|
1895
|
-
const targetDir = normalizeTargetDir(promptedOptions.targetDir);
|
|
1896
|
-
const targetState = inspectTargetDir(targetDir, promptedOptions.force);
|
|
1897
|
-
const operations = buildCopyPlan(
|
|
1898
|
-
BUNDLED_TEMPLATE_ROOT,
|
|
1899
|
-
targetDir,
|
|
1900
|
-
promptedOptions,
|
|
1901
|
-
"",
|
|
1902
|
-
"init",
|
|
1903
|
-
);
|
|
1904
|
-
|
|
1905
|
-
if (promptedOptions.dryRun) {
|
|
1906
|
-
console.log("dry-run 预览");
|
|
1907
|
-
console.log(`输出目录:${targetDir}`);
|
|
1908
|
-
for (const operation of operations) {
|
|
1909
|
-
console.log(`${operation.type}: ${operation.relativePath}`);
|
|
1910
|
-
}
|
|
1911
|
-
return;
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
prepareTargetDir(targetDir, targetState);
|
|
1915
|
-
executePlan(operations, promptedOptions);
|
|
1916
|
-
verifyGeneratedTemplate(targetDir, "init");
|
|
1917
|
-
writeTemplateMetadata(
|
|
1918
|
-
targetDir,
|
|
1919
|
-
buildMetadata(promptedOptions, operations.filter((operation) => operation.type !== "mkdir"), targetDir),
|
|
1920
|
-
);
|
|
1921
|
-
|
|
1922
|
-
if (promptedOptions.gitInit) {
|
|
1923
|
-
initializeGitRepository(targetDir);
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
console.log("初始化完成");
|
|
1927
|
-
console.log(`输出目录:${targetDir}`);
|
|
1928
|
-
console.log("下一步建议:");
|
|
1929
|
-
console.log(`1. cd ${targetDir}`);
|
|
1930
|
-
console.log(
|
|
1931
|
-
promptedOptions.gitInit
|
|
1932
|
-
? "2. 运行 git status 检查初始化结果"
|
|
1933
|
-
: "2. 如需版本管理,可执行 git init",
|
|
1934
|
-
);
|
|
1935
|
-
console.log("3. 检查 AGENTS.md、README 和 docs 目录是否符合预期");
|
|
1936
|
-
});
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
function runAttach(argv = []) {
|
|
1940
|
-
const options = parseArgs(argv);
|
|
1941
|
-
if (options.help) {
|
|
1942
|
-
printHelp();
|
|
1943
|
-
return;
|
|
1944
|
-
}
|
|
1945
|
-
if (options.version) {
|
|
1946
|
-
printVersion();
|
|
1947
|
-
return;
|
|
1948
|
-
}
|
|
1949
|
-
assertRequiredOptions(options, "attach");
|
|
1950
|
-
if (options.dryRun && options.apply) {
|
|
1951
|
-
throw new Error("attach 的 --dry-run 与 --apply 互斥");
|
|
1952
|
-
}
|
|
1953
|
-
if (!options.dryRun && !options.apply) {
|
|
1954
|
-
throw new Error("attach 必须显式传入 --dry-run 或 --apply");
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
readTemplateSnapshot();
|
|
1958
|
-
const targetDir = normalizeTargetDir(options.targetDir);
|
|
1959
|
-
inspectExistingTargetDir(targetDir, { force: Boolean(options.force) });
|
|
1960
|
-
if (pathKind(targetPath(targetDir, TEMPLATE_METADATA_FILENAME)) !== "missing") {
|
|
1961
|
-
throw new Error("当前项目已有模板元数据,请使用 sync,不要重复 attach");
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
const variables = attachVariables(options);
|
|
1965
|
-
const plan = classifyAttachPlan(targetDir, variables);
|
|
1966
|
-
const warning = gitDirtyWarning(targetDir);
|
|
1967
|
-
if (warning) {
|
|
1968
|
-
console.log(warning);
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
if (options.dryRun) {
|
|
1972
|
-
printAttachDryRun(plan, targetDir);
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
if (plan.migration.unsafe.length > 0) {
|
|
1977
|
-
printMigrationPlan(plan.migration);
|
|
1978
|
-
throw new Error("attach 被 unsafe 迁移项阻断;--force 不能绕过,请先人工整理 Ticket 归属");
|
|
1979
|
-
}
|
|
1980
|
-
if (plan.unsafe.length > 0) {
|
|
1981
|
-
printAttachDryRun(plan, targetDir);
|
|
1982
|
-
throw new Error("attach 被 unsafe 受管路径阻断;--force 不能绕过,请先人工整理目标路径");
|
|
1983
|
-
}
|
|
1984
|
-
if (plan.migration.conflicts.length > 0) {
|
|
1985
|
-
printMigrationPlan(plan.migration);
|
|
1986
|
-
throw new Error("attach 被旧路径迁移冲突阻断,请先处理目标冲突");
|
|
1987
|
-
}
|
|
1988
|
-
if (plan.conflicts.length > 0 && !options.force) {
|
|
1989
|
-
printAttachDryRun(plan, targetDir);
|
|
1990
|
-
throw new Error("attach 检测到受管文件冲突;请先 dry-run,再使用 --apply --force");
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
const managedToApply = [
|
|
1994
|
-
...plan.missing,
|
|
1995
|
-
...plan.identity,
|
|
1996
|
-
...(options.force ? plan.conflicts : []),
|
|
1997
|
-
];
|
|
1998
|
-
const transaction = new Transaction(targetDir);
|
|
1999
|
-
|
|
2000
|
-
try {
|
|
2001
|
-
transaction.prepare([
|
|
2002
|
-
...affectedPathsForManagedOperations(managedToApply),
|
|
2003
|
-
...affectedPathsForMigration(plan.migration),
|
|
2004
|
-
TEMPLATE_METADATA_FILENAME,
|
|
2005
|
-
]);
|
|
2006
|
-
for (const operation of managedToApply) {
|
|
2007
|
-
applyManagedOperation(operation, transaction);
|
|
2008
|
-
}
|
|
2009
|
-
applyMigrationPlan(plan.migration, transaction);
|
|
2010
|
-
verifyGeneratedTemplate(targetDir);
|
|
2011
|
-
writeTemplateMetadata(
|
|
2012
|
-
targetDir,
|
|
2013
|
-
buildMetadata(
|
|
2014
|
-
variables,
|
|
2015
|
-
plan.desiredOperations,
|
|
2016
|
-
targetDir,
|
|
2017
|
-
),
|
|
2018
|
-
transaction,
|
|
2019
|
-
);
|
|
2020
|
-
} catch (error) {
|
|
2021
|
-
try {
|
|
2022
|
-
transaction.rollback();
|
|
2023
|
-
} catch (rollbackError) {
|
|
2024
|
-
throw new Error(`${error.message}\n回滚失败:${rollbackError.message}`);
|
|
2025
|
-
}
|
|
2026
|
-
throw new Error(`${error.message}\n已回滚本次 attach;临时备份保留于 ${transaction.backupRoot}`);
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
const backupPath = transaction.finish();
|
|
2030
|
-
console.log("接管完成");
|
|
2031
|
-
console.log(`目标目录:${targetDir}`);
|
|
2032
|
-
console.log(`新增研发管理资产:${plan.missing.length + plan.identity.length}`);
|
|
2033
|
-
if (plan.conflicts.length > 0) {
|
|
2034
|
-
console.log(`force 覆盖冲突:${plan.conflicts.length}`);
|
|
2035
|
-
}
|
|
2036
|
-
if (backupPath) {
|
|
2037
|
-
console.log(`备份目录:${backupPath}`);
|
|
2038
|
-
console.log(`清理命令:rm -rf ${shellQuote(backupPath)}`);
|
|
2039
|
-
}
|
|
2040
|
-
console.log("下一步建议:运行 git diff 或 git status 检查接管结果");
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
function runSync(argv = []) {
|
|
2044
|
-
const options = parseArgs(argv);
|
|
2045
|
-
if (options.help) {
|
|
2046
|
-
printHelp();
|
|
2047
|
-
return;
|
|
2048
|
-
}
|
|
2049
|
-
if (options.version) {
|
|
2050
|
-
printVersion();
|
|
2051
|
-
return;
|
|
2052
|
-
}
|
|
2053
|
-
readTemplateSnapshot();
|
|
2054
|
-
const targetDir = normalizeTargetDir(options.targetDir || ".");
|
|
2055
|
-
inspectExistingTargetDir(targetDir, { force: Boolean(options.force) });
|
|
2056
|
-
const { metadata } = loadTemplateMetadata(targetDir);
|
|
2057
|
-
const identity = readTargetIdentity(targetDir);
|
|
2058
|
-
const syncPlan = classifySyncPlan(targetDir, metadata, identity);
|
|
2059
|
-
const migrationPlan = buildLegacyMigrationPlan(
|
|
2060
|
-
targetDir,
|
|
2061
|
-
syncPlan.desiredOperations,
|
|
2062
|
-
{ checkFlatTickets: false },
|
|
2063
|
-
);
|
|
2064
|
-
const warning = gitDirtyWarning(targetDir);
|
|
2065
|
-
if (warning) {
|
|
2066
|
-
console.log(warning);
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
if (options.dryRun) {
|
|
2070
|
-
printSyncDryRun(targetDir, metadata, syncPlan, migrationPlan);
|
|
2071
|
-
return;
|
|
2072
|
-
}
|
|
2073
|
-
if (migrationPlan.unsafe.length > 0) {
|
|
2074
|
-
printMigrationPlan(migrationPlan);
|
|
2075
|
-
throw new Error("sync 被 unsafe 迁移项阻断;请先人工整理 Ticket 归属");
|
|
2076
|
-
}
|
|
2077
|
-
if (syncPlan.unsafe.length > 0) {
|
|
2078
|
-
printSyncDryRun(targetDir, metadata, syncPlan, migrationPlan);
|
|
2079
|
-
throw new Error("sync 被 unsafe 受管路径阻断;--force 不能绕过,请先人工整理目标路径");
|
|
2080
|
-
}
|
|
2081
|
-
if (migrationPlan.conflicts.length > 0) {
|
|
2082
|
-
printMigrationPlan(migrationPlan);
|
|
2083
|
-
throw new Error("sync 被旧路径迁移冲突阻断,请先处理目标冲突");
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
const managedToApply = [
|
|
2087
|
-
...syncPlan.updated,
|
|
2088
|
-
...syncPlan.added,
|
|
2089
|
-
...(options.force ? syncPlan.forceableConflicts : []),
|
|
2090
|
-
];
|
|
2091
|
-
const transaction = new Transaction(targetDir);
|
|
2092
|
-
|
|
2093
|
-
try {
|
|
2094
|
-
transaction.prepare([
|
|
2095
|
-
...affectedPathsForManagedOperations(managedToApply),
|
|
2096
|
-
...affectedPathsForMigration(migrationPlan),
|
|
2097
|
-
TEMPLATE_METADATA_FILENAME,
|
|
2098
|
-
]);
|
|
2099
|
-
for (const operation of managedToApply) {
|
|
2100
|
-
applyManagedOperation(operation, transaction);
|
|
2101
|
-
}
|
|
2102
|
-
applyMigrationPlan(migrationPlan, transaction);
|
|
2103
|
-
verifyGeneratedTemplate(targetDir, "sync");
|
|
2104
|
-
writeTemplateMetadata(
|
|
2105
|
-
targetDir,
|
|
2106
|
-
buildNextSyncMetadata(metadata, syncPlan, targetDir),
|
|
2107
|
-
transaction,
|
|
2108
|
-
);
|
|
2109
|
-
} catch (error) {
|
|
2110
|
-
try {
|
|
2111
|
-
transaction.rollback();
|
|
2112
|
-
} catch (rollbackError) {
|
|
2113
|
-
throw new Error(`${error.message}\n回滚失败:${rollbackError.message}`);
|
|
2114
|
-
}
|
|
2115
|
-
throw new Error(`${error.message}\n已回滚本次 sync;临时备份保留于 ${transaction.backupRoot}`);
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
const backupPath = transaction.finish();
|
|
2119
|
-
console.log("同步完成");
|
|
2120
|
-
console.log(`模板版本:${metadata.templateVersion || metadata.cliVersion || "unknown"} -> ${PACKAGE_MANIFEST.version}`);
|
|
2121
|
-
console.log(`自动更新:${syncPlan.updated.length}`);
|
|
2122
|
-
console.log(`新增文件:${syncPlan.added.length}`);
|
|
2123
|
-
console.log(`跳过文件:${syncPlan.skipped.length}`);
|
|
2124
|
-
console.log(`删除差异:${syncPlan.removed.length}`);
|
|
2125
|
-
if (syncPlan.skipped.length > 0) {
|
|
2126
|
-
console.log("本地已修改,已跳过:");
|
|
2127
|
-
for (const operation of syncPlan.skipped) {
|
|
2128
|
-
console.log(`- ${operation.relativePath}: ${operation.reason}`);
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
if (syncPlan.removed.length > 0) {
|
|
2132
|
-
console.log("模板已移除但未自动删除:");
|
|
2133
|
-
for (const relativePath of syncPlan.removed) {
|
|
2134
|
-
console.log(`- ${relativePath}`);
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
if (backupPath) {
|
|
2138
|
-
console.log(`备份目录:${backupPath}`);
|
|
2139
|
-
console.log(`清理命令:rm -rf ${shellQuote(backupPath)}`);
|
|
2140
|
-
}
|
|
2141
|
-
console.log("下一步建议:");
|
|
2142
|
-
console.log("1. 运行 git diff 或 git status 检查同步结果");
|
|
2143
|
-
console.log("2. 人工处理被跳过文件和删除差异(如有)");
|
|
2144
|
-
console.log("3. 确认无误后提交本次模板同步结果");
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
function argvIncludesFlag(argv, flags) {
|
|
2148
|
-
return argv.some((arg) => flags.has(arg));
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
function printVersion() {
|
|
2152
|
-
console.log(`create-yss-spec ${PACKAGE_MANIFEST.version}`);
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
function printHelp() {
|
|
2156
|
-
console.log(`create-yss-spec ${PACKAGE_MANIFEST.version}
|
|
2157
|
-
|
|
2158
|
-
USAGE
|
|
2159
|
-
$ create-yss-spec [COMMAND] [OPTIONS]
|
|
2160
|
-
|
|
2161
|
-
COMMANDS
|
|
2162
|
-
(default) 初始化新的模板实例仓库
|
|
2163
|
-
attach 向已有项目补齐受管研发管理资产
|
|
2164
|
-
sync 同步已有模板实例的受管资产
|
|
2165
|
-
update 检查 npm 最新版本,如有更新则安装
|
|
2166
|
-
upgrade update 的别名
|
|
2167
|
-
|
|
2168
|
-
OPTIONS
|
|
2169
|
-
--project-name <name> 项目名称;init 不传则进入交互输入
|
|
2170
|
-
--business-domain <domain> 业务领域;init 不传则进入交互输入
|
|
2171
|
-
--team-size <size> 团队规模;init 不传则可留空,默认「待补充」
|
|
2172
|
-
--target-dir <dir> 目标目录;init 不传则进入交互输入,sync 默认为当前目录
|
|
2173
|
-
--issue-tracker github|gitlab 默认 issue tracker 偏好(默认 github)
|
|
2174
|
-
--dry-run 只预览计划,不写入文件;update 只查询不安装
|
|
2175
|
-
--apply attach 确认执行写入;不能与 --dry-run 同时使用
|
|
2176
|
-
--force init:允许清空非空目录后重新生成
|
|
2177
|
-
attach / sync:覆盖受管冲突文件;unsafe 路径始终阻断
|
|
2178
|
-
update:即使已是最新也重新安装;npx / 源码目录仍不覆盖
|
|
2179
|
-
--git-init 初始化完成后执行 git init
|
|
2180
|
-
--include-example-docs 显式保留示例文档(默认开启)
|
|
2181
|
-
--no-example-docs 不生成示例文档
|
|
2182
|
-
-h, --help 显示本帮助信息
|
|
2183
|
-
-v, --version 显示 CLI 版本
|
|
2184
|
-
|
|
2185
|
-
EXAMPLES
|
|
2186
|
-
$ npm create yss-spec@latest
|
|
2187
|
-
$ npx create-yss-spec@latest --help
|
|
2188
|
-
$ npx create-yss-spec@latest --version
|
|
2189
|
-
$ npx create-yss-spec@latest \\
|
|
2190
|
-
--project-name "Acme Spec Repo" \\
|
|
2191
|
-
--business-domain "Investment Research" \\
|
|
2192
|
-
--team-size "12" \\
|
|
2193
|
-
--target-dir "./acme-spec-repo" \\
|
|
2194
|
-
--issue-tracker github \\
|
|
2195
|
-
--git-init
|
|
2196
|
-
$ npx create-yss-spec@latest \\
|
|
2197
|
-
--project-name "Preview Repo" \\
|
|
2198
|
-
--business-domain "Data Platform" \\
|
|
2199
|
-
--target-dir "./preview-repo" \\
|
|
2200
|
-
--dry-run
|
|
2201
|
-
$ npx create-yss-spec@latest attach \\
|
|
2202
|
-
--target-dir . \\
|
|
2203
|
-
--project-name "Acme Application" \\
|
|
2204
|
-
--business-domain "Data Platform" \\
|
|
2205
|
-
--dry-run
|
|
2206
|
-
$ npx create-yss-spec@latest attach \\
|
|
2207
|
-
--target-dir . \\
|
|
2208
|
-
--project-name "Acme Application" \\
|
|
2209
|
-
--business-domain "Data Platform" \\
|
|
2210
|
-
--apply
|
|
2211
|
-
$ npx create-yss-spec@latest sync
|
|
2212
|
-
$ npx create-yss-spec@latest sync --dry-run
|
|
2213
|
-
$ npx create-yss-spec@latest sync --target-dir . --force
|
|
2214
|
-
$ npx create-yss-spec update
|
|
2215
|
-
$ npx create-yss-spec update --dry-run
|
|
2216
|
-
$ npx create-yss-spec upgrade
|
|
2217
|
-
|
|
2218
|
-
LEARN MORE
|
|
2219
|
-
仓库 README:https://github.com/iloveZzz/create-yss-spec#readme
|
|
2220
|
-
使用手册:https://github.com/iloveZzz/create-yss-spec/blob/main/docs/user-guide/create-yss-spec-cli-guide.md
|
|
2221
|
-
模板仓库:https://github.com/iloveZzz/yss-spec-project-template
|
|
2222
|
-
`);
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
async function runCli(argv = []) {
|
|
2226
|
-
if (argvIncludesFlag(argv, HELP_FLAGS)) {
|
|
2227
|
-
printHelp();
|
|
2228
|
-
return;
|
|
2229
|
-
}
|
|
2230
|
-
if (argvIncludesFlag(argv, VERSION_FLAGS)) {
|
|
2231
|
-
printVersion();
|
|
2232
|
-
return;
|
|
2233
|
-
}
|
|
2234
|
-
if (argv[0] === "sync") {
|
|
2235
|
-
runSync(argv.slice(1));
|
|
2236
|
-
return;
|
|
2237
|
-
}
|
|
2238
|
-
if (argv[0] === "attach") {
|
|
2239
|
-
runAttach(argv.slice(1));
|
|
2240
|
-
return;
|
|
2241
|
-
}
|
|
2242
|
-
if (UPDATE_COMMANDS.has(argv[0])) {
|
|
2243
|
-
runUpdate(argv.slice(1), {
|
|
2244
|
-
packageRoot: PACKAGE_ROOT,
|
|
2245
|
-
currentVersion: PACKAGE_MANIFEST.version,
|
|
2246
|
-
});
|
|
2247
|
-
return;
|
|
2248
|
-
}
|
|
2249
|
-
await runInit(argv);
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
module.exports = {
|
|
2253
|
-
runCli,
|
|
2254
|
-
};
|
|
3
|
+
// Compatibility bridge for callers that still import `src/cli.js` directly.
|
|
4
|
+
// Production command execution lives under `src/cli/`, `src/commands/`,
|
|
5
|
+
// `src/template/`, `src/filesystem/`, `src/git/`, and `src/validation/`.
|
|
6
|
+
module.exports = require("./cli/index.js");
|