projectops 4.2.15 → 4.2.17
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 +1 -1
- package/package.json +1 -1
- package/src/commands/interactive.js +41 -4
- package/src/core/breaking-check.js +4 -1
- package/src/core/copy/workflows.js +52 -15
- package/src/core/migration-guide.js +224 -0
- package/src/core/options-ask.js +22 -9
- package/src/core/run-trace.js +89 -0
- package/src/core/wizard-env.js +18 -5
- package/src/index.js +38 -7
- package/src/ui/env-plan.js +10 -6
- package/src/ui/summary.js +13 -2
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -14,6 +14,8 @@ import { runMigrations } from "../core/migrations/index.js";
|
|
|
14
14
|
import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
|
|
15
15
|
import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
|
|
16
16
|
import { askAllOptionalWorkflows, OPTION_AXES } from "../core/options-ask.js";
|
|
17
|
+
import { createRunTrace } from "../core/run-trace.js";
|
|
18
|
+
import { appendGuideEntry } from "../core/migration-guide.js";
|
|
17
19
|
import { promptEnvPlan } from "../ui/env-plan.js";
|
|
18
20
|
import { listWorkflowConflicts } from "../core/copy/workflows.js";
|
|
19
21
|
import { createContext, VALID_TYPES } from "../context.js";
|
|
@@ -31,6 +33,9 @@ const isCancel = (v) => v === CANCEL || typeof v === "symbol";
|
|
|
31
33
|
// skills = runSkills 주입 지점(테스트가 실제 IDE CLI를 안 건드리게). 기본은 실제 runSkills.
|
|
32
34
|
export async function runInteractive(baseCtx, { cwd = process.cwd(), source = { type: "git" }, clock, io = prompts, skills = runSkills } = {}) {
|
|
33
35
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
36
|
+
// 실행 트레이스 (#494) — 실제 CLI(io=prompts)에서만 터미널 미러를 켠다 (테스트 스텁 io는 이벤트만).
|
|
37
|
+
const trace = createRunTrace();
|
|
38
|
+
if (io === prompts) trace.mirrorStart();
|
|
34
39
|
try {
|
|
35
40
|
// 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
|
|
36
41
|
acquireTemplate({ tempDir, source });
|
|
@@ -53,9 +58,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
53
58
|
if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
54
59
|
|
|
55
60
|
// Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
|
|
61
|
+
let breakingReport = null; // #493 — 통과 구간 항목을 가이드에 조치 방법 전문으로 임베드
|
|
56
62
|
const proceed = await runBreakingCheck({
|
|
57
63
|
cwd, tempDir, templateVersion,
|
|
58
64
|
askYesNo: (msg, def) => io.askYesNo(msg, def),
|
|
65
|
+
onItems: (items) => { breakingReport = items; },
|
|
59
66
|
});
|
|
60
67
|
if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
|
|
61
68
|
|
|
@@ -89,6 +96,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
89
96
|
let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
|
|
90
97
|
let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
|
|
91
98
|
let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
|
|
99
|
+
let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
|
|
100
|
+
let deployBranchCreated = null; // #493 — 마법사가 직접 생성했는지 (가이드 기록용)
|
|
92
101
|
let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
|
|
93
102
|
const showOptional = mode === "full" || mode === "workflows";
|
|
94
103
|
const realTty = process.stdout.isTTY === true;
|
|
@@ -123,6 +132,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
123
132
|
changelogProvider = r.changelogProvider;
|
|
124
133
|
changelogBaseUrl = r.changelogBaseUrl;
|
|
125
134
|
deployBranch = r.deployBranch;
|
|
135
|
+
deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
|
|
136
|
+
deployBranchCreated = r.deployBranchCreated ?? deployBranchCreated; // #493
|
|
126
137
|
intent = r.intent;
|
|
127
138
|
}
|
|
128
139
|
|
|
@@ -186,6 +197,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
186
197
|
changelogProvider = r.changelogProvider;
|
|
187
198
|
changelogBaseUrl = r.changelogBaseUrl;
|
|
188
199
|
deployBranch = r.deployBranch;
|
|
200
|
+
deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
|
|
201
|
+
deployBranchCreated = r.deployBranchCreated ?? deployBranchCreated; // #493
|
|
189
202
|
intent = r.intent;
|
|
190
203
|
}
|
|
191
204
|
}
|
|
@@ -249,14 +262,18 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
// 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 대화형은 safe 티어 확인 1회.
|
|
265
|
+
let migrationsResult = null; // #493 — 가이드 기록용
|
|
252
266
|
if (mode === "full" || mode === "workflows") {
|
|
253
|
-
await runMigrations({
|
|
267
|
+
migrationsResult = await runMigrations({
|
|
254
268
|
targetRoot: cwd,
|
|
255
269
|
askYesNo: (msg, def) => io.askYesNo(msg, def),
|
|
256
270
|
});
|
|
271
|
+
for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
|
|
272
|
+
for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
|
|
257
273
|
}
|
|
258
274
|
|
|
259
275
|
// 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
|
|
276
|
+
const orphanReport = { cleaned: [], pending: [] }; // #493 — 가이드 기록용
|
|
260
277
|
if (mode === "full" || mode === "workflows") {
|
|
261
278
|
const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
|
|
262
279
|
if (orphans.length > 0) {
|
|
@@ -269,28 +286,48 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
269
286
|
const results = applyOrphanCleanup(cwd, orphans);
|
|
270
287
|
const ok = results.filter((r) => r.action === "bak");
|
|
271
288
|
const failed = results.filter((r) => r.action === "error");
|
|
289
|
+
orphanReport.cleaned = ok.map((r) => r.filename);
|
|
290
|
+
for (const r of ok) trace.event("orphan", "neutralized", r.filename);
|
|
272
291
|
io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
|
|
292
|
+
} else {
|
|
293
|
+
orphanReport.pending = orphans.map((o) => o.filename);
|
|
273
294
|
}
|
|
274
295
|
}
|
|
275
296
|
}
|
|
276
297
|
|
|
277
298
|
let result = null;
|
|
278
|
-
if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
|
|
299
|
+
if (mode === "full") result = runFull(ctx, tempDir, cwd, { ...hooks, trace });
|
|
279
300
|
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
|
280
|
-
else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, hooks);
|
|
301
|
+
else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
|
|
281
302
|
|
|
282
303
|
// 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
|
|
283
304
|
const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
|
|
284
305
|
if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
|
|
285
306
|
|
|
307
|
+
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
|
|
308
|
+
let migrationGuidePath = null;
|
|
309
|
+
if (mode === "full" || mode === "workflows") {
|
|
310
|
+
const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now });
|
|
311
|
+
migrationGuidePath = appendGuideEntry(cwd, {
|
|
312
|
+
now, mode, types, repoName,
|
|
313
|
+
templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
|
|
314
|
+
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent },
|
|
315
|
+
branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
|
|
316
|
+
breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
|
|
317
|
+
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
318
|
+
traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
|
|
319
|
+
}).guidePath;
|
|
320
|
+
}
|
|
321
|
+
|
|
286
322
|
// 완료 요약 (.sh print_summary L5438)
|
|
287
323
|
io.summary?.({
|
|
288
|
-
mode, types, version, deployBranch,
|
|
324
|
+
mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
|
|
289
325
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
290
326
|
}, cwd);
|
|
291
327
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
|
292
328
|
return 0;
|
|
293
329
|
} finally {
|
|
330
|
+
trace.mirrorStop();
|
|
294
331
|
remove(tempDir);
|
|
295
332
|
}
|
|
296
333
|
}
|
|
@@ -27,7 +27,9 @@ export async function loadBreakingJson(tempDir) {
|
|
|
27
27
|
// templateVersion - 설치하려는 템플릿 버전 (.sh의 DEFAULT_VERSION 고정 버그를 실버전으로 교정 — 설계 D2)
|
|
28
28
|
// askYesNo - async(message, defaultYes)→bool. null이면 비대화형: 경고만 출력 후 진행
|
|
29
29
|
// loader - 테스트 주입용 (기본 loadBreakingJson)
|
|
30
|
-
|
|
30
|
+
// onItems - (#493) 통과 구간 항목 콜백 ({current, target, critical, warnings}) —
|
|
31
|
+
// 마이그레이션 가이드가 조치 방법 전문을 임베드하는 데 쓴다 (표시 여부와 무관하게 호출)
|
|
32
|
+
export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo = null, loader = loadBreakingJson, onItems = null }) {
|
|
31
33
|
const vy = join(cwd, "version.yml");
|
|
32
34
|
if (!existsSync(vy)) return true; // 신규 통합 — 비교 기준 없음
|
|
33
35
|
const { templateVersion: current } = parseExisting(readFileSync(vy, "utf8"));
|
|
@@ -37,6 +39,7 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
|
|
|
37
39
|
if (!json) return true;
|
|
38
40
|
|
|
39
41
|
const { critical, warnings } = collectBreaking(json, current, templateVersion);
|
|
42
|
+
if (critical.length || warnings.length) onItems?.({ current, target: templateVersion, critical, warnings });
|
|
40
43
|
if (critical.length === 0 && warnings.length === 0) return true;
|
|
41
44
|
|
|
42
45
|
// 요약 리스트 표시 (#473 — 전문 통덤프는 벽글이 되어 정작 CRITICAL이 안 읽혔고,
|
|
@@ -11,11 +11,27 @@ import { substituteBranches } from "../branch-sub.js";
|
|
|
11
11
|
|
|
12
12
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
13
13
|
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
14
|
-
|
|
14
|
+
// trace(#494): 치환 1건당 env.substituted 이벤트 (before/after 포함 — 치환 불일치류 버그의 즉시 발견 근거).
|
|
15
|
+
function configureEnv(targetPath, filename, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true, trace = null }) {
|
|
15
16
|
const content = readFileSync(targetPath, "utf8");
|
|
16
17
|
if (!content.includes("@wizard")) return;
|
|
17
|
-
const
|
|
18
|
+
const collectSubs = trace ? [] : null;
|
|
19
|
+
const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks, collectSubs });
|
|
18
20
|
writeFileSync(targetPath, out);
|
|
21
|
+
if (trace && collectSubs) {
|
|
22
|
+
for (const s of collectSubs) trace.event("env", "substituted", filename, { type, ...s });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// util 버전 동기화 워크플로우 (#491) — .github/util/ 모듈(version.json)이 있는 레포에서만 의미가 있다.
|
|
27
|
+
// util이 없는 레포(예: spring 단독)에 복사하면 트리거가 영원히 안 걸리는 no-op 오염이 된다.
|
|
28
|
+
const UTIL_VERSION_SYNC = "PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC.yml";
|
|
29
|
+
|
|
30
|
+
// 이 통합에서 util 모듈이 존재하게 되는가 — 대상에 이미 있거나(업데이트),
|
|
31
|
+
// 선택 타입의 util이 템플릿에 있어 곧 복사될 예정(runFull 6단계)이면 true.
|
|
32
|
+
function utilSyncApplies(tempDir, targetRoot, types) {
|
|
33
|
+
if (exists(join(targetRoot, ".github", "util"))) return true;
|
|
34
|
+
return types.some((t) => exists(join(tempDir, ".github", "util", t)));
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
// 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
|
|
@@ -46,6 +62,7 @@ function classify(srcDir, workflowsDir, envOpts) {
|
|
|
46
62
|
export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
47
63
|
const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
|
|
48
64
|
const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
|
|
65
|
+
const trace = hooks.trace ?? null; // #494 — 실행 트레이스 (null-safe: 미주입이면 전 이벤트 no-op)
|
|
49
66
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
50
67
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
51
68
|
if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
|
|
@@ -62,22 +79,29 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
62
79
|
const commonDir = join(projectTypesDir, "common");
|
|
63
80
|
if (exists(commonDir)) {
|
|
64
81
|
for (const filename of listYamlFiles(commonDir)) {
|
|
82
|
+
// #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
|
|
83
|
+
if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) {
|
|
84
|
+
trace?.event("copy", "excluded", filename, { reason: "util-modules-absent" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
65
87
|
const src = join(commonDir, filename);
|
|
66
88
|
const dst = join(workflowsDir, filename);
|
|
67
89
|
if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
|
|
68
90
|
counters.skipped++;
|
|
91
|
+
trace?.event("copy", "skipped-unchanged", filename, { group: "common" });
|
|
69
92
|
continue;
|
|
70
93
|
}
|
|
71
94
|
copyFileSync(src, dst);
|
|
72
95
|
counters.copied++;
|
|
73
96
|
counters.copiedFiles.push(filename);
|
|
97
|
+
trace?.event("copy", "copied", filename, { group: "common" });
|
|
74
98
|
}
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
// (2~4) 타입별
|
|
78
102
|
for (const type of types) {
|
|
79
103
|
const asks = new Map();
|
|
80
|
-
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions }, counters);
|
|
104
|
+
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions, trace }, counters);
|
|
81
105
|
if (asks.size) deployValues.set(type, asks);
|
|
82
106
|
}
|
|
83
107
|
|
|
@@ -89,13 +113,16 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
89
113
|
const dst = join(workflowsDir, filename);
|
|
90
114
|
if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
|
|
91
115
|
counters.skipped++;
|
|
116
|
+
trace?.event("copy", "skipped-unchanged", filename, { group: "common-deploy" });
|
|
92
117
|
continue;
|
|
93
118
|
}
|
|
94
|
-
|
|
119
|
+
const backedUp = existsSync(dst);
|
|
120
|
+
if (backedUp) renameSync(dst, dst + ".bak");
|
|
95
121
|
copyFileSync(src, dst);
|
|
96
122
|
counters.optionalCopied++;
|
|
97
123
|
counters.copied++;
|
|
98
124
|
counters.copiedFiles.push(filename);
|
|
125
|
+
trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "common-deploy" });
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
|
|
@@ -109,6 +136,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
109
136
|
counters.optionalCopied++;
|
|
110
137
|
counters.copied++;
|
|
111
138
|
counters.copiedFiles.push(filename);
|
|
139
|
+
trace?.event("copy", "copied", filename, { group: "secret-backup" });
|
|
112
140
|
}
|
|
113
141
|
}
|
|
114
142
|
|
|
@@ -120,7 +148,10 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
120
148
|
if (!existsSync(p)) continue;
|
|
121
149
|
const before = readFileSync(p, "utf8");
|
|
122
150
|
const after = substituteBranches(before, branches);
|
|
123
|
-
if (after !== before)
|
|
151
|
+
if (after !== before) {
|
|
152
|
+
writeFileSync(p, after);
|
|
153
|
+
trace?.event("env", "branch-substituted", f, { defaultBranch: branches.defaultBranch, deployBranch: branches.deployBranch });
|
|
154
|
+
}
|
|
124
155
|
}
|
|
125
156
|
}
|
|
126
157
|
|
|
@@ -129,7 +160,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
129
160
|
|
|
130
161
|
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
131
162
|
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
132
|
-
function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
|
|
163
|
+
function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
|
|
133
164
|
const src = join(srcDir, filename);
|
|
134
165
|
const dst = join(workflowsDir, filename);
|
|
135
166
|
if (decision === "backup") {
|
|
@@ -138,6 +169,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
|
|
|
138
169
|
copyFileSync(src, dst);
|
|
139
170
|
counters.copied++;
|
|
140
171
|
counters.copiedFiles?.push(filename);
|
|
172
|
+
trace?.event("copy", "replaced-bak", filename, { decision });
|
|
141
173
|
return;
|
|
142
174
|
}
|
|
143
175
|
if (decision === "template") {
|
|
@@ -145,9 +177,11 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
|
|
|
145
177
|
const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
|
|
146
178
|
copyFileSync(src, join(workflowsDir, templateName)); // cp가 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
|
|
147
179
|
counters.templateAdded++;
|
|
180
|
+
trace?.event("copy", "template-added", templateName, { original: filename });
|
|
148
181
|
return;
|
|
149
182
|
}
|
|
150
183
|
counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
|
|
184
|
+
trace?.event("copy", "skipped-conflict", filename, { decision: decision ?? "skip", note: "사용자 수정본 유지 — 병합 검토 후보" });
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
// 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
|
|
@@ -190,7 +224,7 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
|
|
|
190
224
|
const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
|
|
191
225
|
|
|
192
226
|
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
193
|
-
const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
|
|
227
|
+
const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null } = ctx;
|
|
194
228
|
const typeDir = join(projectTypesDir, type);
|
|
195
229
|
const envOpts = envOptsFor(type);
|
|
196
230
|
let unchangedNames = [];
|
|
@@ -199,19 +233,19 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
199
233
|
if (exists(typeDir)) {
|
|
200
234
|
const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
|
|
201
235
|
unchangedNames = unchanged.slice();
|
|
202
|
-
for (const f of unchanged) counters.skipped++;
|
|
203
|
-
for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
|
|
236
|
+
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
|
|
237
|
+
for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: type }); }
|
|
204
238
|
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
205
|
-
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters);
|
|
239
|
+
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
|
|
206
240
|
}
|
|
207
241
|
|
|
208
242
|
// server-deploy — deploy=docker-ssh일 때만 포함 (#439)
|
|
209
243
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
210
244
|
if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
|
|
211
245
|
const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
|
|
212
|
-
for (const f of unchanged) counters.skipped++;
|
|
213
|
-
for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
|
|
214
|
-
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
|
|
246
|
+
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
|
|
247
|
+
for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
|
|
248
|
+
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
|
|
215
249
|
}
|
|
216
250
|
|
|
217
251
|
// publish/<target> (opt-in — #439 publish 축. 타입은 파일 위치일 뿐 게이트가 아니다)
|
|
@@ -225,13 +259,16 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
225
259
|
const dst = join(workflowsDir, filename);
|
|
226
260
|
if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
|
|
227
261
|
counters.skipped++;
|
|
262
|
+
trace?.event("copy", "skipped-unchanged", filename, { group: `${type}/publish/${target}` });
|
|
228
263
|
continue;
|
|
229
264
|
}
|
|
230
|
-
|
|
265
|
+
const backedUp = existsSync(dst);
|
|
266
|
+
if (backedUp) renameSync(dst, dst + ".bak");
|
|
231
267
|
copyFileSync(src, dst);
|
|
232
268
|
counters.optionalCopied++;
|
|
233
269
|
counters.copied++;
|
|
234
270
|
counters.copiedFiles.push(filename);
|
|
271
|
+
trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: `${type}/publish/${target}` });
|
|
235
272
|
}
|
|
236
273
|
}
|
|
237
274
|
|
|
@@ -242,7 +279,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
242
279
|
const target = join(workflowsDir, filename);
|
|
243
280
|
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
244
281
|
if (unchangedNames.includes(filename)) continue; // unchanged 제외
|
|
245
|
-
configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
|
|
282
|
+
configureEnv(target, filename, { ...envOpts, collectAsks, trace }); // env 계획 values/useDefaults 포함
|
|
246
283
|
}
|
|
247
284
|
}
|
|
248
285
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// 마이그레이션 가이드 (#493) — Layer 1 큐레이션 문서.
|
|
2
|
+
// 마법사(full/workflows) 실행이 끝나면 대상 레포의 docs/projectops/migration/PROJECTOPS-MIGRATION-GUIDE.md에
|
|
3
|
+
// "고정 헤더(최초 1회) + 실행 엔트리(append-only)"를 남긴다. 사람용 동적 체크리스트와
|
|
4
|
+
// AI Agent용 yaml 메타데이터를 한 엔트리에 담고, run-trace(#494)의 events를 단일 소스로 소비한다.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
7
|
+
import { writeText } from "./fsutil.js";
|
|
8
|
+
import { MIGRATION_DIR } from "./run-trace.js";
|
|
9
|
+
|
|
10
|
+
export const GUIDE_FILE = `${MIGRATION_DIR}/PROJECTOPS-MIGRATION-GUIDE.md`;
|
|
11
|
+
export const GUIDE_SCHEMA = 1;
|
|
12
|
+
|
|
13
|
+
// 고정 헤더 — 문서 목적 + AI Agent 해석 가이드라인. 최초 생성 시 1회만 쓴다 (엔트리와 분리).
|
|
14
|
+
const HEADER = `# ProjectOps 마이그레이션 가이드
|
|
15
|
+
|
|
16
|
+
이 문서는 projectops 마법사(\`npx projectops\`)가 **실행을 마칠 때마다 자동으로 기록**하는
|
|
17
|
+
마이그레이션 이력입니다. 실행 엔트리는 아래에 시간순으로 append되며, 기존 엔트리는 수정되지 않습니다.
|
|
18
|
+
|
|
19
|
+
## 사람이 읽는 법
|
|
20
|
+
|
|
21
|
+
- 각 엔트리의 **확인 체크리스트**가 그 실행 이후 사람이 마무리해야 하는 수동 작업입니다.
|
|
22
|
+
- 체크리스트는 실행에서 실제 발생한 것만 나옵니다 — 항목이 없으면 할 일이 없다는 뜻입니다.
|
|
23
|
+
- 상세 실행 기록(파일별 결정·치환 전후값)은 엔트리 메타의 \`trace_file\`(JSONL)·\`log_file\`(터미널 원문)에 있습니다.
|
|
24
|
+
|
|
25
|
+
## AI Agent 해석 가이드라인
|
|
26
|
+
|
|
27
|
+
각 엔트리의 \`\`\`yaml 메타 블록(\`# projectops-migration\`)을 파싱해 아래 규칙으로 판단·조치한다.
|
|
28
|
+
|
|
29
|
+
| 필드 | 의미 | 권장 조치 |
|
|
30
|
+
|------|------|----------|
|
|
31
|
+
| \`workflows.leftover_old_gen\` | 구세대 워크플로우가 현역 가능성으로 보존됨 | 신형과 트리거 중복 여부 검사 → 전환 확인 후 구 파일 삭제 제안 |
|
|
32
|
+
| \`workflows.skipped_conflict\` | 사용자 수정본이 유지됨 (신형 미적용) | 사용자본과 신형 템플릿 diff → 병합 제안 |
|
|
33
|
+
| \`workflows.replaced_bak\` | .bak 백업 후 신형으로 교체됨 | .bak과 신형 비교 → 사용자 커스텀 유실분 복원 검토 후 .bak 정리 |
|
|
34
|
+
| \`env_applied\` | 워크플로우에 적용된 환경값 | 실제 워크플로우 env와 대조 → 드리프트 발견 시 경고 |
|
|
35
|
+
| \`breaking_traversed\` | 이 실행이 통과한 호환성 변경 (조치 방법 전문은 사람용 섹션) | \`action_required: true\` 항목의 조치 완료 여부 확인 |
|
|
36
|
+
| \`manual_actions_pending\` | 남은 수동 작업 코드 목록 | 비어 있지 않으면 사용자에게 상기 |
|
|
37
|
+
| \`trace_file\` | 파일별 결정·치환 전후값 JSONL (Layer 2) | "왜 이 파일이 이렇게 됐나"는 파일명으로 grep |
|
|
38
|
+
| \`log_file\` | 터미널 출력 원문 (Layer 3) | 실행 재현·포렌식 디버깅용 |
|
|
39
|
+
|
|
40
|
+
- 스키마는 \`schema\` 필드로 버저닝된다. 모르는 필드는 무시하고, 아는 필드만 사용한다.
|
|
41
|
+
- 여러 엔트리가 있으면 **가장 최근 엔트리**가 현재 상태의 기준이다. 과거 엔트리는 이력 참고용.
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
// ── yaml 렌더 헬퍼 (외부 의존성 없이 수동 직렬화 — version-yml.js와 동일 원칙) ──
|
|
45
|
+
const yq = (s) => `"${String(s ?? "").replaceAll('"', '\\"')}"`;
|
|
46
|
+
const ylist = (arr) => (arr && arr.length ? `[${arr.map(yq).join(", ")}]` : "[]");
|
|
47
|
+
|
|
48
|
+
// trace events → 가이드용 파일 목록 파생 (단일 소스 — 이벤트에서 유도).
|
|
49
|
+
export function deriveWorkflowLists(events = []) {
|
|
50
|
+
const pick = (action) => events.filter((e) => e.phase === "copy" && e.action === action).map((e) => e.target);
|
|
51
|
+
return {
|
|
52
|
+
added: pick("copied"),
|
|
53
|
+
replacedBak: pick("replaced-bak"),
|
|
54
|
+
skippedConflict: pick("skipped-conflict"),
|
|
55
|
+
templateAdded: pick("template-added"),
|
|
56
|
+
excluded: pick("excluded"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// trace events → 타입별 적용 env 값 파생.
|
|
61
|
+
export function deriveEnvApplied(events = []) {
|
|
62
|
+
const byType = new Map();
|
|
63
|
+
for (const e of events) {
|
|
64
|
+
if (e.phase !== "env" || e.action !== "substituted") continue;
|
|
65
|
+
const t = e.detail?.type ?? "";
|
|
66
|
+
if (!byType.has(t)) byType.set(t, new Map());
|
|
67
|
+
byType.get(t).set(e.detail?.key ?? "", e.detail?.after ?? "");
|
|
68
|
+
}
|
|
69
|
+
return byType;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 실행 엔트리 렌더링. report:
|
|
73
|
+
// { now, mode, types, repoName, templateFrom, templateTo,
|
|
74
|
+
// options: {deploy, publish, secretBackup, coderabbit, changelogProvider, intent},
|
|
75
|
+
// branches: {defaultBranch, deployBranch, ready, created},
|
|
76
|
+
// breaking: {current, target, critical:[], warnings:[]} | null,
|
|
77
|
+
// migrations: {applied:[], confirmPending:[], askPending:[]} | null,
|
|
78
|
+
// orphans: {cleaned:[], pending:[]} | null,
|
|
79
|
+
// events: [], counters: {}, traceFile, logFile }
|
|
80
|
+
export function renderGuideEntry(report) {
|
|
81
|
+
const r = report ?? {};
|
|
82
|
+
const from = r.templateFrom || "new";
|
|
83
|
+
const to = r.templateTo || "unknown";
|
|
84
|
+
const wf = deriveWorkflowLists(r.events);
|
|
85
|
+
const envByType = deriveEnvApplied(r.events);
|
|
86
|
+
const breaking = r.breaking ?? null;
|
|
87
|
+
const breakingAll = breaking ? [...(breaking.critical ?? []), ...(breaking.warnings ?? [])] : [];
|
|
88
|
+
const mig = r.migrations ?? null;
|
|
89
|
+
const leftoverOldGen = (mig?.confirmPending ?? []).map((e) => ({ file: e.file, replacement: e.replacedBy || "", reason: e.reason || "" }));
|
|
90
|
+
const legacyNeutralized = (mig?.applied ?? []).filter((a) => a.action !== "error");
|
|
91
|
+
const orphanCleaned = (r.orphans?.cleaned ?? []);
|
|
92
|
+
|
|
93
|
+
const L = [];
|
|
94
|
+
L.push("---");
|
|
95
|
+
L.push("");
|
|
96
|
+
L.push(`## ${r.now || ""} — v${from} → v${to} (${r.mode || "full"})`);
|
|
97
|
+
L.push("");
|
|
98
|
+
L.push(`- 타입: ${(r.types ?? []).join(", ") || "-"} · 배포: ${r.options?.deploy ?? "-"} · publish: ${(r.options?.publish ?? []).join(",") || "없음"}`);
|
|
99
|
+
L.push(`- 워크플로우: 신규/갱신 ${wf.added.length + wf.replacedBak.length}개 · 유지(unchanged/충돌스킵) ${(r.counters?.skipped ?? 0)}개`);
|
|
100
|
+
L.push("");
|
|
101
|
+
|
|
102
|
+
// ── 확인 체크리스트 (동적 — 실제 발생분만) ──
|
|
103
|
+
const checklist = [];
|
|
104
|
+
if (leftoverOldGen.length) {
|
|
105
|
+
checklist.push(`- [ ] **구세대 배포 워크플로우 ${leftoverOldGen.length}개 전환 후 삭제** — 현역 배포일 수 있어 마법사가 건드리지 않았습니다:`);
|
|
106
|
+
for (const o of leftoverOldGen) checklist.push(` - \`${o.file}\`${o.replacement ? ` → 신형 \`${o.replacement}\`` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
if (wf.replacedBak.length || legacyNeutralized.length) {
|
|
109
|
+
checklist.push(`- [ ] **.bak 백업 파일 확인 후 정리** — 커스텀 유실분이 없는지 신형과 비교하세요:`);
|
|
110
|
+
for (const f of wf.replacedBak) checklist.push(` - \`${f}.bak\` (충돌 교체 백업)`);
|
|
111
|
+
for (const a of legacyNeutralized) if (a.to && String(a.to).endsWith(".bak")) checklist.push(` - \`${a.to}\` (레거시 무해화)`);
|
|
112
|
+
}
|
|
113
|
+
if (wf.skippedConflict.length) {
|
|
114
|
+
checklist.push(`- [ ] **기존 수정본 유지 ${wf.skippedConflict.length}개 — 신형과 병합 검토**: ${wf.skippedConflict.map((f) => `\`${f}\``).join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
if (wf.added.length || wf.replacedBak.length) {
|
|
117
|
+
checklist.push(`- [ ] **새/갱신 CICD가 요구하는 GitHub Secrets 등록 확인** (Settings → Secrets → Actions, \`_GITHUB_PAT_TOKEN\` 포함)`);
|
|
118
|
+
}
|
|
119
|
+
if (envByType.size) {
|
|
120
|
+
checklist.push(`- [ ] **적용된 배포 환경값 검증** — 실제 환경과 다르면 워크플로우 env를 직접 수정:`);
|
|
121
|
+
for (const [t, kv] of envByType) {
|
|
122
|
+
for (const [k, v] of kv) checklist.push(` - ${t} · \`${k}\` = \`${v}\``);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (r.branches?.created === true) {
|
|
126
|
+
checklist.push(`- [x] 개발(릴리스 소스) 브랜치 \`${r.branches?.deployBranch ?? "develop"}\` — 마법사가 생성·확인 완료`);
|
|
127
|
+
} else if (r.branches?.ready === false) {
|
|
128
|
+
checklist.push(`- [ ] 개발(릴리스 소스) 브랜치 \`${r.branches?.deployBranch ?? "develop"}\` 생성 — 릴리스 PR이 동작하려면 필요합니다`);
|
|
129
|
+
}
|
|
130
|
+
if (checklist.length) {
|
|
131
|
+
L.push("### 확인 체크리스트");
|
|
132
|
+
L.push("");
|
|
133
|
+
L.push(...checklist);
|
|
134
|
+
L.push("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 버전 점프에서 통과한 호환성 변경 (조치 방법 전문 — 터미널에서 스킵해도 여기 남는다) ──
|
|
138
|
+
if (breakingAll.length) {
|
|
139
|
+
L.push(`### 통과한 호환성 변경 (v${breaking.current} → v${breaking.target})`);
|
|
140
|
+
L.push("");
|
|
141
|
+
for (const it of breakingAll) {
|
|
142
|
+
const sev = (breaking.critical ?? []).includes(it) ? "CRITICAL" : "WARNING";
|
|
143
|
+
L.push(`#### ${sev === "CRITICAL" ? "❗" : "⚠️"} [${sev}] ${it.version} — ${it.title || ""}`);
|
|
144
|
+
if (it.message) L.push(`${it.message}`);
|
|
145
|
+
L.push("");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── AI 메타데이터 ──
|
|
150
|
+
L.push("### AI 메타데이터");
|
|
151
|
+
L.push("");
|
|
152
|
+
L.push("```yaml");
|
|
153
|
+
L.push("# projectops-migration (machine-readable)");
|
|
154
|
+
L.push(`schema: ${GUIDE_SCHEMA}`);
|
|
155
|
+
L.push(`run_at: ${yq(r.now || "")}`);
|
|
156
|
+
L.push(`template: { from: ${yq(from)}, to: ${yq(to)} }`);
|
|
157
|
+
L.push(`mode: ${r.mode || "full"}`);
|
|
158
|
+
L.push(`types: ${ylist(r.types)}`);
|
|
159
|
+
L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")} }`);
|
|
160
|
+
L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
|
|
161
|
+
L.push("workflows:");
|
|
162
|
+
L.push(` added: ${ylist(wf.added)}`);
|
|
163
|
+
L.push(` replaced_bak: ${ylist(wf.replacedBak)}`);
|
|
164
|
+
L.push(` skipped_conflict: ${ylist(wf.skippedConflict)}`);
|
|
165
|
+
L.push(` template_added: ${ylist(wf.templateAdded)}`);
|
|
166
|
+
if (legacyNeutralized.length) {
|
|
167
|
+
L.push(" legacy_neutralized:");
|
|
168
|
+
for (const a of legacyNeutralized) L.push(` - { file: ${yq(a.from ?? a.id ?? "")}, to: ${yq(a.to ?? "")}, action: ${yq(a.action ?? "")} }`);
|
|
169
|
+
} else {
|
|
170
|
+
L.push(" legacy_neutralized: []");
|
|
171
|
+
}
|
|
172
|
+
if (leftoverOldGen.length) {
|
|
173
|
+
L.push(" leftover_old_gen:");
|
|
174
|
+
for (const o of leftoverOldGen) L.push(` - { file: ${yq(o.file)}, replacement: ${yq(o.replacement)} }`);
|
|
175
|
+
} else {
|
|
176
|
+
L.push(" leftover_old_gen: []");
|
|
177
|
+
}
|
|
178
|
+
if (orphanCleaned.length) L.push(` orphan_neutralized: ${ylist(orphanCleaned)}`);
|
|
179
|
+
if (envByType.size) {
|
|
180
|
+
L.push("env_applied:");
|
|
181
|
+
for (const [t, kv] of envByType) {
|
|
182
|
+
L.push(` ${t}:`);
|
|
183
|
+
for (const [k, v] of kv) L.push(` ${k}: ${yq(v)}`);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
L.push("env_applied: {}");
|
|
187
|
+
}
|
|
188
|
+
if (breakingAll.length) {
|
|
189
|
+
L.push("breaking_traversed:");
|
|
190
|
+
for (const it of breakingAll) {
|
|
191
|
+
const sev = (breaking.critical ?? []).includes(it) ? "critical" : "warning";
|
|
192
|
+
L.push(` - { version: ${yq(it.version)}, severity: ${sev}, title: ${yq(it.title ?? "")}, action_required: ${sev === "critical"} }`);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
L.push("breaking_traversed: []");
|
|
196
|
+
}
|
|
197
|
+
const pending = [];
|
|
198
|
+
if (leftoverOldGen.length) pending.push("delete-old-gen-workflows");
|
|
199
|
+
if (wf.replacedBak.length || legacyNeutralized.some((a) => a.to && String(a.to).endsWith(".bak"))) pending.push("review-bak-files");
|
|
200
|
+
if (wf.skippedConflict.length) pending.push("merge-skipped-conflicts");
|
|
201
|
+
if (wf.added.length || wf.replacedBak.length) pending.push("register-secrets");
|
|
202
|
+
if (r.branches?.ready === false) pending.push("create-deploy-branch");
|
|
203
|
+
L.push(`manual_actions_pending: ${ylist(pending)}`);
|
|
204
|
+
L.push(`trace_file: ${yq(r.traceFile ?? "")}`);
|
|
205
|
+
L.push(`log_file: ${yq(r.logFile ?? "")}`);
|
|
206
|
+
L.push("```");
|
|
207
|
+
L.push("");
|
|
208
|
+
return L.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 가이드 파일에 엔트리 append (파일 없으면 헤더부터 생성). 반환: { guidePath, created }.
|
|
212
|
+
export function appendGuideEntry(targetRoot, report) {
|
|
213
|
+
const guidePath = join(targetRoot, GUIDE_FILE);
|
|
214
|
+
const entry = renderGuideEntry(report);
|
|
215
|
+
const created = !existsSync(guidePath);
|
|
216
|
+
if (created) {
|
|
217
|
+
writeText(guidePath, HEADER + "\n" + entry);
|
|
218
|
+
} else {
|
|
219
|
+
// append-only — 기존 엔트리 불변 (이력 보존 계약)
|
|
220
|
+
const prev = readFileSync(guidePath, "utf8");
|
|
221
|
+
appendFileSync(guidePath, (prev.endsWith("\n") ? "" : "\n") + entry);
|
|
222
|
+
}
|
|
223
|
+
return { guidePath: GUIDE_FILE, created };
|
|
224
|
+
}
|
package/src/core/options-ask.js
CHANGED
|
@@ -16,34 +16,39 @@ import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
|
|
|
16
16
|
// 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
|
|
17
17
|
// 로컬에 없고 원격에도 없(거나 불명이)면 기본 브랜치에서 생성을 제안하고, 생성 후 push 여부도 묻는다.
|
|
18
18
|
// git 미설치·비레포·질문 불가(io.confirm 없음)면 조용히 통과 — 마법사 진행을 막지 않는다.
|
|
19
|
+
// 반환 ready (#490 — 완료 요약이 "브랜치 만들어라" 재지시를 접을지 판단):
|
|
20
|
+
// true = 브랜치가 이미 있거나 이번 실행에서 생성함 → 요약에서 생성 안내 불필요
|
|
21
|
+
// false = 없는데 사용자가 거절/생성 실패 → 안내 유지
|
|
22
|
+
// null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
|
|
19
23
|
export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
|
|
20
|
-
if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false };
|
|
24
|
+
if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null };
|
|
21
25
|
const st = branchStatus(targetRoot, deployBranch);
|
|
22
|
-
if (!st.isRepo
|
|
26
|
+
if (!st.isRepo) return { created: false, pushed: false, ready: null };
|
|
27
|
+
if (st.local) return { created: false, pushed: false, ready: true };
|
|
23
28
|
if (st.remote === true) {
|
|
24
29
|
say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
|
|
25
|
-
return { created: false, pushed: false };
|
|
30
|
+
return { created: false, pushed: false, ready: true };
|
|
26
31
|
}
|
|
27
32
|
say(`⚠️ 개발(릴리스 소스) 브랜치 '${deployBranch}'가 없습니다 — 릴리스(${deployBranch}→${defaultBranch || "기본 브랜치"} PR)가 동작하려면 필요합니다.`);
|
|
28
33
|
const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
|
|
29
34
|
if (mk !== true) {
|
|
30
35
|
say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
|
|
31
|
-
return { created: false, pushed: false };
|
|
36
|
+
return { created: false, pushed: false, ready: false };
|
|
32
37
|
}
|
|
33
38
|
if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
|
|
34
39
|
say(`⚠️ 브랜치 생성 실패 — 직접 실행해주세요: git branch ${deployBranch}${defaultBranch ? ` ${defaultBranch}` : ""}`);
|
|
35
|
-
return { created: false, pushed: false };
|
|
40
|
+
return { created: false, pushed: false, ready: false };
|
|
36
41
|
}
|
|
37
42
|
say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
|
|
38
43
|
// #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
|
|
39
44
|
const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
|
|
40
|
-
if (up !== true) return { created: true, pushed: false };
|
|
45
|
+
if (up !== true) return { created: true, pushed: false, ready: true };
|
|
41
46
|
if (pushBranch(targetRoot, deployBranch)) {
|
|
42
47
|
say(`✅ origin/${deployBranch} push 완료`);
|
|
43
|
-
return { created: true, pushed: true };
|
|
48
|
+
return { created: true, pushed: true, ready: true };
|
|
44
49
|
}
|
|
45
50
|
say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
|
|
46
|
-
return { created: true, pushed: false };
|
|
51
|
+
return { created: true, pushed: false, ready: true };
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
// 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
|
|
@@ -115,6 +120,8 @@ export async function askAllOptionalWorkflows({
|
|
|
115
120
|
let changelogProvider = current.changelogProvider ?? null;
|
|
116
121
|
let changelogBaseUrl = current.changelogBaseUrl ?? null;
|
|
117
122
|
let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
|
|
123
|
+
let deployBranchReady = null; // #490 — 이번 실행에서 브랜치 존재/생성이 확인됐는지 (null=확인 안 함)
|
|
124
|
+
let deployBranchCreated = null; // #493 — 이번 실행에서 마법사가 직접 생성했는지 (가이드 기록용)
|
|
118
125
|
let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
|
|
119
126
|
|
|
120
127
|
// basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
|
|
@@ -328,7 +335,10 @@ export async function askAllOptionalWorkflows({
|
|
|
328
335
|
deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
|
|
329
336
|
say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
|
|
330
337
|
// 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
|
|
331
|
-
|
|
338
|
+
// #490 — 결과(ready)를 완료 요약에 전달해 이미 생성/확인한 브랜치를 재지시하지 않는다
|
|
339
|
+
const br = await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
|
|
340
|
+
deployBranchReady = br.ready;
|
|
341
|
+
deployBranchCreated = br.created;
|
|
332
342
|
}
|
|
333
343
|
}
|
|
334
344
|
|
|
@@ -349,6 +359,9 @@ export async function askAllOptionalWorkflows({
|
|
|
349
359
|
changelogProvider: changelogProvider ?? "github-ai",
|
|
350
360
|
changelogBaseUrl: changelogBaseUrl ?? "",
|
|
351
361
|
deployBranch: deployBranch ?? "develop",
|
|
362
|
+
deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
|
|
363
|
+
deployBranchCreated, // #493 — true=이번 실행에서 마법사가 생성, null=확인 안 함
|
|
364
|
+
|
|
352
365
|
// #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
|
|
353
366
|
intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
|
|
354
367
|
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 마법사 실행 트레이스 (#494) — 3계층 기록의 Layer 2(JSONL 이벤트) + Layer 3(터미널 미러).
|
|
2
|
+
// 마법사의 모든 결정·행동을 이벤트 한 줄씩 남겨, 나중에 사람·AI Agent가 파일명 grep 한 번으로
|
|
3
|
+
// "이 파일에 마법사가 한 모든 일"을 시간순 추적할 수 있게 한다 (v4.2.15 수동 로그 복사 진단의 자동화).
|
|
4
|
+
// Layer 1(큐레이션 가이드)은 migration-guide.js — 이 모듈의 events를 단일 소스로 소비한다.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { writeText } from "./fsutil.js";
|
|
7
|
+
|
|
8
|
+
export const MIGRATION_DIR = "docs/projectops/migration";
|
|
9
|
+
export const TRACE_SCHEMA = 1;
|
|
10
|
+
|
|
11
|
+
// 민감값 가드 — PAT·토큰·시크릿·비밀번호는 어떤 이벤트에도 남기지 않는다 (#494 안전 규칙).
|
|
12
|
+
const SENSITIVE_KEY_RE = /pat|token|secret|password|credential/i;
|
|
13
|
+
export function scrubDetail(detail) {
|
|
14
|
+
if (detail == null || typeof detail !== "object" || Array.isArray(detail)) return detail;
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [k, v] of Object.entries(detail)) {
|
|
17
|
+
if (SENSITIVE_KEY_RE.test(k)) continue;
|
|
18
|
+
out[k] = (v != null && typeof v === "object" && !Array.isArray(v)) ? scrubDetail(v) : v;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// now("YYYY-MM-DD HH:MM:SS") → 파일명 스탬프 "YYYYMMDD_HHMMSS". 형식이 아니면 "run" 폴백(테스트 주입 clock 안전).
|
|
24
|
+
export function stampFromNow(now) {
|
|
25
|
+
const digits = String(now ?? "").replace(/[^0-9]/g, "");
|
|
26
|
+
if (digits.length < 14) return "run";
|
|
27
|
+
return `${digits.slice(0, 8)}_${digits.slice(8, 14)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 트레이스 팩토리. clockIso 주입 가능(테스트 결정성) — 기본은 실제 UTC.
|
|
31
|
+
export function createRunTrace({ clockIso = null } = {}) {
|
|
32
|
+
const events = [];
|
|
33
|
+
const lines = [];
|
|
34
|
+
let restore = null;
|
|
35
|
+
|
|
36
|
+
const nowIso = () => clockIso ?? new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
events,
|
|
40
|
+
lines,
|
|
41
|
+
|
|
42
|
+
// 이벤트 1건 기록. detail은 민감키 스크럽 후 저장.
|
|
43
|
+
event(phase, action, target = "", detail = null) {
|
|
44
|
+
const e = { ts: nowIso(), phase, action, target };
|
|
45
|
+
const d = scrubDetail(detail);
|
|
46
|
+
if (d != null && (typeof d !== "object" || Object.keys(d).length > 0)) e.detail = d;
|
|
47
|
+
events.push(e);
|
|
48
|
+
return e;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// 터미널 출력 미러 시작 — stdout/stderr write를 감싸 사본만 수집(출력 자체는 그대로 통과).
|
|
52
|
+
// 실제 CLI 실행에서만 켠다 (테스트 스텁 io 경로는 호출하지 않음).
|
|
53
|
+
mirrorStart() {
|
|
54
|
+
if (restore) return;
|
|
55
|
+
const so = process.stdout.write; // 원본 참조 보관 — 복원 시 identity 유지
|
|
56
|
+
const se = process.stderr.write;
|
|
57
|
+
const capture = (chunk) => {
|
|
58
|
+
try { lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); } catch { /* 미러 실패는 실행에 영향 없음 */ }
|
|
59
|
+
};
|
|
60
|
+
process.stdout.write = function (chunk, ...rest) { capture(chunk); return so.apply(process.stdout, [chunk, ...rest]); };
|
|
61
|
+
process.stderr.write = function (chunk, ...rest) { capture(chunk); return se.apply(process.stderr, [chunk, ...rest]); };
|
|
62
|
+
restore = () => { process.stdout.write = so; process.stderr.write = se; };
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
mirrorStop() {
|
|
66
|
+
if (restore) { restore(); restore = null; }
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Layer 2/3 파일 기록 — docs/projectops/migration/{stamp}_v{from}_to_v{to}.{jsonl,log}
|
|
70
|
+
// 반환: { traceFile, logFile } (targetRoot 기준 상대 경로 — 가이드 메타 포인터용).
|
|
71
|
+
// 이벤트가 0건이면 기록하지 않는다(no-op 실행 오염 방지) — null 반환.
|
|
72
|
+
write({ targetRoot = ".", fromVersion = "", toVersion = "", now = "" } = {}) {
|
|
73
|
+
if (events.length === 0) return null;
|
|
74
|
+
const stamp = stampFromNow(now);
|
|
75
|
+
const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
76
|
+
const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
77
|
+
const base = `${stamp}_v${from}_to_v${to}`;
|
|
78
|
+
const traceFile = `${MIGRATION_DIR}/${base}.jsonl`;
|
|
79
|
+
const header = JSON.stringify({ schema: TRACE_SCHEMA, kind: "projectops-migration-trace", from, to, started: events[0]?.ts ?? "" });
|
|
80
|
+
writeText(join(targetRoot, traceFile), [header, ...events.map((e) => JSON.stringify(e))].join("\n") + "\n");
|
|
81
|
+
let logFile = null;
|
|
82
|
+
if (lines.length > 0) {
|
|
83
|
+
logFile = `${MIGRATION_DIR}/${base}.log`;
|
|
84
|
+
writeText(join(targetRoot, logFile), lines.join(""));
|
|
85
|
+
}
|
|
86
|
+
return { traceFile, logFile };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/core/wizard-env.js
CHANGED
|
@@ -41,6 +41,13 @@ export function resolveToken(name, type, resolvers = {}) {
|
|
|
41
41
|
return typeof fn === "function" ? (fn(type) ?? "") : "";
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// 잔여 전역 토큰 치환 (.sh 3347~3351) — 파일 본문·수집값(#489)·카드 표시가 전부 같은 규칙을 쓴다.
|
|
45
|
+
// 파일에 실제 써지는 값과 version.yml deploy 블록 기억값이 어긋나지 않게 하는 단일 지점.
|
|
46
|
+
export function resolveGlobalTokens(s, repoName = "") {
|
|
47
|
+
if (typeof s !== "string" || (!s.includes("__PROJECT_NAME__") && !s.includes("__APP_ARTIFACT_NAME__"))) return s;
|
|
48
|
+
return s.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
// 파일 전체 치환 (configure_workflow_env 등가).
|
|
45
52
|
// content: 원본 워크플로우 텍스트. 반환: 치환된 텍스트.
|
|
46
53
|
// opts:
|
|
@@ -50,8 +57,9 @@ export function resolveToken(name, type, resolvers = {}) {
|
|
|
50
57
|
// resolvers - resolveToken용
|
|
51
58
|
// repoName - __PROJECT_NAME__/__APP_ARTIFACT_NAME__ 치환값
|
|
52
59
|
// projectPath - paths-anchor 치환용 ('.'이면 anchor 미변경)
|
|
60
|
+
// collectSubs - 배열이면 치환 1건당 {key, action, before, after}를 push (#494 트레이스용)
|
|
53
61
|
export function substituteEnv(content, opts = {}) {
|
|
54
|
-
const { type = "", values = new Map(), useDefaults = true, resolvers = {}, repoName = "", projectPath = ".", collectAsks = null } = opts;
|
|
62
|
+
const { type = "", values = new Map(), useDefaults = true, resolvers = {}, repoName = "", projectPath = ".", collectAsks = null, collectSubs = null } = opts;
|
|
55
63
|
if (!content.includes("@wizard")) return content;
|
|
56
64
|
|
|
57
65
|
// CRLF 안전: EOL을 분리해 LF 기준으로 파싱·치환하고, 원래 EOL 스타일을 복원한다.
|
|
@@ -70,16 +78,21 @@ export function substituteEnv(content, opts = {}) {
|
|
|
70
78
|
if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
|
|
71
79
|
else val = def;
|
|
72
80
|
// ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
|
|
73
|
-
|
|
81
|
+
// #489 — 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
|
|
82
|
+
// version.yml deploy 블록이 설치본과 항상 바이트 일치하게 한다.
|
|
83
|
+
if (collectAsks) collectAsks.set(p.key, resolveGlobalTokens(val, repoName));
|
|
84
|
+
}
|
|
85
|
+
// #494 트레이스 — 치환 전후값 기록 (after는 파일 최종형과 동일하게 전역 토큰까지 해석)
|
|
86
|
+
if (Array.isArray(collectSubs) && val !== "" && val != null) {
|
|
87
|
+
const before = (lines[i].match(/:\s*"([^"]*)"/) || [])[1] ?? "";
|
|
88
|
+
collectSubs.push({ key: p.key, action: p.action, before, after: resolveGlobalTokens(val, repoName) });
|
|
74
89
|
}
|
|
75
90
|
lines[i] = setEnvLine(lines[i], p.key, val);
|
|
76
91
|
}
|
|
77
92
|
let out = lines.join(usesCRLF ? "\r\n" : "\n");
|
|
78
93
|
|
|
79
94
|
// 잔여 전역 토큰 (.sh 3347~3351)
|
|
80
|
-
|
|
81
|
-
out = out.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
|
|
82
|
-
}
|
|
95
|
+
out = resolveGlobalTokens(out, repoName);
|
|
83
96
|
|
|
84
97
|
// paths-anchor (.sh 3353~3360): 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
|
|
85
98
|
if (PATHS_ANCHOR_RE.test(out) && projectPath && projectPath !== ".") {
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import { parseExisting } from "./core/version-yml.js";
|
|
|
14
14
|
import { runBreakingCheck } from "./core/breaking-check.js";
|
|
15
15
|
import { runMigrations } from "./core/migrations/index.js";
|
|
16
16
|
import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
|
|
17
|
+
import { createRunTrace } from "./core/run-trace.js";
|
|
18
|
+
import { appendGuideEntry } from "./core/migration-guide.js";
|
|
17
19
|
import { resolveProjectPaths } from "./core/paths-resolve.js";
|
|
18
20
|
import { printBannerCompact } from "./ui/banner.js";
|
|
19
21
|
import { printSummary } from "./ui/summary.js";
|
|
@@ -135,6 +137,13 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
135
137
|
});
|
|
136
138
|
|
|
137
139
|
let result = null;
|
|
140
|
+
// 실행 트레이스 (#494) — 비대화형도 이벤트 기록 (터미널 미러는 CLI 실행에서만 유의미하므로 함께 켠다)
|
|
141
|
+
const trace = createRunTrace();
|
|
142
|
+
const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
|
|
143
|
+
let breakingReport = null;
|
|
144
|
+
let migrationsResult = null;
|
|
145
|
+
let orphanPending = [];
|
|
146
|
+
if (recordArtifacts) trace.mirrorStart();
|
|
138
147
|
try {
|
|
139
148
|
acquireTemplate({ tempDir, source });
|
|
140
149
|
context.templateVersion = readTemplateVersion(tempDir);
|
|
@@ -143,38 +152,60 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
143
152
|
printBannerCompact({ version: context.templateVersion, mode: opts.mode });
|
|
144
153
|
|
|
145
154
|
// Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
|
|
146
|
-
const proceed = await runBreakingCheck({
|
|
155
|
+
const proceed = await runBreakingCheck({
|
|
156
|
+
cwd, tempDir, templateVersion: context.templateVersion,
|
|
157
|
+
onItems: (items) => { breakingReport = items; },
|
|
158
|
+
});
|
|
147
159
|
if (!proceed) return 0;
|
|
148
160
|
|
|
149
161
|
// 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
|
|
150
|
-
if (
|
|
151
|
-
await runMigrations({ targetRoot: cwd });
|
|
162
|
+
if (recordArtifacts) {
|
|
163
|
+
migrationsResult = await runMigrations({ targetRoot: cwd });
|
|
164
|
+
for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
|
|
165
|
+
for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
// 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
|
|
155
|
-
if (
|
|
169
|
+
if (recordArtifacts) {
|
|
156
170
|
const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
|
|
171
|
+
orphanPending = orphans.map((o) => o.filename);
|
|
157
172
|
for (const o of orphans) {
|
|
158
173
|
console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
switch (opts.mode) {
|
|
163
|
-
case "full": result = runFull(context, tempDir, cwd); break;
|
|
178
|
+
case "full": result = runFull(context, tempDir, cwd, { trace }); break;
|
|
164
179
|
case "version": result = runVersion(context, tempDir, cwd); break;
|
|
165
|
-
case "workflows": result = runWorkflows(context, tempDir, cwd); break;
|
|
180
|
+
case "workflows": result = runWorkflows(context, tempDir, cwd, { trace }); break;
|
|
166
181
|
case "issues": result = runIssues(context, tempDir, cwd); break;
|
|
167
182
|
default:
|
|
168
183
|
// 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
|
|
169
184
|
break;
|
|
170
185
|
}
|
|
171
186
|
} finally {
|
|
187
|
+
trace.mirrorStop();
|
|
172
188
|
remove(tempDir);
|
|
173
189
|
}
|
|
174
190
|
|
|
191
|
+
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
|
|
192
|
+
let migrationGuidePath = null;
|
|
193
|
+
if (recordArtifacts) {
|
|
194
|
+
const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
|
|
195
|
+
migrationGuidePath = appendGuideEntry(cwd, {
|
|
196
|
+
now, mode: opts.mode, types, repoName,
|
|
197
|
+
templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
|
|
198
|
+
options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent },
|
|
199
|
+
branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
|
|
200
|
+
breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
|
|
201
|
+
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
202
|
+
traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
|
|
203
|
+
}).guidePath;
|
|
204
|
+
}
|
|
205
|
+
|
|
175
206
|
// 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
|
|
176
207
|
printSummary({
|
|
177
|
-
mode: opts.mode, types, version, deployBranch: context.deployBranch,
|
|
208
|
+
mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
|
|
178
209
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
179
210
|
}, cwd);
|
|
180
211
|
return 0;
|
package/src/ui/env-plan.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { stdin, stderr } from "node:process";
|
|
9
9
|
import { PATHS } from "../core/paths.js";
|
|
10
10
|
import { exists, listYamlFiles } from "../core/fsutil.js";
|
|
11
|
-
import { parseWizardLine, resolveToken } from "../core/wizard-env.js";
|
|
11
|
+
import { parseWizardLine, resolveToken, resolveGlobalTokens } from "../core/wizard-env.js";
|
|
12
12
|
import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
|
|
13
13
|
import * as engine from "./readline-engine.js";
|
|
14
14
|
|
|
@@ -39,7 +39,7 @@ export function scopeString(usages = []) {
|
|
|
39
39
|
// 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
|
|
40
40
|
// usages:Map<key,[{type,workflowName}]> }
|
|
41
41
|
export function collectAsks(tempDir, types = [], opts = {}) {
|
|
42
|
-
const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null } = opts;
|
|
42
|
+
const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null, repoName = "" } = opts;
|
|
43
43
|
const baseDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
44
44
|
const keys = [];
|
|
45
45
|
const defaults = new Map();
|
|
@@ -64,9 +64,13 @@ export function collectAsks(tempDir, types = [], opts = {}) {
|
|
|
64
64
|
const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
|
|
65
65
|
if (!p || p.action !== "ask") continue;
|
|
66
66
|
// 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// #489 — __PROJECT_NAME__ 등 전역 토큰은 카드 표시 전에 미리 해석한다.
|
|
68
|
+
// 설치본(파일 복사 시 전역 치환)과 동일한 값을 보여주지 않으면
|
|
69
|
+
// 사용자가 "리터럴이 그대로 박히나?" 오해하게 된다.
|
|
70
|
+
const typeDefault = resolveGlobalTokens(
|
|
71
|
+
p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg,
|
|
72
|
+
repoName,
|
|
73
|
+
);
|
|
70
74
|
typeDefaults.set(`${type}|${p.key}`, typeDefault);
|
|
71
75
|
if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
|
|
72
76
|
const list = usages.get(p.key) || [];
|
|
@@ -140,7 +144,7 @@ export async function promptEnvPlan({
|
|
|
140
144
|
deployTarget = "docker-ssh", publishTargets = [], targetRoot = ".", repoName = "", log = defaultLog,
|
|
141
145
|
} = {}) {
|
|
142
146
|
const prompts = loadWizardPrompts(targetRoot, tempDir);
|
|
143
|
-
const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts });
|
|
147
|
+
const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts, repoName });
|
|
144
148
|
const defaults = asks.defaults;
|
|
145
149
|
|
|
146
150
|
// 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
|
package/src/ui/summary.js
CHANGED
|
@@ -9,6 +9,7 @@ const SEPARATOR = "────────────────────
|
|
|
9
9
|
export function printSummary(ctx, targetRoot = ".") {
|
|
10
10
|
const { mode, types = [], version = "", counters = {} } = ctx || {};
|
|
11
11
|
const deployBranchName = ctx?.deployBranch || "develop"; // #477 — 설정된 배포 브랜치명으로 안내
|
|
12
|
+
const deployBranchReady = ctx?.deployBranchReady === true; // #490 — 마법사가 이번 실행에서 존재/생성 확인함
|
|
12
13
|
const err = (s = "") => process.stderr.write(`${s}\n`);
|
|
13
14
|
// 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
|
|
14
15
|
const isTty = !!process.stderr.isTTY;
|
|
@@ -127,6 +128,11 @@ export function printSummary(ctx, targetRoot = ".") {
|
|
|
127
128
|
|
|
128
129
|
err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
|
|
129
130
|
err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
|
|
131
|
+
// #493 — 이번 실행의 마이그레이션 기록 (수동 체크리스트·breaking 조치·AI 메타데이터)
|
|
132
|
+
if (ctx?.migrationGuidePath) {
|
|
133
|
+
err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
|
|
134
|
+
err(" (이번 실행의 변경 내역·수동 확인 체크리스트·AI Agent용 메타데이터가 기록됐습니다)");
|
|
135
|
+
}
|
|
130
136
|
err("");
|
|
131
137
|
|
|
132
138
|
// 필수 3가지 작업 안내 (.sh L5605~5625 — 원문 유지)
|
|
@@ -139,8 +145,13 @@ export function printSummary(ctx, targetRoot = ".") {
|
|
|
139
145
|
err(" → Secret Name: _GITHUB_PAT_TOKEN");
|
|
140
146
|
err(" → Scopes: repo, workflow");
|
|
141
147
|
err("");
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
// #490 — 마법사가 브랜치를 직접 생성(또는 존재 확인)했으면 같은 작업을 재지시하지 않는다
|
|
149
|
+
if (deployBranchReady) {
|
|
150
|
+
err(` 2️⃣ ✅ ${deployBranchName} 브랜치 준비 완료 — 마법사가 확인·생성했습니다 (추가 작업 불필요)`);
|
|
151
|
+
} else {
|
|
152
|
+
err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
|
|
153
|
+
err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
|
|
154
|
+
}
|
|
144
155
|
err("");
|
|
145
156
|
err(" 3️⃣ CodeRabbit 활성화 (코드 리뷰를 켰다면)");
|
|
146
157
|
err(" → https://coderabbit.ai 로그인 → GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
|