project-auto-wizard 0.1.33 → 0.2.0
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 +5 -7
- package/package.json +1 -1
- package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
- package/src/cli/args.js +2 -2
- package/src/cli/help.js +4 -4
- package/src/commands/dry-run.js +8 -20
- package/src/commands/full.js +16 -1
- package/src/commands/interactive.js +30 -26
- package/src/commands/purge.js +8 -6
- package/src/commands/status.js +21 -1
- package/src/commands/uninstall.js +13 -9
- package/src/context.js +4 -2
- package/src/core/baseline.js +76 -0
- package/src/core/copy/workflows.js +158 -73
- package/src/{commands/revert.js → core/removal-plan.js} +16 -17
- package/src/index.js +7 -31
- package/src/ui/prompts.js +1 -4
- package/src/commands/version.js +0 -37
- package/src/commands/workflows.js +0 -52
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ npx project-auto-wizard
|
|
|
17
17
|
[](package.json)
|
|
18
18
|
|
|
19
19
|
<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
|
|
20
|
-
## 최신 버전 : v0.1.
|
|
20
|
+
## 최신 버전 : v0.1.34 (2026-08-10)
|
|
21
21
|
|
|
22
22
|
[전체 버전 기록 보기](CHANGELOG.md)
|
|
23
23
|
|
|
@@ -72,13 +72,11 @@ flutter.APP_ARTIFACT_NAME:
|
|
|
72
72
|
- **react/next**: CI와 CI+CD 분리 구성
|
|
73
73
|
- **python**: CI / PR 프리뷰 / SimpleCICD
|
|
74
74
|
|
|
75
|
-
### 되돌리기(`--mode revert`)
|
|
76
|
-
|
|
77
|
-
`npx project-auto-wizard --mode revert --force`는 payload가 설치한 파일명과 **정확히 일치하는 것만** 제거합니다. 사용자가 직접 만든 워크플로우, `version.yml`, `README.md`, `.gitignore`는 건드리지 않습니다. 설치 시 충돌 처리로 생성된 `.bak`/`.template.yaml` 파생 파일도 함께 정리됩니다.
|
|
78
|
-
|
|
79
75
|
### 완전 삭제(`--mode uninstall`)
|
|
80
76
|
|
|
81
|
-
`npx project-auto-wizard --mode uninstall`은
|
|
77
|
+
`npx project-auto-wizard --mode uninstall`은 마법사가 설치한 것을 제거합니다 — 워크플로우·스크립트는 물론, README.md의 `AUTO-VERSION-SECTION` 버전 섹션과 `.gitignore`에 자동 추가된 항목, `version.yml`까지 선택적으로 제거할 수 있습니다.
|
|
78
|
+
|
|
79
|
+
제거 대상은 payload가 설치한 파일명과 정확히 일치하는 것, 그리고 마법사 관리 마커를 가진 파일뿐입니다. 사용자가 직접 만든 워크플로우는 건드리지 않습니다. 설치 시 충돌 처리로 생성된 `.bak`/`.template.yaml` 파생 파일도 함께 정리됩니다.
|
|
82
80
|
|
|
83
81
|
- **대화형(TTY)**: 실제로 설치된 항목만 체크리스트로 보여줍니다. 워크플로우·스크립트는 기본 체크, README·`.gitignore`·`version.yml`은 opt-in입니다. 선택 후 최종 확인(기본 "아니오")을 거쳐야 실제로 삭제됩니다.
|
|
84
82
|
- **비대화형(`--force`)**: 워크플로우·스크립트만 기본 삭제합니다. README·`.gitignore`·`version.yml`까지 지우려면 `--purge-readme`/`--purge-gitignore`/`--purge-version`을 함께 지정하세요.
|
|
@@ -129,7 +127,7 @@ flowchart LR
|
|
|
129
127
|
```
|
|
130
128
|
npx project-auto-wizard [옵션]
|
|
131
129
|
|
|
132
|
-
-m, --mode MODE full |
|
|
130
|
+
-m, --mode MODE full | uninstall | status | doctor (기본: 대화형)
|
|
133
131
|
-t, --type CSV spring,react,... (미지정 시 자동 감지)
|
|
134
132
|
--project-version V 초기 버전 (미지정 시 자동 감지)
|
|
135
133
|
--paths "t=p,..." 모노레포 타입별 경로
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
package/src/cli/args.js
CHANGED
|
@@ -17,7 +17,7 @@ export function parseArgs(argv) {
|
|
|
17
17
|
force: false,
|
|
18
18
|
help: false,
|
|
19
19
|
showVersion: false, // -v/--version → 패키지 버전 출력 (npm 관례)
|
|
20
|
-
dryRun: false, // --dry-run: 실제 변경 없이 미리보기만 (full/
|
|
20
|
+
dryRun: false, // --dry-run: 실제 변경 없이 미리보기만 (full/uninstall)
|
|
21
21
|
purgeReadme: false, // --purge-readme: uninstall --force 시 README 버전 섹션도 제거
|
|
22
22
|
purgeGitignore: false, // --purge-gitignore: uninstall --force 시 .gitignore 자동 추가 항목도 제거
|
|
23
23
|
purgeVersion: false, // --purge-version: uninstall --force 시 version.yml도 제거
|
|
@@ -111,7 +111,7 @@ export function parseArgs(argv) {
|
|
|
111
111
|
}
|
|
112
112
|
if (!VALID_MODES.includes(result.mode)) {
|
|
113
113
|
throw new CliError(
|
|
114
|
-
`지원하지 않는 모드: '${result.mode}'\n지원 모드: interactive full
|
|
114
|
+
`지원하지 않는 모드: '${result.mode}'\n지원 모드: interactive full uninstall status doctor`
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
117
|
return result;
|
package/src/cli/help.js
CHANGED
|
@@ -5,8 +5,8 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
|
|
|
5
5
|
npx project-auto-wizard [옵션]
|
|
6
6
|
|
|
7
7
|
옵션:
|
|
8
|
-
-m, --mode MODE 통합 모드 (full |
|
|
9
|
-
기본: interactive (대화형).
|
|
8
|
+
-m, --mode MODE 통합 모드 (full | uninstall | status | doctor)
|
|
9
|
+
기본: interactive (대화형). full = 설치 및 업데이트
|
|
10
10
|
uninstall = 완전 삭제(대화형 체크리스트, --force 시 --purge-*로 opt-in)
|
|
11
11
|
status = 설치 상태·드리프트 확인(읽기 전용). doctor = 환경 진단(읽기 전용)
|
|
12
12
|
-t, --type CSV 프로젝트 타입 csv (예: spring,react,python)
|
|
@@ -19,8 +19,8 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
|
|
|
19
19
|
--nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
|
|
20
20
|
--secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
|
|
21
21
|
--semver-auto / --no-semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함)
|
|
22
|
-
--force full
|
|
23
|
-
--dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌 (full/
|
|
22
|
+
--force full 실행에 필수 (모든 확인 생략, 비대화형 기본값 사용)
|
|
23
|
+
--dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌 (full/uninstall 지원)
|
|
24
24
|
--purge-readme --mode uninstall --force 시 README.md 버전 섹션도 제거
|
|
25
25
|
--purge-gitignore --mode uninstall --force 시 .gitignore 자동 추가 항목도 제거
|
|
26
26
|
--purge-version --mode uninstall --force 시 version.yml도 제거
|
package/src/commands/dry-run.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// --dry-run 미리보기 — 실제 파일을 쓰지 않고 무엇이 바뀔지 계산한다.
|
|
2
|
-
// full/
|
|
2
|
+
// full/uninstall 두 모드 지원 (issue #70 — 부분 설치·되돌리기 모드 제거).
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { PATHS } from "../core/paths.js";
|
|
6
6
|
import { planWorkflows } from "../core/copy/workflows.js";
|
|
7
|
-
import { planRevert } from "./revert.js";
|
|
8
7
|
import { planUninstall } from "./uninstall.js";
|
|
9
8
|
import { buildVersionYml, parseExisting } from "../core/version-yml.js";
|
|
10
9
|
import { readVersionYmlTemplate } from "../core/assets.js";
|
|
@@ -35,32 +34,21 @@ function versionYmlPreview(context, payloadRoot, targetRoot) {
|
|
|
35
34
|
return { existed: existingRaw !== null, changed: existingRaw !== wouldBe };
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
// mode: "full" | "
|
|
37
|
+
// mode: "full" | "uninstall". 읽기 전용 — 아무 파일도 쓰지 않는다.
|
|
39
38
|
export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
|
|
40
|
-
if (mode === "revert") return { mode, revert: planRevert(payloadRoot, targetRoot) };
|
|
41
39
|
if (mode === "uninstall") {
|
|
42
40
|
return { mode, uninstall: planUninstall(payloadRoot, targetRoot, context.uninstallSelection) };
|
|
43
41
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
if (mode === "full" || mode === "version") {
|
|
50
|
-
result.versionYml = versionYmlPreview(context, payloadRoot, targetRoot);
|
|
51
|
-
}
|
|
52
|
-
return result;
|
|
42
|
+
return {
|
|
43
|
+
mode,
|
|
44
|
+
workflows: planWorkflows(context, payloadRoot, targetRoot),
|
|
45
|
+
versionYml: versionYmlPreview(context, payloadRoot, targetRoot),
|
|
46
|
+
};
|
|
53
47
|
}
|
|
54
48
|
|
|
55
49
|
export function printDryRun(plan) {
|
|
56
50
|
const lines = ["", `project-auto-wizard --dry-run (mode: ${plan.mode}) — 미리보기, 실제 파일은 바뀌지 않았습니다`, ""];
|
|
57
|
-
if (plan.mode === "
|
|
58
|
-
const r = plan.revert;
|
|
59
|
-
lines.push(`제거될 워크플로우 (${r.workflows.length}개):`);
|
|
60
|
-
for (const f of r.workflows) lines.push(` - ${f}`);
|
|
61
|
-
lines.push(`제거될 스크립트 (${r.scripts.length}개):`);
|
|
62
|
-
for (const f of r.scripts) lines.push(` - ${f}`);
|
|
63
|
-
} else if (plan.mode === "uninstall") {
|
|
51
|
+
if (plan.mode === "uninstall") {
|
|
64
52
|
const u = plan.uninstall;
|
|
65
53
|
lines.push(`제거될 워크플로우 (${u.workflows.length}개):`);
|
|
66
54
|
for (const f of u.workflows) lines.push(` - ${f}`);
|
package/src/commands/full.js
CHANGED
|
@@ -10,9 +10,10 @@ import { buildVersionYml, parseExisting } from "../core/version-yml.js";
|
|
|
10
10
|
import { readVersionYmlTemplate } from "../core/assets.js";
|
|
11
11
|
import { markerForType } from "../core/detect.js";
|
|
12
12
|
import { addVersionSectionToReadme } from "../core/copy/readme.js";
|
|
13
|
-
import { copyWorkflows } from "../core/copy/workflows.js";
|
|
13
|
+
import { copyWorkflows, computeBaselineEntries, makeSrcText } from "../core/copy/workflows.js";
|
|
14
14
|
import { copyScripts } from "../core/copy/simple.js";
|
|
15
15
|
import { ensureGitignore } from "../core/copy/gitignore.js";
|
|
16
|
+
import { readBaseline, writeBaseline } from "../core/baseline.js";
|
|
16
17
|
|
|
17
18
|
// context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
|
|
18
19
|
// force, repoName, resolvers, now, today }
|
|
@@ -56,5 +57,19 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
|
56
57
|
const gitignoreUpdated = wfCounters.backupAdded > 0 || wfCounters.templateAdded > 0;
|
|
57
58
|
if (gitignoreUpdated) ensureGitignore(targetRoot);
|
|
58
59
|
|
|
60
|
+
// 6. baseline 기록 (issue #69) — 다음 업데이트에서 "누가 바꿨는지"를 가를 기준점.
|
|
61
|
+
// env 치환까지 전부 끝난 뒤에 해시해야 디스크 내용이 최종형이다. 그래서 copyWorkflows 안이
|
|
62
|
+
// 아니라 여기서 기록한다.
|
|
63
|
+
// 기존 baseline은 병합 대상 — 이번에 건드리지 않은 파일의 기준점을 잃지 않는다.
|
|
64
|
+
writeBaseline(targetRoot, {
|
|
65
|
+
templateVersion,
|
|
66
|
+
installedAt: now || today || "",
|
|
67
|
+
entries: computeBaselineEntries(
|
|
68
|
+
wfCounters.baselineTargets || new Map(),
|
|
69
|
+
join(targetRoot, PATHS.workflowsDir),
|
|
70
|
+
makeSrcText(context.branches || null)),
|
|
71
|
+
previous: readBaseline(targetRoot),
|
|
72
|
+
});
|
|
73
|
+
|
|
59
74
|
return { workflows: wfCounters, gitignoreUpdated };
|
|
60
75
|
}
|
|
@@ -12,12 +12,9 @@ import { resolveProjectPaths } from "../core/paths-resolve.js";
|
|
|
12
12
|
import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch } from "../core/branches.js";
|
|
13
13
|
import { askAllOptionalWorkflows } from "../core/options-ask.js";
|
|
14
14
|
import { promptEnvPlan } from "../ui/env-plan.js";
|
|
15
|
-
import {
|
|
15
|
+
import { surveyWorkflows } from "../core/copy/workflows.js";
|
|
16
16
|
import { createContext, VALID_TYPES } from "../context.js";
|
|
17
17
|
import { runFull } from "./full.js";
|
|
18
|
-
import { runVersion } from "./version.js";
|
|
19
|
-
import { runWorkflows } from "./workflows.js";
|
|
20
|
-
import { runRevert } from "./revert.js";
|
|
21
18
|
import { runUninstallFlow } from "./uninstall.js";
|
|
22
19
|
import * as prompts from "../ui/prompts.js";
|
|
23
20
|
import { runStatus, printStatus } from "./status.js";
|
|
@@ -55,16 +52,6 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
55
52
|
break;
|
|
56
53
|
}
|
|
57
54
|
|
|
58
|
-
// revert 모드 — 확인 질문(기본 아니오) 후 payload 유래 파일 제거. 감지·breaking 게이트 불필요.
|
|
59
|
-
if (mode === "revert") {
|
|
60
|
-
const ok = await io.askYesNo("마법사가 설치한 워크플로우·스크립트를 제거할까요? (version.yml·README는 보존)", false);
|
|
61
|
-
if (ok !== true) { io.cancelMessage?.("되돌리기를 취소했습니다."); return 0; }
|
|
62
|
-
const r = runRevert({}, payload, cwd);
|
|
63
|
-
io.note?.(`워크플로우 ${r.workflows.length}개, 스크립트 ${r.scripts.length}개 제거`, "되돌리기 완료");
|
|
64
|
-
io.outro?.("되돌리기를 마쳤습니다.");
|
|
65
|
-
return 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
55
|
// uninstall 모드 — 대화형 체크리스트로 항목별 opt-in 후 삭제. 감지·breaking 게이트 불필요.
|
|
69
56
|
// runUninstallFlow는 취소/항목없음 시 null을 반환한다 — 그때는 완료 outro를 찍지 않는다.
|
|
70
57
|
if (mode === "uninstall") {
|
|
@@ -89,7 +76,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
89
76
|
let includeNexus = existing?.options?.nexus ?? false;
|
|
90
77
|
let includeSecretBackup = existing?.options?.secretBackup ?? false;
|
|
91
78
|
let includeSemverAuto = existing?.options?.semverAuto ?? null;
|
|
92
|
-
const showOptional = mode === "full"
|
|
79
|
+
const showOptional = mode === "full";
|
|
93
80
|
const realTty = process.stdout.isTTY === true;
|
|
94
81
|
|
|
95
82
|
// 층2 — 감지 로그 (#446)
|
|
@@ -188,8 +175,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
188
175
|
}
|
|
189
176
|
}
|
|
190
177
|
|
|
191
|
-
// 경로 확정 (.sh resolve_project_paths L1362~1589 —
|
|
192
|
-
if (mode === "full"
|
|
178
|
+
// 경로 확정 (.sh resolve_project_paths L1362~1589 — 저장값·후보 스캔·질문)
|
|
179
|
+
if (mode === "full") {
|
|
193
180
|
paths = await resolveProjectPaths({
|
|
194
181
|
root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
|
|
195
182
|
force: false, tty: realTty, io: io.engineIo ?? {},
|
|
@@ -219,17 +206,37 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
219
206
|
});
|
|
220
207
|
ctx.templateVersion = templateVersion;
|
|
221
208
|
|
|
222
|
-
//
|
|
209
|
+
// 사용자가 답해야 하는 것만 묻는다 (issue #69). baseline 3-way가 자동으로 안전한 경우를
|
|
210
|
+
// 걸러내므로, 여기 오는 것은 (a) 양쪽이 다 바뀐 진짜 충돌과 (b) 사용자가 지운 파일뿐이다.
|
|
223
211
|
let hooks = {};
|
|
224
212
|
if (showOptional && io.engineIo?.select) {
|
|
225
|
-
const conflicts =
|
|
213
|
+
const { conflicts, removed } = surveyWorkflows(ctx, payload, cwd);
|
|
214
|
+
|
|
215
|
+
// (b) 지운 파일 — 조용히 되살리지 않는다. 되살리는 쪽이 기본이 아니라는 점이 핵심이다.
|
|
216
|
+
const restoreRemoved = new Set();
|
|
217
|
+
if (removed.length) {
|
|
218
|
+
io.note?.(removed.map((r) => ` - ${r.filename}`).join("\n"),
|
|
219
|
+
`이전 설치에는 있었지만 지금 없는 워크플로우 (${removed.length}개)`);
|
|
220
|
+
for (const { filename } of removed) {
|
|
221
|
+
const sel = await io.engineIo.select({
|
|
222
|
+
message: `${filename} — 직접 지우신 것으로 보입니다. 다시 추가할까요?`,
|
|
223
|
+
options: [
|
|
224
|
+
{ value: "keep", label: "추가하지 않음 — 지운 상태 유지 (기본)" },
|
|
225
|
+
{ value: "restore", label: "다시 추가 — 최신 버전으로 설치" },
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
if (!isCancel(sel) && sel === "restore") restoreRemoved.add(filename); // ESC = 유지
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// (a) 진짜 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
|
|
233
|
+
const decisions = new Map();
|
|
226
234
|
if (conflicts.length) {
|
|
227
235
|
const perType = new Map();
|
|
228
|
-
const decisions = new Map();
|
|
229
236
|
for (const { filename, type } of conflicts) {
|
|
230
237
|
if (!perType.has(type)) {
|
|
231
238
|
const sel = await io.engineIo.select({
|
|
232
|
-
message:
|
|
239
|
+
message: `내 수정과 업스트림 변경이 겹칩니다 (${type}) — 어떻게 할까요?`,
|
|
233
240
|
options: [
|
|
234
241
|
{ value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
|
|
235
242
|
{ value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
|
|
@@ -240,14 +247,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
240
247
|
}
|
|
241
248
|
decisions.set(filename, perType.get(type));
|
|
242
249
|
}
|
|
243
|
-
hooks = { decisions };
|
|
244
250
|
}
|
|
251
|
+
hooks = { decisions, restoreRemoved };
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
|
|
248
|
-
if (mode === "full") result = runFull(ctx, payload, cwd, hooks);
|
|
249
|
-
else if (mode === "version") result = runVersion(ctx, payload, cwd);
|
|
250
|
-
else if (mode === "workflows") result = runWorkflows(ctx, payload, cwd, hooks);
|
|
254
|
+
const result = runFull(ctx, payload, cwd, hooks);
|
|
251
255
|
|
|
252
256
|
// 완료 요약 (.sh print_summary L5438)
|
|
253
257
|
io.summary?.({
|
package/src/commands/purge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// purge 모드 —
|
|
1
|
+
// purge 모드 — planRemoval이 판별하는 전부(워크플로우·스크립트) + version.yml·README
|
|
2
2
|
// AUTO-VERSION-SECTION 블록·CHANGELOG를 추가로 제거해 설치 이전 상태로 완전히 되돌린다.
|
|
3
3
|
// 개발·테스트 전용 숨김 모드 — DESIGN-SPEC purge #6.
|
|
4
4
|
// develop 브랜치 삭제는 파일 삭제와 성격이 달라(실행 시점 git 상태 판단 필요) 여기 plan에는
|
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { PATHS } from "../core/paths.js";
|
|
9
9
|
import { remove } from "../core/fsutil.js";
|
|
10
|
-
import {
|
|
10
|
+
import { planRemoval } from "../core/removal-plan.js";
|
|
11
11
|
import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
|
|
12
12
|
|
|
13
13
|
const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
|
|
@@ -15,10 +15,11 @@ const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
|
|
|
15
15
|
// keepFlags: { versionYml, readme, changelog, workflows, scripts } — true인 카테고리는 후보에서 제외.
|
|
16
16
|
// 반환: { workflows, scripts, versionYml, readmeSection, changelog } — 아무것도 지우지 않는 순수 함수.
|
|
17
17
|
export function planPurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
|
|
18
|
-
const
|
|
18
|
+
const removalPlan = planRemoval(payloadRoot, targetRoot);
|
|
19
19
|
return {
|
|
20
|
-
workflows: keepFlags.workflows ? [] :
|
|
21
|
-
|
|
20
|
+
workflows: keepFlags.workflows ? [] : removalPlan.workflows,
|
|
21
|
+
baseline: keepFlags.workflows ? [] : removalPlan.baseline,
|
|
22
|
+
scripts: keepFlags.scripts ? [] : removalPlan.scripts,
|
|
22
23
|
versionYml: !keepFlags.versionYml && existsSync(join(targetRoot, PATHS.versionFile)),
|
|
23
24
|
readmeSection: !keepFlags.readme && hasVersionSection(targetRoot),
|
|
24
25
|
changelog: keepFlags.changelog ? [] : CHANGELOG_FILES.filter((f) => existsSync(join(targetRoot, f))),
|
|
@@ -44,7 +45,7 @@ export function printPurgePlan(plan, { dryRun = false } = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// 실제 삭제 수행 — planPurge()와 동일 shape을 반환하되 실제로 제거된 항목을 반영한다.
|
|
47
|
-
//
|
|
48
|
+
// planRemoval 결과를 그대로 지우지 않는 이유: 그 목록은 항상 전체라서
|
|
48
49
|
// --keep-* 로 선택적 카테고리만 보존하는 요구사항과 맞지 않는다.
|
|
49
50
|
// H3 (Fable 검토): readmeSection은 plan의 판정을 그대로 되돌려주지 않고
|
|
50
51
|
// removeVersionSectionFromReadme()의 실제 반환값("removed"인지)을 반영한다 — 스펙 §6이
|
|
@@ -55,6 +56,7 @@ export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
|
|
|
55
56
|
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
56
57
|
for (const name of plan.workflows) remove(join(wfDir, name));
|
|
57
58
|
for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
|
|
59
|
+
for (const p of plan.baseline || []) remove(join(targetRoot, p));
|
|
58
60
|
if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
|
|
59
61
|
const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
|
|
60
62
|
for (const f of plan.changelog) remove(join(targetRoot, f));
|
package/src/commands/status.js
CHANGED
|
@@ -33,7 +33,17 @@ export function runStatus(payloadRoot, targetRoot = ".") {
|
|
|
33
33
|
types: existing.types,
|
|
34
34
|
branches: existing.branches,
|
|
35
35
|
options: existing.options,
|
|
36
|
-
|
|
36
|
+
// 사용자가 손댄 파일 = 진짜 충돌(changed) + 업스트림은 그대로인데 내가 고친 것(localOnly).
|
|
37
|
+
// baseline이 없으면 localOnly는 항상 비어 있어 종전과 동일하게 동작한다 (issue #69).
|
|
38
|
+
modifiedFiles: [...plan.changed, ...plan.localOnly].map((f) => f.filename),
|
|
39
|
+
// 업데이트 시 무슨 일이 일어날지 미리 보여주는 버킷들 — 판단 재료가 없어 사용자가
|
|
40
|
+
// 직접 git diff를 떠야 했던 문제를 없앤다.
|
|
41
|
+
buckets: {
|
|
42
|
+
autoUpdatable: plan.upstreamOnly.map((f) => f.filename), // 질문 없이 최신으로 교체됨
|
|
43
|
+
localKept: plan.localOnly.map((f) => f.filename), // 질문 없이 내 수정본 유지됨
|
|
44
|
+
conflicts: plan.changed.map((f) => f.filename), // 양쪽 변경 — 검토 필요
|
|
45
|
+
removed: plan.removed.map((f) => f.filename), // 내가 지웠고 복원하지 않음
|
|
46
|
+
},
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
49
|
|
|
@@ -59,6 +69,16 @@ export function printStatus(status) {
|
|
|
59
69
|
} else {
|
|
60
70
|
lines.push("", "모든 워크플로우 파일이 설치 시점 기본값과 동일합니다 (수정 없음).");
|
|
61
71
|
}
|
|
72
|
+
|
|
73
|
+
// 업데이트하면 무슨 일이 일어나는지 (issue #69). baseline이 없는 설치는 전부 0이라 출력하지 않는다.
|
|
74
|
+
const b = status.buckets || { autoUpdatable: [], localKept: [], conflicts: [], removed: [] };
|
|
75
|
+
if (b.autoUpdatable.length || b.localKept.length || b.conflicts.length || b.removed.length) {
|
|
76
|
+
lines.push("", "지금 업데이트하면:");
|
|
77
|
+
lines.push(` 업스트림 변경 — 자동 적용 가능 ${String(b.autoUpdatable.length).padStart(3)}`);
|
|
78
|
+
lines.push(` 내가 수정 · 업스트림 그대로 ${String(b.localKept.length).padStart(3)} (유지)`);
|
|
79
|
+
lines.push(` 양쪽 변경 — 검토 필요 ${String(b.conflicts.length).padStart(3)}`);
|
|
80
|
+
lines.push(` 내가 삭제함 — 복원 안 함 ${String(b.removed.length).padStart(3)}`);
|
|
81
|
+
}
|
|
62
82
|
lines.push("");
|
|
63
83
|
console.log(lines.join("\n"));
|
|
64
84
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// uninstall 모드 —
|
|
2
|
-
// 대화형 체크리스트 또는 --force + --purge-* 로 항목별 opt-in.
|
|
1
|
+
// uninstall 모드 — 마법사가 설치한 파일(planRemoval 판별분)에 더해 README/.gitignore/version.yml까지
|
|
2
|
+
// 선택적으로 제거한다. 대화형 체크리스트 또는 --force + --purge-* 로 항목별 opt-in.
|
|
3
|
+
// core/removal-plan.js는 읽기 전용으로만 재사용한다(아무것도 지우지 않는 순수 함수).
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { existsSync } from "node:fs";
|
|
5
6
|
import { PATHS } from "../core/paths.js";
|
|
6
7
|
import { remove } from "../core/fsutil.js";
|
|
7
|
-
import {
|
|
8
|
+
import { planRemoval } from "../core/removal-plan.js";
|
|
8
9
|
import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
|
|
9
10
|
import { removeAutoAddedEntriesFromGitignore, hasAutoAddedEntries } from "../core/copy/gitignore.js";
|
|
10
11
|
import { CANCEL } from "../ui/prompts.js";
|
|
@@ -12,10 +13,12 @@ import { CANCEL } from "../ui/prompts.js";
|
|
|
12
13
|
// selection: { workflows, scripts, readme, gitignore, versionYml } (모두 boolean).
|
|
13
14
|
// 반환: 위와 동일한 키의 boolean/배열 — 실제로 제거 "대상"인지 여부(순수 함수, 아무것도 지우지 않음).
|
|
14
15
|
export function planUninstall(payloadRoot, targetRoot, selection) {
|
|
15
|
-
const
|
|
16
|
+
const removalPlan = planRemoval(payloadRoot, targetRoot);
|
|
16
17
|
return {
|
|
17
|
-
workflows: selection.workflows ?
|
|
18
|
-
|
|
18
|
+
workflows: selection.workflows ? removalPlan.workflows : [],
|
|
19
|
+
// baseline은 워크플로우 해시 기록 — 워크플로우를 지우면 함께 사라져야 한다 (issue #69)
|
|
20
|
+
baseline: selection.workflows ? removalPlan.baseline : [],
|
|
21
|
+
scripts: selection.scripts ? removalPlan.scripts : [],
|
|
19
22
|
readme: selection.readme ? hasVersionSection(targetRoot) : false,
|
|
20
23
|
gitignore: selection.gitignore ? hasAutoAddedEntries(targetRoot) : false,
|
|
21
24
|
versionYml: selection.versionYml ? existsSync(join(targetRoot, PATHS.versionFile)) : false,
|
|
@@ -28,6 +31,7 @@ export function runUninstall(context, payloadRoot, targetRoot, selection) {
|
|
|
28
31
|
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
29
32
|
for (const name of plan.workflows) remove(join(wfDir, name));
|
|
30
33
|
for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
|
|
34
|
+
for (const p of plan.baseline || []) remove(join(targetRoot, p));
|
|
31
35
|
// removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
|
|
32
36
|
// 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
|
|
33
37
|
// 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
|
|
@@ -52,10 +56,10 @@ const ITEM_DEFS = [
|
|
|
52
56
|
export const SAFE_ITEMS = ["workflows", "scripts"];
|
|
53
57
|
|
|
54
58
|
function detectAvailableItems(payloadRoot, targetRoot) {
|
|
55
|
-
const
|
|
59
|
+
const removalPlan = planRemoval(payloadRoot, targetRoot);
|
|
56
60
|
const presence = {
|
|
57
|
-
workflows:
|
|
58
|
-
scripts:
|
|
61
|
+
workflows: removalPlan.workflows.length > 0,
|
|
62
|
+
scripts: removalPlan.scripts.length > 0,
|
|
59
63
|
readme: hasVersionSection(targetRoot),
|
|
60
64
|
gitignore: hasAutoAddedEntries(targetRoot),
|
|
61
65
|
versionYml: existsSync(join(targetRoot, PATHS.versionFile)),
|
package/src/context.js
CHANGED
|
@@ -6,9 +6,11 @@ export const VALID_TYPES = [
|
|
|
6
6
|
|
|
7
7
|
// --mode 화이트리스트 (issue #19) — 알 수 없는 값은 부수효과(브랜치 조회 등) 이전에 즉시 거부해야 한다.
|
|
8
8
|
// purge는 --help/대화형 메뉴에 노출하지 않는 숨김 모드(issue #6)이지만 검증 대상에는 포함한다.
|
|
9
|
+
// version/workflows(부분 설치)와 revert는 제거됐다 (issue #70) — 부분 설치는 설치 시점 baseline을
|
|
10
|
+
// 반쪽만 갱신해 업데이트 판정을 흐리고, revert는 uninstall의 부분집합이었다.
|
|
9
11
|
export const VALID_MODES = [
|
|
10
|
-
"interactive", "full",
|
|
11
|
-
"
|
|
12
|
+
"interactive", "full",
|
|
13
|
+
"uninstall", "status", "doctor", "purge",
|
|
12
14
|
];
|
|
13
15
|
|
|
14
16
|
export const DEFAULT_VERSION = "0.0.0"; // 패키지 버전 읽기 실패 시 폴백 (배너용 — breaking 비교엔 안 씀)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// 설치 시점 baseline (issue #69) — 업데이트에서 "누가 바꿨는지"를 가르는 기준점.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가: isUnchanged()는 payload(theirs)와 설치본(ours)을 2-way로 비교한다.
|
|
4
|
+
// base가 없으니 업스트림이 한 글자만 고쳐도 사용자가 손대지 않은 파일이 changed로 떨어지고,
|
|
5
|
+
// 결국 "전부 skip(업데이트 못 받음)" 아니면 "전부 backup(사용자 수정 전멸)" 둘 중 하나만
|
|
6
|
+
// 고를 수 있게 된다.
|
|
7
|
+
//
|
|
8
|
+
// 파일 사본이 아니라 해시만 남긴다 — 분류가 목적이지 자동 병합이 목적이 아니다.
|
|
9
|
+
//
|
|
10
|
+
// 해시를 두 개 두는 이유(이슈 원안은 하나였다): env 치환으로 사용자 값이 들어간 파일은
|
|
11
|
+
// 디스크 내용과 "기본값으로 렌더한 결과"가 애초에 다르다. 하나로는 두 질문에 동시에 답할 수 없다.
|
|
12
|
+
// - installed : 설치 시점 우리가 디스크에 쓴 내용 → "사용자가 그 뒤에 손댔는가"
|
|
13
|
+
// - rendered : 그 시점 payload를 기본값 치환한 결과 → "업스트림이 그 뒤에 바뀌었는가"
|
|
14
|
+
//
|
|
15
|
+
// installed는 우리가 실제로 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
|
|
16
|
+
// "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { writeText } from "./fsutil.js";
|
|
21
|
+
|
|
22
|
+
export const BASELINE_DIR = ".github/.wizard";
|
|
23
|
+
export const BASELINE_PATH = ".github/.wizard/baseline.json";
|
|
24
|
+
|
|
25
|
+
export function sha256(text) {
|
|
26
|
+
return "sha256:" + createHash("sha256").update(String(text), "utf8").digest("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 없거나 깨졌으면 null — 호출부는 "base 미상"으로 폴백한다(조용히 빈 baseline을 쓰지 않는다.
|
|
30
|
+
// 빈 baseline은 "기록이 없다"가 아니라 "전부 삭제됐다"로 오해될 수 있다).
|
|
31
|
+
export function readBaseline(targetRoot = ".") {
|
|
32
|
+
const p = join(targetRoot, BASELINE_PATH);
|
|
33
|
+
if (!existsSync(p)) return null;
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(readFileSync(p, "utf8"));
|
|
36
|
+
if (!data || typeof data !== "object" || typeof data.files !== "object" || data.files === null) return null;
|
|
37
|
+
return data;
|
|
38
|
+
} catch {
|
|
39
|
+
return null; // 손상된 baseline은 없는 것으로 취급 — 업데이트를 막지 않는다
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// entries: Map<filename, {installed?:string|null, rendered:string}>
|
|
44
|
+
// 기존 baseline은 병합 대상이다 — 이번 실행에서 건드리지 않은 파일의 기준점을 잃지 않는다.
|
|
45
|
+
export function writeBaseline(targetRoot, { templateVersion, installedAt, entries, previous = null }) {
|
|
46
|
+
const files = { ...(previous?.files || {}) };
|
|
47
|
+
for (const [filename, entry] of entries) {
|
|
48
|
+
const prev = files[filename] || {};
|
|
49
|
+
files[filename] = {
|
|
50
|
+
// installed는 이번에 실제로 쓴 경우에만 갱신. 유지(skip)한 파일은 예전 기준점을 지킨다.
|
|
51
|
+
installed: entry.installed ?? prev.installed ?? null,
|
|
52
|
+
rendered: entry.rendered,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const out = {
|
|
56
|
+
templateVersion: templateVersion || "unknown",
|
|
57
|
+
installedAt: installedAt || "",
|
|
58
|
+
files,
|
|
59
|
+
};
|
|
60
|
+
writeText(join(targetRoot, BASELINE_PATH), JSON.stringify(out, null, 2) + "\n");
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// baseline에는 있는데 디스크에 없는 파일 = 사용자가 지운 것.
|
|
65
|
+
// 별도의 삭제 이력 파일이 필요 없다는 것이 이 설계의 부산물이다.
|
|
66
|
+
// candidates: payload가 이번에 설치하려는 파일명 목록 (그 밖의 baseline 항목은 관심 없다)
|
|
67
|
+
export function detectRemoved(baseline, candidates, workflowsDir) {
|
|
68
|
+
if (!baseline) return [];
|
|
69
|
+
const removed = [];
|
|
70
|
+
for (const filename of candidates) {
|
|
71
|
+
if (!baseline.files[filename]) continue; // 우리가 설치한 적 없는 파일 — 판단 근거 없음
|
|
72
|
+
if (existsSync(join(workflowsDir, filename))) continue;
|
|
73
|
+
removed.push(filename);
|
|
74
|
+
}
|
|
75
|
+
return removed;
|
|
76
|
+
}
|
|
@@ -6,12 +6,13 @@ import { join, basename } from "node:path";
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
7
7
|
import { PATHS, PAYLOAD } from "../paths.js";
|
|
8
8
|
import { exists, writeText, listYamlFiles } from "../fsutil.js";
|
|
9
|
-
import {
|
|
9
|
+
import { substituteEnv } from "../wizard-env.js";
|
|
10
10
|
import { substitute } from "../branding.js";
|
|
11
|
+
import { sha256, readBaseline } from "../baseline.js";
|
|
11
12
|
|
|
12
13
|
// 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
|
|
13
14
|
// classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
|
|
14
|
-
function makeSrcText(branches) {
|
|
15
|
+
export function makeSrcText(branches) {
|
|
15
16
|
return (p) => {
|
|
16
17
|
const raw = readFileSync(p, "utf8");
|
|
17
18
|
return branches ? substitute(raw, branches) : raw;
|
|
@@ -34,34 +35,100 @@ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", reso
|
|
|
34
35
|
writeFileSync(targetPath, out);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
function
|
|
40
|
-
|
|
38
|
+
// payload 원본을 "기본값으로 가상 치환한 최종형" — isUnchanged가 내부에서 쓰는 것과 같은 값이다.
|
|
39
|
+
// baseline.rendered와 비교해 "업스트림이 바뀌었는가"를 판정하는 데 쓴다.
|
|
40
|
+
function renderVirtual(templateContent, envOpts) {
|
|
41
|
+
return substituteEnv(templateContent, { ...envOpts, useDefaults: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 분류 — 대상 워크플로우 디렉토리 기준. srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
|
|
45
|
+
//
|
|
46
|
+
// baseline이 있으면 3-way로 가른다 (issue #69). base가 없던 시절에는 업스트림이 한 글자만 고쳐도
|
|
47
|
+
// 사용자가 손대지 않은 파일이 changed로 떨어져, "전부 skip" 아니면 "전부 backup" 둘 중 하나만
|
|
48
|
+
// 고를 수 있었다.
|
|
49
|
+
//
|
|
50
|
+
// ours === theirs → unchanged 이미 최신, 할 일 없음
|
|
51
|
+
// sha(ours) === base.installed → upstreamOnly 사용자 미수정 → 질문 없이 교체
|
|
52
|
+
// sha(theirs) === base.rendered → localOnly 업스트림 그대로 → 질문 없이 유지
|
|
53
|
+
// 그 외 → changed 진짜 충돌 → 질문
|
|
54
|
+
// 디스크에 없는데 baseline에 있음 → removed 사용자가 지움 → 되살리기 전에 물어봄
|
|
55
|
+
//
|
|
56
|
+
// baseline이 없는 기존 설치는 base 미상이라 upstreamOnly/localOnly 판정을 할 수 없고,
|
|
57
|
+
// 종전대로 unchanged/changed 2분류로 떨어진다(폴백). 그 실행에서 baseline이 심긴다.
|
|
58
|
+
function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null) {
|
|
59
|
+
const result = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
|
|
41
60
|
for (const filename of listYamlFiles(srcDir)) {
|
|
42
61
|
const src = join(srcDir, filename);
|
|
43
62
|
const dst = join(workflowsDir, filename);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
result.newFiles.push(filename);
|
|
63
|
+
const base = baseline?.files?.[filename] || null;
|
|
64
|
+
|
|
65
|
+
if (!existsSync(dst)) {
|
|
66
|
+
// baseline에 있는데 디스크에 없다 = 우리가 깔았던 파일을 사용자가 지웠다.
|
|
67
|
+
// 별도의 삭제 이력 파일이 필요 없다는 것이 baseline 설계의 부산물이다.
|
|
68
|
+
if (base) result.removed.push(filename);
|
|
69
|
+
else result.newFiles.push(filename);
|
|
70
|
+
continue;
|
|
51
71
|
}
|
|
72
|
+
|
|
73
|
+
const tpl = srcText(src);
|
|
74
|
+
const inst = readFileSync(dst, "utf8");
|
|
75
|
+
const theirs = renderVirtual(tpl, envOpts);
|
|
76
|
+
if (theirs === inst) { result.unchanged.push(filename); continue; }
|
|
77
|
+
if (base?.installed && sha256(inst) === base.installed) { result.upstreamOnly.push(filename); continue; }
|
|
78
|
+
if (base?.rendered && sha256(theirs) === base.rendered) { result.localOnly.push(filename); continue; }
|
|
79
|
+
result.changed.push(filename);
|
|
52
80
|
}
|
|
53
81
|
return result;
|
|
54
82
|
}
|
|
55
83
|
|
|
84
|
+
// 한 원본 디렉토리를 분류 결과대로 처리한다. common·타입별·server-deploy가 같은 규칙을 쓴다.
|
|
85
|
+
// filter: trunk-based 제외 같은 파일 단위 필터.
|
|
86
|
+
//
|
|
87
|
+
// 자동 처리되는 두 버킷이 이 함수의 핵심이다 (issue #69):
|
|
88
|
+
// upstreamOnly — 사용자가 손대지 않았으니 그냥 최신으로 교체한다. 물어볼 이유가 없다.
|
|
89
|
+
// localOnly — 업스트림이 그대로니 사용자 수정본을 그대로 둔다. 역시 물어볼 이유가 없다.
|
|
90
|
+
function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () => true) {
|
|
91
|
+
const { srcText, baseline, decisions, restoreRemoved, baselineTargets } = ctx;
|
|
92
|
+
const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
|
|
93
|
+
const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
|
|
94
|
+
const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
|
|
95
|
+
|
|
96
|
+
for (const f of c.unchanged.filter(filter)) { counters.skipped++; track(f, false); }
|
|
97
|
+
|
|
98
|
+
for (const f of c.localOnly.filter(filter)) { counters.skipped++; counters.keptLocal.push(f); track(f, false); }
|
|
99
|
+
|
|
100
|
+
for (const f of c.newFiles.filter(filter)) write(f);
|
|
101
|
+
|
|
102
|
+
for (const f of c.upstreamOnly.filter(filter)) { write(f); counters.autoUpdated.push(f); }
|
|
103
|
+
|
|
104
|
+
// 사용자가 지운 파일은 조용히 되살리지 않는다. 복원 결정이 있을 때만 다시 쓴다.
|
|
105
|
+
// 되살리지 않은 파일은 baselineTargets에 넣지 않는다 — 디스크에 없어 해시할 것이 없고,
|
|
106
|
+
// 기존 baseline 항목은 병합으로 남아 다음 실행에서도 "지운 파일"로 인식된다.
|
|
107
|
+
for (const f of c.removed.filter(filter)) {
|
|
108
|
+
if (restoreRemoved.has(f)) { write(f); counters.restoredFiles.push(f); }
|
|
109
|
+
else counters.removedKept.push(f);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const f of c.changed.filter(filter)) {
|
|
113
|
+
const decision = decisions.get(f);
|
|
114
|
+
applyDecision(decision, srcDir, workflowsDir, f, counters, srcText);
|
|
115
|
+
// 'backup'만 대상 파일 자체를 새로 쓴다. 'template'은 다른 파일명이고 'skip'은 기존 유지.
|
|
116
|
+
track(f, decision === "backup");
|
|
117
|
+
}
|
|
118
|
+
return c;
|
|
119
|
+
}
|
|
120
|
+
|
|
56
121
|
// copy_workflows 본체 (동기 — 기존 호출부 무변경).
|
|
57
122
|
// context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
|
|
58
123
|
// envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
|
|
59
|
-
// hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'
|
|
124
|
+
// hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'>, — 진짜 충돌(changed) 결정
|
|
125
|
+
// restoreRemoved?: Set<filename> } — 사용자가 지운 파일 중 복원할 것
|
|
60
126
|
// 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
|
|
61
|
-
// 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded}
|
|
127
|
+
// 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded, autoUpdated, keptLocal, removedKept, restoredFiles}
|
|
62
128
|
export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
63
129
|
const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
|
|
64
130
|
const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
|
|
131
|
+
const restoreRemoved = hooks.restoreRemoved instanceof Set ? hooks.restoreRemoved : new Set();
|
|
65
132
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
66
133
|
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
67
134
|
if (!exists(projectTypesDir)) throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
|
|
@@ -70,31 +137,30 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
|
|
|
70
137
|
const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
|
|
71
138
|
counters.deployValues = deployValues;
|
|
72
139
|
counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (issue #19 — printSummary 정확성용)
|
|
140
|
+
counters.autoUpdated = []; // 질문 없이 최신으로 교체된 파일 (사용자 미수정)
|
|
141
|
+
counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
|
|
142
|
+
counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
|
|
143
|
+
counters.restoredFiles = []; // 사용자가 지웠지만 복원하기로 한 파일
|
|
73
144
|
const srcText = makeSrcText(context.branches || null);
|
|
74
|
-
|
|
145
|
+
const baseline = readBaseline(targetRoot);
|
|
146
|
+
const baselineTargets = new Map(); // filename -> { srcPath, envOpts, wrote }
|
|
147
|
+
// values/useDefaults는 치환 경로에서만 의미 (renderVirtual은 useDefaults:true 강제 — 가상 비교 무손상)
|
|
75
148
|
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
|
|
149
|
+
const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets };
|
|
76
150
|
|
|
77
|
-
// (1) common —
|
|
78
|
-
// changed면 decisions Map 결정에 따름(미지정 시 skip — 기존 사용자 수정 보존).
|
|
151
|
+
// (1) common — 타입별과 동일 규칙 (README 계약, issue #20 H3).
|
|
79
152
|
// trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
|
|
80
153
|
const branchMode = context.branches?.mode || "pr-flow";
|
|
81
154
|
const commonDir = join(projectTypesDir, "common");
|
|
82
155
|
if (exists(commonDir)) {
|
|
83
156
|
const notExcluded = (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename));
|
|
84
|
-
|
|
85
|
-
counters.skipped += unchanged.filter(notExcluded).length;
|
|
86
|
-
for (const f of newFiles.filter(notExcluded)) {
|
|
87
|
-
writeText(join(workflowsDir, f), srcText(join(commonDir, f)));
|
|
88
|
-
counters.copied++;
|
|
89
|
-
counters.copiedFiles.push(f);
|
|
90
|
-
}
|
|
91
|
-
for (const f of changed.filter(notExcluded)) applyDecision(decisions.get(f), commonDir, workflowsDir, f, counters, srcText);
|
|
157
|
+
processDir(commonDir, workflowsDir, envOptsFor("common"), dirCtx, counters, notExcluded);
|
|
92
158
|
}
|
|
93
159
|
|
|
94
160
|
// (2~4) 타입별
|
|
95
161
|
for (const type of types) {
|
|
96
162
|
const asks = new Map();
|
|
97
|
-
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks,
|
|
163
|
+
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, dirCtx }, counters);
|
|
98
164
|
if (asks.size) deployValues.set(type, asks);
|
|
99
165
|
}
|
|
100
166
|
|
|
@@ -108,12 +174,29 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
|
|
|
108
174
|
counters.optionalCopied++;
|
|
109
175
|
counters.copied++;
|
|
110
176
|
counters.copiedFiles.push(filename);
|
|
177
|
+
baselineTargets.set(filename, { srcPath: join(secretDir, filename), envOpts: envOptsFor("common"), wrote: true });
|
|
111
178
|
}
|
|
112
179
|
}
|
|
113
180
|
|
|
181
|
+
counters.baselineTargets = baselineTargets; // 호출부(runFull)가 env 치환 완료 후 baseline을 기록한다
|
|
114
182
|
return counters;
|
|
115
183
|
}
|
|
116
184
|
|
|
185
|
+
// copyWorkflows가 끝나고 env 치환까지 마친 뒤에 호출한다 — 그래야 디스크 내용이 최종형이다.
|
|
186
|
+
// entries: Map<filename, {installed:string|null, rendered:string}>
|
|
187
|
+
export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
|
|
188
|
+
const entries = new Map();
|
|
189
|
+
for (const [filename, info] of baselineTargets) {
|
|
190
|
+
const dst = join(workflowsDir, filename);
|
|
191
|
+
if (!existsSync(dst)) continue;
|
|
192
|
+
const rendered = sha256(renderVirtual(srcText(info.srcPath), info.envOpts));
|
|
193
|
+
// installed는 이번에 우리가 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
|
|
194
|
+
// "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
|
|
195
|
+
entries.set(filename, { installed: info.wrote ? sha256(readFileSync(dst, "utf8")) : null, rendered });
|
|
196
|
+
}
|
|
197
|
+
return entries;
|
|
198
|
+
}
|
|
199
|
+
|
|
117
200
|
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
118
201
|
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
119
202
|
function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
|
|
@@ -139,43 +222,52 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
|
|
|
139
222
|
counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
|
|
140
223
|
}
|
|
141
224
|
|
|
142
|
-
//
|
|
225
|
+
// 대화형 사전 조사 — 사람이 답해야 하는 것만 뽑는다 (issue #69).
|
|
143
226
|
// copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
|
|
144
227
|
// common도 타입별과 동일하게 스캔한다 (issue #20 H3 — 이전에는 common 충돌이 질문조차 되지 않았다).
|
|
145
|
-
|
|
228
|
+
// 반환: { conflicts: [{filename,type}], removed: [{filename,type}] }
|
|
229
|
+
// conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
|
|
230
|
+
// removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
|
|
231
|
+
export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
146
232
|
const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
|
|
147
233
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
148
234
|
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
149
235
|
const srcText = makeSrcText(context.branches || null);
|
|
236
|
+
const baseline = readBaseline(targetRoot);
|
|
150
237
|
const branchMode = context.branches?.mode || "pr-flow";
|
|
151
|
-
const conflicts = []; //
|
|
238
|
+
const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
|
|
239
|
+
const removed = [];
|
|
240
|
+
|
|
241
|
+
const collect = (srcDir, envOpts, type, skipFile = () => false) => {
|
|
242
|
+
const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
|
|
243
|
+
for (const f of c.changed) { if (!skipFile(f)) conflicts.push({ filename: f, type }); }
|
|
244
|
+
for (const f of c.removed) { if (!skipFile(f)) removed.push({ filename: f, type }); }
|
|
245
|
+
};
|
|
152
246
|
|
|
153
247
|
const commonDir = join(projectTypesDir, "common");
|
|
154
248
|
if (exists(commonDir)) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f)) continue;
|
|
158
|
-
conflicts.push({ filename: f, type: "common" });
|
|
159
|
-
}
|
|
249
|
+
collect(commonDir, { type: "common", projectPath: ".", repoName, resolvers }, "common",
|
|
250
|
+
(f) => branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f));
|
|
160
251
|
}
|
|
161
252
|
|
|
162
253
|
for (const type of types) {
|
|
163
254
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
164
255
|
const typeDir = join(projectTypesDir, type);
|
|
165
|
-
if (exists(typeDir))
|
|
166
|
-
for (const f of classify(typeDir, workflowsDir, envOpts, srcText).changed) conflicts.push({ filename: f, type });
|
|
167
|
-
}
|
|
256
|
+
if (exists(typeDir)) collect(typeDir, envOpts, type);
|
|
168
257
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
169
|
-
if (exists(serverDeployDir) && !includeNexus)
|
|
170
|
-
for (const f of classify(serverDeployDir, workflowsDir, envOpts, srcText).changed) conflicts.push({ filename: f, type });
|
|
171
|
-
}
|
|
258
|
+
if (exists(serverDeployDir) && !includeNexus) collect(serverDeployDir, envOpts, type);
|
|
172
259
|
}
|
|
173
|
-
return conflicts;
|
|
260
|
+
return { conflicts, removed };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 진짜 충돌 목록만 필요할 때 (기존 호출부 호환).
|
|
264
|
+
export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
|
|
265
|
+
return surveyWorkflows(context, payloadRoot, targetRoot).conflicts;
|
|
174
266
|
}
|
|
175
267
|
|
|
176
268
|
// 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
|
|
177
269
|
// 동기 엔진에 위임한다. WHY 분리: copyWorkflows를 async로 바꾸면 await 없이 호출하는
|
|
178
|
-
// 기존 호출부(runFull
|
|
270
|
+
// 기존 호출부(runFull)가 깨진다 — 시그니처 무변경 원칙.
|
|
179
271
|
// onConflict 반환값: 'template' | 'skip' | 'backup' (그 외/미지정 → 'skip').
|
|
180
272
|
export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot = ".", { onConflict } = {}) {
|
|
181
273
|
const decisions = new Map();
|
|
@@ -189,23 +281,18 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
|
|
|
189
281
|
}
|
|
190
282
|
|
|
191
283
|
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
192
|
-
const { includeNexus, envOptsFor, collectAsks = null,
|
|
284
|
+
const { includeNexus, envOptsFor, collectAsks = null, dirCtx } = ctx;
|
|
285
|
+
const { srcText, baselineTargets } = dirCtx;
|
|
193
286
|
const typeDir = join(projectTypesDir, type);
|
|
194
287
|
const envOpts = envOptsFor(type);
|
|
195
|
-
|
|
288
|
+
// env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
|
|
289
|
+
// 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
|
|
290
|
+
const untouched = [];
|
|
196
291
|
|
|
197
292
|
// 타입별 워크플로우 (직하위)
|
|
198
293
|
if (exists(typeDir)) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
for (const f of unchanged) counters.skipped++;
|
|
202
|
-
for (const f of newFiles) {
|
|
203
|
-
writeText(join(workflowsDir, f), srcText(join(typeDir, f)));
|
|
204
|
-
counters.copied++;
|
|
205
|
-
counters.copiedFiles.push(f);
|
|
206
|
-
}
|
|
207
|
-
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
208
|
-
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, srcText);
|
|
294
|
+
const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters);
|
|
295
|
+
untouched.push(...c.unchanged, ...c.localOnly);
|
|
209
296
|
}
|
|
210
297
|
|
|
211
298
|
// server-deploy
|
|
@@ -214,14 +301,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
214
301
|
if (includeNexus) {
|
|
215
302
|
// Nexus 프로젝트 → 폴더째 제외 (복사 안 함)
|
|
216
303
|
} else {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
for (const f of newFiles) {
|
|
220
|
-
writeText(join(workflowsDir, f), srcText(join(serverDeployDir, f)));
|
|
221
|
-
counters.copied++;
|
|
222
|
-
counters.copiedFiles.push(f);
|
|
223
|
-
}
|
|
224
|
-
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, srcText);
|
|
304
|
+
const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters);
|
|
305
|
+
untouched.push(...c.unchanged, ...c.localOnly);
|
|
225
306
|
}
|
|
226
307
|
}
|
|
227
308
|
|
|
@@ -232,7 +313,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
232
313
|
const src = join(nexusDir, filename);
|
|
233
314
|
const dst = join(workflowsDir, filename);
|
|
234
315
|
const body = srcText(src);
|
|
235
|
-
if (existsSync(dst) &&
|
|
316
|
+
if (existsSync(dst) && renderVirtual(body, envOpts) === readFileSync(dst, "utf8")) {
|
|
236
317
|
counters.skipped++;
|
|
237
318
|
continue;
|
|
238
319
|
}
|
|
@@ -241,16 +322,17 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
241
322
|
counters.optionalCopied++;
|
|
242
323
|
counters.copied++;
|
|
243
324
|
counters.copiedFiles.push(filename);
|
|
325
|
+
baselineTargets.set(filename, { srcPath: src, envOpts, wrote: true });
|
|
244
326
|
}
|
|
245
327
|
}
|
|
246
328
|
|
|
247
|
-
// env 치환 — 이 타입의 원본 디렉토리들에서 복사돼
|
|
329
|
+
// env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
|
|
248
330
|
for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
|
|
249
331
|
if (!exists(srcDir)) continue;
|
|
250
332
|
for (const filename of listYamlFiles(srcDir)) {
|
|
251
333
|
const target = join(workflowsDir, filename);
|
|
252
|
-
if (!existsSync(target)) continue;
|
|
253
|
-
if (
|
|
334
|
+
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
335
|
+
if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
|
|
254
336
|
configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
|
|
255
337
|
}
|
|
256
338
|
}
|
|
@@ -264,11 +346,14 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
264
346
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
265
347
|
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
266
348
|
const srcText = makeSrcText(context.branches || null);
|
|
349
|
+
const baseline = readBaseline(targetRoot);
|
|
267
350
|
const branchMode = context.branches?.mode || "pr-flow";
|
|
268
|
-
|
|
351
|
+
// upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다 (issue #69).
|
|
352
|
+
const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
|
|
353
|
+
const BUCKETS = ["newFiles", "unchanged", "changed", "upstreamOnly", "localOnly", "removed"];
|
|
269
354
|
|
|
270
355
|
const merge = (result, type, excluded = null) => {
|
|
271
|
-
for (const bucket of
|
|
356
|
+
for (const bucket of BUCKETS) {
|
|
272
357
|
for (const filename of result[bucket]) {
|
|
273
358
|
if (excluded && excluded.has(filename)) continue;
|
|
274
359
|
plan[bucket].push({ filename, type });
|
|
@@ -279,7 +364,7 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
279
364
|
const commonDir = join(projectTypesDir, "common");
|
|
280
365
|
if (exists(commonDir)) {
|
|
281
366
|
const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
|
|
282
|
-
merge(classify(commonDir, workflowsDir, envOpts, srcText), "common",
|
|
367
|
+
merge(classify(commonDir, workflowsDir, envOpts, srcText, baseline), "common",
|
|
283
368
|
branchMode === "trunk-based" ? TRUNK_BASED_EXCLUDED : null);
|
|
284
369
|
}
|
|
285
370
|
|
|
@@ -296,16 +381,16 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
296
381
|
for (const type of types) {
|
|
297
382
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
298
383
|
const typeDir = join(projectTypesDir, type);
|
|
299
|
-
if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText), type);
|
|
384
|
+
if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline), type);
|
|
300
385
|
|
|
301
386
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
302
387
|
if (exists(serverDeployDir) && !includeNexus) {
|
|
303
|
-
merge(classify(serverDeployDir, workflowsDir, envOpts, srcText), type);
|
|
388
|
+
merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline), type);
|
|
304
389
|
}
|
|
305
390
|
|
|
306
391
|
const nexusDir = join(typeDir, "nexus");
|
|
307
392
|
if (exists(nexusDir) && includeNexus) {
|
|
308
|
-
merge(classify(nexusDir, workflowsDir, envOpts, srcText), type);
|
|
393
|
+
merge(classify(nexusDir, workflowsDir, envOpts, srcText, baseline), type);
|
|
309
394
|
}
|
|
310
395
|
}
|
|
311
396
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 제거 대상 판별기 — 마법사가 설치한 파일이 무엇인지 가려낸다.
|
|
2
|
+
// uninstall(항목 체크리스트)과 purge(숨김 개발 모드)의 공통 기반이며, 아무것도 지우지 않는다.
|
|
3
|
+
//
|
|
2
4
|
// 원칙: (a) 현재 payload에 존재하는 파일명과 정확히 일치하는 것, (b) 설치된 워크플로우 파일 중
|
|
3
5
|
// 마법사 관리 마커(MANAGED_WORKFLOW_MARKER)로 시작하는 것 — 이 둘의 합집합을 제거 대상으로 삼는다.
|
|
4
6
|
// (a)만으로는 과거 버전에서 설치된 뒤 이후 릴리스에서 payload 파일명이 바뀌거나 삭제된 파일을
|
|
@@ -6,12 +8,15 @@
|
|
|
6
8
|
// 없는(그러나 파일명은 여전히 현재 payload와 일치하는) 기존 설치 전체를 인식하지 못하는 회귀가
|
|
7
9
|
// 생긴다 — 그래서 두 방식을 합집합으로 병행한다. 마커는 이 수정 이후 배포되는 payload 템플릿부터
|
|
8
10
|
// 포함되므로, (b) 경로가 실제로 새로 잡아내는 것은 "이름이 바뀌거나 삭제된, 마커가 있는" 파일뿐이다.
|
|
9
|
-
// 사용자가 직접 만든 워크플로우·version.yml·README·.gitignore는
|
|
11
|
+
// 사용자가 직접 만든 워크플로우·version.yml·README·.gitignore는 대상이 아니다
|
|
10
12
|
// (version.yml은 사용자 버전 데이터 — 제거 대상이 아니라 산출물이다).
|
|
13
|
+
//
|
|
14
|
+
// 이 파일은 원래 src/commands/revert.js였다. revert 모드는 uninstall의 부분집합이라 제거됐고
|
|
15
|
+
// (issue #70), 판별 로직만 남아 commands가 아닌 core로 옮겨졌다.
|
|
11
16
|
import { join } from "node:path";
|
|
12
17
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
13
|
-
import { PATHS, PAYLOAD } from "
|
|
14
|
-
import {
|
|
18
|
+
import { PATHS, PAYLOAD } from "./paths.js";
|
|
19
|
+
import { BASELINE_DIR, BASELINE_PATH } from "./baseline.js";
|
|
15
20
|
|
|
16
21
|
// payload/workflows/**/*.yaml 첫 줄에 심어둔 고정 마커 — 이 값이 바뀌면 과거 설치분과의 매칭이 끊긴다.
|
|
17
22
|
export const MANAGED_WORKFLOW_MARKER = "# project-auto-wizard:managed-workflow";
|
|
@@ -41,13 +46,13 @@ function markedWorkflowNames(wfDir) {
|
|
|
41
46
|
return names;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
// 아무것도 지우지 않는 순수 함수 —
|
|
45
|
-
export function
|
|
49
|
+
// 아무것도 지우지 않는 순수 함수 — uninstall/purge/--dry-run이 공유한다.
|
|
50
|
+
export function planRemoval(payloadRoot, targetRoot = ".") {
|
|
46
51
|
const removedWf = new Set();
|
|
47
52
|
const removedScripts = [];
|
|
48
53
|
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
49
54
|
if (existsSync(wfDir)) {
|
|
50
|
-
// (a) 현재 payload와 파일명이 일치하는 것 —
|
|
55
|
+
// (a) 현재 payload와 파일명이 일치하는 것 — 마커 유무 무관(기존 설치 회귀 방지).
|
|
51
56
|
for (const name of payloadWorkflowNames(payloadRoot)) {
|
|
52
57
|
const p = join(wfDir, name);
|
|
53
58
|
if (existsSync(p)) removedWf.add(name);
|
|
@@ -61,14 +66,8 @@ export function planRevert(payloadRoot, targetRoot = ".") {
|
|
|
61
66
|
for (const s of ["version_manager.py", "changelog_manager.py"]) {
|
|
62
67
|
if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
|
|
63
68
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
export function runRevert(context, payloadRoot, targetRoot = ".") {
|
|
69
|
-
const plan = planRevert(payloadRoot, targetRoot);
|
|
70
|
-
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
71
|
-
for (const name of plan.workflows) remove(join(wfDir, name));
|
|
72
|
-
for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
|
|
73
|
-
return plan;
|
|
69
|
+
// baseline은 마법사가 만든 내부 상태 파일이다 — 설치물을 지우면 함께 사라져야 한다.
|
|
70
|
+
// 남겨두면 다음 설치가 "예전에 깔았다가 사용자가 지운 파일"로 오인해 전부 removed로 분류한다.
|
|
71
|
+
const baseline = existsSync(join(targetRoot, BASELINE_PATH)) ? [BASELINE_DIR] : [];
|
|
72
|
+
return { workflows: [...removedWf], scripts: removedScripts, baseline };
|
|
74
73
|
}
|
package/src/index.js
CHANGED
|
@@ -17,9 +17,6 @@ import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch, default
|
|
|
17
17
|
import { printBannerCompact } from "./ui/banner.js";
|
|
18
18
|
import { printSummary } from "./ui/summary.js";
|
|
19
19
|
import { runFull } from "./commands/full.js";
|
|
20
|
-
import { runVersion } from "./commands/version.js";
|
|
21
|
-
import { runWorkflows } from "./commands/workflows.js";
|
|
22
|
-
import { runRevert } from "./commands/revert.js";
|
|
23
20
|
import { runUninstall, runUninstallFlow } from "./commands/uninstall.js";
|
|
24
21
|
import * as prompts from "./ui/prompts.js";
|
|
25
22
|
import { runInteractive } from "./commands/interactive.js";
|
|
@@ -81,33 +78,16 @@ export async function run(argv, {
|
|
|
81
78
|
if (opts.mode === "interactive") {
|
|
82
79
|
// --dry-run은 대화형 모드에서 조용히 무시되면 안 됨(실제 설치가 진행돼버림) — 명시 에러로 차단.
|
|
83
80
|
if (opts.dryRun) {
|
|
84
|
-
console.error("--dry-run은 --mode <full|
|
|
81
|
+
console.error("--dry-run은 --mode <full|uninstall>와 함께 사용하세요 (대화형 모드에서는 지원하지 않습니다).");
|
|
85
82
|
return 1;
|
|
86
83
|
}
|
|
87
84
|
if (!process.stdout.isTTY) {
|
|
88
|
-
console.error("대화형 입력이 불가능한 환경입니다. --mode <full|
|
|
85
|
+
console.error("대화형 입력이 불가능한 환경입니다. --mode <full|uninstall> 와 --force 를 지정하세요.");
|
|
89
86
|
return 1;
|
|
90
87
|
}
|
|
91
88
|
return await runInteractive({}, { cwd, payloadRoot: payload, clock });
|
|
92
89
|
}
|
|
93
90
|
|
|
94
|
-
// revert 모드 — payload 유래 파일 제거 (감지·질문 불필요, --force 게이트만)
|
|
95
|
-
if (opts.mode === "revert") {
|
|
96
|
-
// TTY 여부와 무관하게 --force가 없으면 거부한다 (issue #19 — TTY에서 확인 없이 즉시 실행되던 결함 수정).
|
|
97
|
-
// --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
|
|
98
|
-
if (!opts.force && !opts.dryRun) {
|
|
99
|
-
console.error("revert 모드는 --force 없이 실행할 수 없습니다 (확인 절차가 없습니다).");
|
|
100
|
-
return 1;
|
|
101
|
-
}
|
|
102
|
-
if (opts.dryRun) {
|
|
103
|
-
printDryRun(planDryRun("revert", {}, payload, cwd));
|
|
104
|
-
return 0;
|
|
105
|
-
}
|
|
106
|
-
const r = runRevert({}, payload, cwd);
|
|
107
|
-
console.error(`제거됨 — 워크플로우 ${r.workflows.length}개, 스크립트 ${r.scripts.length}개`);
|
|
108
|
-
console.error("version.yml·README·.gitignore는 보존됩니다 (사용자 데이터).");
|
|
109
|
-
return 0;
|
|
110
|
-
}
|
|
111
91
|
// purge 모드 — 마법사가 만든 모든 산출물을 지워 설치 이전 상태로 완전히 되돌린다.
|
|
112
92
|
// 개발·테스트 전용 숨김 모드 — --help/대화형 메뉴에 노출하지 않는다 (issue #6).
|
|
113
93
|
if (opts.mode === "purge") {
|
|
@@ -183,7 +163,7 @@ export async function run(argv, {
|
|
|
183
163
|
return 0;
|
|
184
164
|
}
|
|
185
165
|
|
|
186
|
-
// uninstall 모드 —
|
|
166
|
+
// uninstall 모드 — 설치물에 더해 README·gitignore·version.yml까지 선택적으로 제거.
|
|
187
167
|
if (opts.mode === "uninstall") {
|
|
188
168
|
const safeSelection = {
|
|
189
169
|
workflows: true, scripts: true,
|
|
@@ -303,14 +283,10 @@ export async function run(argv, {
|
|
|
303
283
|
return 0;
|
|
304
284
|
}
|
|
305
285
|
|
|
306
|
-
// opts.mode는 parseArgs()에서 화이트리스트 검증을 통과했고, interactive/
|
|
307
|
-
// 전부 위에서 조기 반환했으므로 이 시점엔 full
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
case "full": result = runFull(context, payload, cwd); break;
|
|
311
|
-
case "version": result = runVersion(context, payload, cwd); break;
|
|
312
|
-
case "workflows": result = runWorkflows(context, payload, cwd); break;
|
|
313
|
-
}
|
|
286
|
+
// opts.mode는 parseArgs()에서 화이트리스트 검증을 통과했고, interactive/purge/uninstall/status/doctor는
|
|
287
|
+
// 전부 위에서 조기 반환했으므로 이 시점엔 full 하나로 보장된다 (issue #19 — default 분기 제거,
|
|
288
|
+
// issue #70 — 부분 설치 모드 제거로 분기 자체가 사라졌다).
|
|
289
|
+
const result = runFull(context, payload, cwd);
|
|
314
290
|
|
|
315
291
|
// 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
|
|
316
292
|
printSummary({
|
package/src/ui/prompts.js
CHANGED
|
@@ -12,10 +12,7 @@ export async function selectMode({ again = false } = {}) {
|
|
|
12
12
|
return engine.select({
|
|
13
13
|
message: again ? "다음으로 무엇을 할까요?" : "무엇을 설치할까요?",
|
|
14
14
|
options: [
|
|
15
|
-
{ value: "full", label: "
|
|
16
|
-
{ value: "version", label: "버전 관리만 — 버전 자동 증가·동기화 시스템만 설치" },
|
|
17
|
-
{ value: "workflows", label: "워크플로우만 — 빌드·배포 GitHub Actions만 설치" },
|
|
18
|
-
{ value: "revert", label: "되돌리기 — 마법사가 설치한 워크플로우·스크립트 제거" },
|
|
15
|
+
{ value: "full", label: "설치 / 업데이트 — 버전관리 + 자동화 워크플로우 (처음이라면 추천)" },
|
|
19
16
|
{ value: "uninstall", label: "완전 삭제 — 마법사가 설치·수정한 모든 항목 제거(확인 후, README·gitignore·version.yml 포함)" },
|
|
20
17
|
{ value: "status", label: "설치 상태 확인 — 읽기 전용, 버전·타입·드리프트 확인" },
|
|
21
18
|
{ value: "doctor", label: "환경 진단 — 읽기 전용, gh CLI·권한·secret 설정 점검" },
|
package/src/commands/version.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// version 모드 (.sh execute_integration version case 등가).
|
|
2
|
-
// 순서: version.yml → readme → scripts.
|
|
3
|
-
// (워크플로우를 복사하지 않으므로 충돌 백업 부산물이 생길 수 없다 — gitignore 갱신 대상 없음, issue #7.
|
|
4
|
-
// util·issue·setup-guide는 스코프 제외.)
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
-
import { writeText } from "../core/fsutil.js";
|
|
8
|
-
import { PATHS } from "../core/paths.js";
|
|
9
|
-
import { buildVersionYml, parseExisting } from "../core/version-yml.js";
|
|
10
|
-
import { readVersionYmlTemplate } from "../core/assets.js";
|
|
11
|
-
import { markerForType } from "../core/detect.js";
|
|
12
|
-
import { addVersionSectionToReadme } from "../core/copy/readme.js";
|
|
13
|
-
import { copyScripts } from "../core/copy/simple.js";
|
|
14
|
-
|
|
15
|
-
export function runVersion(context, payloadRoot, targetRoot = ".") {
|
|
16
|
-
const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
|
|
17
|
-
now, today, templateVersion = "unknown",
|
|
18
|
-
includeNexus = false, includeSecretBackup = false,
|
|
19
|
-
includeSemverAuto } = context;
|
|
20
|
-
|
|
21
|
-
const pathMarkers = new Map();
|
|
22
|
-
for (const [t] of paths) pathMarkers.set(t, markerForType(t));
|
|
23
|
-
|
|
24
|
-
// 기존 version.yml의 알려지지 않은 최상위 필드를 재생성 시 보존한다 (issue #20 M8).
|
|
25
|
-
const vyPath = join(targetRoot, PATHS.versionFile);
|
|
26
|
-
const extraTopLevel = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")).extraTopLevel : [];
|
|
27
|
-
|
|
28
|
-
writeText(join(targetRoot, PATHS.versionFile),
|
|
29
|
-
buildVersionYml({
|
|
30
|
-
templateText: readVersionYmlTemplate(payloadRoot),
|
|
31
|
-
version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
|
|
32
|
-
extraTopLevel,
|
|
33
|
-
templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeSemverAuto: includeSemverAuto !== false, optionsDate: today },
|
|
34
|
-
}));
|
|
35
|
-
addVersionSectionToReadme(version, targetRoot);
|
|
36
|
-
copyScripts(payloadRoot, targetRoot);
|
|
37
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// workflows 모드 (.sh execute_integration workflows case 등가).
|
|
2
|
-
// 순서: copy_workflows → update_version_yml_deploy(version.yml 있을 때만) → scripts.
|
|
3
|
-
// (version.yml 생성 안 함 — 기존 version.yml이 있을 때만 deploy 블록 추가.
|
|
4
|
-
// util/config/setup-guide 설치는 project-auto-wizard 스코프에서 제외 — DESIGN-SPEC §2)
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { PATHS } from "../core/paths.js";
|
|
8
|
-
import { copyWorkflows } from "../core/copy/workflows.js";
|
|
9
|
-
import { copyScripts } from "../core/copy/simple.js";
|
|
10
|
-
import { escapeYamlDoubleQuoted } from "../core/wizard-env.js";
|
|
11
|
-
|
|
12
|
-
export function runWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
13
|
-
const wf = copyWorkflows(context, payloadRoot, targetRoot, hooks);
|
|
14
|
-
|
|
15
|
-
// update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
|
|
16
|
-
const vy = join(targetRoot, PATHS.versionFile);
|
|
17
|
-
if (existsSync(vy) && wf.deployValues && wf.deployValues.size) {
|
|
18
|
-
writeFileSync(vy, upsertDeployBlock(readFileSync(vy, "utf8"), wf.deployValues));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
copyScripts(payloadRoot, targetRoot);
|
|
22
|
-
return { workflows: wf };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// 기존 version.yml에서 deploy: 블록을 제거하고 새로 append (.sh update_version_yml_deploy 멱등).
|
|
26
|
-
export function upsertDeployBlock(content, deployValues) {
|
|
27
|
-
// 기존 deploy: 블록 제거 (deploy: 라인 ~ 다음 최상위 키 전까지)
|
|
28
|
-
const lines = content.split(/\r?\n/);
|
|
29
|
-
const out = [];
|
|
30
|
-
let inDeploy = false;
|
|
31
|
-
for (const line of lines) {
|
|
32
|
-
if (/^deploy:/.test(line)) { inDeploy = true; continue; }
|
|
33
|
-
if (inDeploy) {
|
|
34
|
-
if (/^\s/.test(line) || line === "") continue; // 들여쓰기/빈줄 = deploy 내부
|
|
35
|
-
inDeploy = false;
|
|
36
|
-
}
|
|
37
|
-
out.push(line);
|
|
38
|
-
}
|
|
39
|
-
let text = out.join("\n").replace(/\n+$/, "\n");
|
|
40
|
-
// 새 deploy 블록 append
|
|
41
|
-
const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t)?.size);
|
|
42
|
-
if (deployTypes.length) {
|
|
43
|
-
text += `\ndeploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)\n`;
|
|
44
|
-
for (const t of deployTypes) {
|
|
45
|
-
text += ` ${t}:\n`;
|
|
46
|
-
// 동일한 이스케이프를 재사용 — deploy 값도 @wizard ask 값과 같은 경로로 들어오므로
|
|
47
|
-
// 큰따옴표가 섞이면 YAML이 깨진다 (issue #20 L9, 세 번째 지점).
|
|
48
|
-
for (const [k, v] of deployValues.get(t)) text += ` ${k}: "${escapeYamlDoubleQuoted(v)}"\n`;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return text;
|
|
52
|
-
}
|