projectops 4.5.0 → 4.6.1
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/full.js +32 -15
- package/src/commands/interactive.js +57 -14
- package/src/commands/workflows.js +12 -2
- package/src/core/copy/workflows.js +54 -13
- package/src/core/migration-guide.js +2 -2
- package/src/core/run-trace.js +164 -9
- package/src/index.js +151 -38
- package/src/ui/summary.js +14 -7
package/README.md
CHANGED
package/package.json
CHANGED
package/src/commands/full.js
CHANGED
|
@@ -33,46 +33,63 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
33
33
|
|
|
34
34
|
// 3. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
|
|
35
35
|
// hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
|
|
36
|
-
const
|
|
36
|
+
const step = (name, fn, d) => (hooks.trace ? hooks.trace.step(name, fn, d) : fn());
|
|
37
|
+
const wfCounters = step("copy-workflows", () => copyWorkflows(context, tempDir, targetRoot, hooks),
|
|
38
|
+
{ types, deploy: deployTarget, publish: publishTargets });
|
|
37
39
|
const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
|
|
38
40
|
|
|
39
41
|
// 1. version.yml 생성 (전체 재생성 — metadata → deploy → template 순, .sh 최종형과 동일)
|
|
40
|
-
writeText(join(targetRoot, PATHS.versionFile),
|
|
42
|
+
step("write-version-yml", () => writeText(join(targetRoot, PATHS.versionFile),
|
|
41
43
|
buildVersionYml({
|
|
42
44
|
version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
|
|
43
45
|
deployValues,
|
|
44
46
|
templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
|
|
45
47
|
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease },
|
|
46
|
-
}));
|
|
48
|
+
})), { version, versionCode });
|
|
47
49
|
|
|
48
50
|
// 2. README 버전 섹션
|
|
49
|
-
addVersionSectionToReadme(version, targetRoot);
|
|
51
|
+
step("update-readme", () => addVersionSectionToReadme(version, targetRoot), { version });
|
|
50
52
|
|
|
51
53
|
// 5. scripts / config
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// 워크플로우 밖 영역도 기록한다 (#561) — 종전에는 "내 스크립트가 갱신됐나"를
|
|
55
|
+
// 로그만 보고 알 수 없었다.
|
|
56
|
+
step("copy-scripts", () => copyScripts(tempDir, targetRoot));
|
|
57
|
+
step("copy-config", () => copyConfigFolder(tempDir, targetRoot));
|
|
54
58
|
|
|
55
59
|
// 6. util (타입별)
|
|
56
|
-
for (const t of types) copyUtilModules(tempDir, t, { force }, targetRoot);
|
|
60
|
+
for (const t of types) step("copy-util", () => copyUtilModules(tempDir, t, { force }, targetRoot), { type: t });
|
|
57
61
|
|
|
58
62
|
// 7. issue / discussion 템플릿
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
step("copy-templates", () => {
|
|
64
|
+
copyIssueTemplates(tempDir, targetRoot);
|
|
65
|
+
copyDiscussionTemplates(tempDir, targetRoot);
|
|
66
|
+
});
|
|
61
67
|
|
|
62
68
|
// 8. coderabbit / gitignore / setup guide
|
|
63
69
|
// CodeRabbit 코드리뷰 미사용 선택(#457)이면 .coderabbit.yaml을 복사하지 않는다.
|
|
64
|
-
copyCoderabbit(tempDir, { force, enabled: codeReviewCoderabbit }, targetRoot)
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
step("copy-coderabbit", () => copyCoderabbit(tempDir, { force, enabled: codeReviewCoderabbit }, targetRoot),
|
|
71
|
+
{ enabled: codeReviewCoderabbit });
|
|
72
|
+
step("ensure-gitignore", () => ensureGitignore(targetRoot));
|
|
73
|
+
step("copy-setup-guide", () => copySetupGuide(tempDir, targetRoot));
|
|
67
74
|
|
|
68
75
|
// 9. 설치 후 검증 (#549) — 디스크에 쓰인 최종 결과물을 다시 읽는다.
|
|
69
76
|
// 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에 의존하므로,
|
|
70
77
|
// 최종 내용을 보는 것이 실제 배포될 것과 같은 것을 보는 유일한 방법이다.
|
|
71
|
-
const verification = verifyInstall(targetRoot);
|
|
72
|
-
|
|
78
|
+
const verification = step("verify-install", () => verifyInstall(targetRoot));
|
|
79
|
+
// detail 키 이름 주의: run-trace의 민감값 가드가 pat|token|secret|password|credential을
|
|
80
|
+
// 키에서 걸러낸다(#494). 여기서 다루는 값은 비밀이 아니라 "치환 플레이스홀더 이름"과
|
|
81
|
+
// "등록이 필요한 키 이름"이라 가드에 걸리지 않는 이름을 쓴다 — 가드 자체는 우회하지 않는다.
|
|
82
|
+
hooks.trace?.event("verify", "scan", "", {
|
|
73
83
|
unresolved: verification.unresolved.length,
|
|
74
|
-
|
|
84
|
+
requiredKeys: verification.secrets.size,
|
|
75
85
|
});
|
|
86
|
+
// 미치환 값은 배포 시점에 실패할 자리다 — 어느 파일 몇 번째 줄인지 로그에 남긴다.
|
|
87
|
+
for (const u of verification.unresolved) {
|
|
88
|
+
hooks.trace?.event("verify", "unresolved", u.filename, { line: u.line, placeholder: u.token });
|
|
89
|
+
}
|
|
90
|
+
for (const [name, users] of verification.secrets) {
|
|
91
|
+
hooks.trace?.event("verify", "required-key", name, { workflows: users });
|
|
92
|
+
}
|
|
76
93
|
|
|
77
94
|
return { workflows: wfCounters, verification };
|
|
78
95
|
}
|
|
@@ -14,7 +14,7 @@ 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, applicableTargets } from "../core/options-ask.js";
|
|
17
|
-
import { createRunTrace } from "../core/run-trace.js";
|
|
17
|
+
import { createRunTrace, MIGRATION_DIR } from "../core/run-trace.js";
|
|
18
18
|
import { appendGuideEntry } from "../core/migration-guide.js";
|
|
19
19
|
import { promptEnvPlan } from "../ui/env-plan.js";
|
|
20
20
|
import { listWorkflowConflicts } from "../core/copy/workflows.js";
|
|
@@ -36,11 +36,22 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
36
36
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
37
37
|
// 실행 트레이스 (#494) — 실제 CLI(io=prompts)에서만 터미널 미러를 켠다 (테스트 스텁 io는 이벤트만).
|
|
38
38
|
const trace = createRunTrace();
|
|
39
|
+
// Ctrl+C로 끊어도 기록이 남는다 (#561) — 대화형은 사람이 중간에 끊는 일이 잦다.
|
|
40
|
+
const disarmSignals = trace.armSignals({ targetRoot: cwd, now: clock?.now || "" });
|
|
41
|
+
// finally에서 기록할 때 쓰는 값 — try 안에서 확정되기 전에 취소될 수 있으므로 바깥에 둔다 (#561).
|
|
42
|
+
let traceFrom = "";
|
|
43
|
+
let traceTo = "";
|
|
44
|
+
// 기록 파일을 남기는 모드인지 — 모드 선택 전에 취소될 수 있어 기본값은 false.
|
|
45
|
+
let recordArtifacts = false;
|
|
46
|
+
// 기록을 남기지 않고 끝낼 경우(version/issues 모드의 정상 완주)에만 true.
|
|
47
|
+
// 취소·예외는 모드와 무관하게 남긴다 — 끊겼을 때가 "어디까지 갔는지"가 가장 궁금한 순간이다.
|
|
48
|
+
let skipRecord = false;
|
|
39
49
|
if (io === prompts) trace.mirrorStart();
|
|
40
50
|
try {
|
|
41
51
|
// 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
|
|
42
|
-
acquireTemplate({ tempDir, source });
|
|
52
|
+
trace.step("acquire-template", () => acquireTemplate({ tempDir, source }), { source: source?.type || "git" });
|
|
43
53
|
const templateVersion = readTemplateVersion(tempDir);
|
|
54
|
+
traceTo = templateVersion;
|
|
44
55
|
|
|
45
56
|
// 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
|
|
46
57
|
if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
|
|
@@ -49,6 +60,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
49
60
|
// 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
|
|
50
61
|
const vyPath = join(cwd, "version.yml");
|
|
51
62
|
const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
|
|
63
|
+
traceFrom = existing?.templateVersion || "";
|
|
52
64
|
|
|
53
65
|
// 층4 — IDE Skills 현재 상태 · 층5 — 신규/업데이트 판별 (#446)
|
|
54
66
|
io.ideStatus?.();
|
|
@@ -56,8 +68,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
56
68
|
|
|
57
69
|
// 1) 모드 선택 — 기존 통합 레포면 업데이트 항목을 맨 위에 노출 (#502)
|
|
58
70
|
const updateInfo = existing?.templateVersion ? { from: existing.templateVersion, to: templateVersion } : null;
|
|
71
|
+
// 실행 경계(#561) — 대화형은 사람이 무엇을 골랐는지가 핵심 기록이다.
|
|
72
|
+
trace.event("run", "start", "interactive", { templateVersion, isUpdate: !!updateInfo });
|
|
59
73
|
const picked = await io.selectMode(updateInfo ? { update: updateInfo } : {});
|
|
60
|
-
|
|
74
|
+
trace.event("prompt", "mode", String(picked ?? ""), { update: !!updateInfo });
|
|
75
|
+
if (picked === CANCEL || picked == null) { trace.event("run", "cancelled", "mode-select", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
61
76
|
// 업데이트 모드(#502): 저장된 통합 범위(templateMode, 없으면 full)를 재실행하고
|
|
62
77
|
// 이하 updateRun 분기가 질문을 최소화한다 ("저장된 설정 그대로 반영"이 계약).
|
|
63
78
|
const updateRun = picked === "update";
|
|
@@ -65,12 +80,12 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
65
80
|
|
|
66
81
|
// Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
|
|
67
82
|
let breakingReport = null; // #493 — 통과 구간 항목을 가이드에 조치 방법 전문으로 임베드
|
|
68
|
-
const proceed = await runBreakingCheck({
|
|
83
|
+
const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
|
|
69
84
|
cwd, tempDir, templateVersion,
|
|
70
85
|
askYesNo: (msg, def) => io.askYesNo(msg, def),
|
|
71
86
|
onItems: (items) => { breakingReport = items; },
|
|
72
|
-
});
|
|
73
|
-
if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
|
|
87
|
+
}));
|
|
88
|
+
if (!proceed) { trace.event("run", "cancelled", "breaking-gate", { reason: "user-declined" }); io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
|
|
74
89
|
|
|
75
90
|
// skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
|
|
76
91
|
if (mode === "skills") {
|
|
@@ -172,7 +187,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
172
187
|
io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), "프로젝트 분석 결과");
|
|
173
188
|
}
|
|
174
189
|
const choice = await io.confirmProjectMenu();
|
|
175
|
-
if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
190
|
+
if (choice === "cancel") { trace.event("run", "cancelled", "confirm-loop", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
176
191
|
if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
|
|
177
192
|
if (choice === "continue") { confirmed = true; break; }
|
|
178
193
|
// edit 루프
|
|
@@ -183,6 +198,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
183
198
|
if (isCancel(what) || what === "done") { editing = false; break; }
|
|
184
199
|
if (what === "type") {
|
|
185
200
|
const t = await io.selectTypes(types);
|
|
201
|
+
trace.event("prompt", "types", (Array.isArray(t) ? t : []).join(",") || "", { before: types });
|
|
186
202
|
if (!isCancel(t) && Array.isArray(t) && t.length) {
|
|
187
203
|
// 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
|
|
188
204
|
const oldSorted = [...types].sort().join(",");
|
|
@@ -264,6 +280,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
264
280
|
if (conflicts.length) {
|
|
265
281
|
io.note?.(conflicts.map((c) => `• ${c.filename}`).join("\n"), `♻️ 템플릿이 갱신된 워크플로우 ${conflicts.length}개`);
|
|
266
282
|
const yes = await io.askYesNo(`위 ${conflicts.length}개를 .bak 백업 후 새 버전으로 교체할까요? (기존 설정값은 유지됩니다)`, true);
|
|
283
|
+
trace.event("prompt", "conflict-bulk", yes ? "backup" : "skip", { count: conflicts.length, files: conflicts.map((c) => c.filename) });
|
|
267
284
|
const decision = yes === true ? "backup" : "skip";
|
|
268
285
|
updateDecisions = new Map();
|
|
269
286
|
for (const { filename } of conflicts) updateDecisions.set(filename, decision);
|
|
@@ -336,6 +353,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
336
353
|
`🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
|
|
337
354
|
);
|
|
338
355
|
const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
|
|
356
|
+
trace.event("prompt", "orphan-cleanup", yes ? "clean" : "keep", { count: orphans.length });
|
|
339
357
|
if (yes === true) {
|
|
340
358
|
const results = applyOrphanCleanup(cwd, orphans);
|
|
341
359
|
const ok = results.filter((r) => r.action === "bak");
|
|
@@ -350,9 +368,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
350
368
|
}
|
|
351
369
|
|
|
352
370
|
let result = null;
|
|
353
|
-
if (mode === "full") result = runFull(ctx, tempDir, cwd, { ...hooks, trace });
|
|
354
|
-
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
|
355
|
-
else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
|
|
371
|
+
if (mode === "full") result = trace.step("install-full", () => runFull(ctx, tempDir, cwd, { ...hooks, trace }));
|
|
372
|
+
else if (mode === "version") result = trace.step("install-version", () => runVersion(ctx, tempDir, cwd));
|
|
373
|
+
else if (mode === "workflows") result = trace.step("install-workflows", () => runWorkflows(ctx, tempDir, cwd, { ...hooks, trace }));
|
|
356
374
|
|
|
357
375
|
// 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
|
|
358
376
|
// 업데이트 모드(#502): 이미 설치된 IDE 스킬만 질문 없이 최신화 (미설치 IDE는 건드리지 않음)
|
|
@@ -365,12 +383,16 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
365
383
|
|
|
366
384
|
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
|
|
367
385
|
let migrationGuidePath = null;
|
|
368
|
-
|
|
369
|
-
|
|
386
|
+
recordArtifacts = mode === "full" || mode === "workflows";
|
|
387
|
+
// 경로는 먼저 계산하고 실제 쓰기는 완료 화면 뒤로 미룬다 — 요약까지 터미널 미러에 담기 위함 (#561)
|
|
388
|
+
const files = recordArtifacts
|
|
389
|
+
? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now })
|
|
390
|
+
: null;
|
|
391
|
+
if (recordArtifacts) {
|
|
370
392
|
migrationGuidePath = appendGuideEntry(cwd, {
|
|
371
393
|
now, mode, types, repoName,
|
|
372
394
|
templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
|
|
373
|
-
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto },
|
|
395
|
+
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease },
|
|
374
396
|
branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
|
|
375
397
|
breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
|
|
376
398
|
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
@@ -382,11 +404,32 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
382
404
|
io.summary?.({
|
|
383
405
|
mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
|
|
384
406
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
407
|
+
verification: result?.verification, // #549 설치 검증 결과
|
|
408
|
+
logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
|
|
409
|
+
logFile: files?.logFile ?? null,
|
|
410
|
+
traceFile: files?.traceFile ?? null,
|
|
385
411
|
}, cwd);
|
|
386
412
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
|
413
|
+
|
|
414
|
+
// 완료 화면까지 출력한 뒤 finally에서 닫는다 (#561) — 여기서 닫으면 이 아래 화면이 안 담긴다.
|
|
415
|
+
trace.event("run", "end", mode || "", {
|
|
416
|
+
workflowsCopied: result?.workflows?.copied ?? 0,
|
|
417
|
+
workflowsSkipped: result?.workflows?.skipped ?? 0,
|
|
418
|
+
});
|
|
419
|
+
skipRecord = !recordArtifacts;
|
|
387
420
|
return 0;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
trace.event("run", "error", "interactive", {
|
|
423
|
+
message: err?.message || String(err),
|
|
424
|
+
stack: String(err?.stack || "").split("\n").slice(0, 3).join(" | "),
|
|
425
|
+
});
|
|
426
|
+
throw err;
|
|
388
427
|
} finally {
|
|
389
|
-
|
|
428
|
+
// 어떤 경로로 빠져나가든 기록을 남긴다 (#561) — 중간 취소·예외·Ctrl+C 포함.
|
|
429
|
+
// 완료 화면 출력이 try 안에 있어야 그 화면까지 미러에 담긴 채로 여기서 닫힌다.
|
|
430
|
+
if (skipRecord) trace.mirrorStop();
|
|
431
|
+
else trace.finalize({ targetRoot: cwd, fromVersion: traceFrom, toVersion: traceTo, now: clock?.now || "" });
|
|
432
|
+
disarmSignals();
|
|
390
433
|
remove(tempDir);
|
|
391
434
|
}
|
|
392
435
|
}
|
|
@@ -35,10 +35,20 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
35
35
|
|
|
36
36
|
// 설치 후 검증 (#549) — 워크플로우만 설치하는 모드라 오히려 더 필요하다.
|
|
37
37
|
const verification = verifyInstall(targetRoot);
|
|
38
|
-
|
|
38
|
+
// detail 키 이름 주의: run-trace의 민감값 가드가 pat|token|secret|password|credential을
|
|
39
|
+
// 키에서 걸러낸다(#494). 여기서 다루는 값은 비밀이 아니라 "치환 플레이스홀더 이름"과
|
|
40
|
+
// "등록이 필요한 키 이름"이라 가드에 걸리지 않는 이름을 쓴다 — 가드 자체는 우회하지 않는다.
|
|
41
|
+
hooks.trace?.event("verify", "scan", "", {
|
|
39
42
|
unresolved: verification.unresolved.length,
|
|
40
|
-
|
|
43
|
+
requiredKeys: verification.secrets.size,
|
|
41
44
|
});
|
|
45
|
+
// 미치환 값은 배포 시점에 실패할 자리다 — 어느 파일 몇 번째 줄인지 로그에 남긴다.
|
|
46
|
+
for (const u of verification.unresolved) {
|
|
47
|
+
hooks.trace?.event("verify", "unresolved", u.filename, { line: u.line, placeholder: u.token });
|
|
48
|
+
}
|
|
49
|
+
for (const [name, users] of verification.secrets) {
|
|
50
|
+
hooks.trace?.event("verify", "required-key", name, { workflows: users });
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
return { workflows: wf, verification };
|
|
44
54
|
}
|
|
@@ -87,26 +87,52 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
87
87
|
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
88
88
|
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
|
|
89
89
|
|
|
90
|
-
// (1) common —
|
|
90
|
+
// (1) common — 타입별과 동일한 판정·보호를 받는다 (#560).
|
|
91
|
+
//
|
|
92
|
+
// 종전에는 "unchanged면 스킵, 아니면 무조건 덮어쓰기"였다. 그런데 릴리스 파이프라인처럼
|
|
93
|
+
// 프로젝트마다 뒤에 붙일 일이 다른 워크플로우는 common에 있어도 사용자가 고쳐 쓸 수밖에
|
|
94
|
+
// 없다. 고치지 않고는 쓸 수 없는 파일을, 고치면 백업도 없이 날아가는 규칙으로 관리하고
|
|
95
|
+
// 있었다. common/deploy조차 .bak을 남기는데 본체만 아무것도 남기지 않았다.
|
|
91
96
|
const commonDir = join(projectTypesDir, "common");
|
|
92
97
|
if (exists(commonDir)) {
|
|
98
|
+
const commonEnv = envOptsFor("common");
|
|
99
|
+
const commonClass = classify(commonDir, workflowsDir, commonEnv, baseline);
|
|
93
100
|
for (const filename of listYamlFiles(commonDir)) {
|
|
94
101
|
// #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
|
|
95
102
|
if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) {
|
|
96
103
|
trace?.event("copy", "excluded", filename, { reason: "util-modules-absent" });
|
|
97
104
|
continue;
|
|
98
105
|
}
|
|
99
|
-
|
|
100
|
-
const dst = join(workflowsDir, filename);
|
|
101
|
-
if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
|
|
106
|
+
if (commonClass.unchanged.includes(filename)) {
|
|
102
107
|
counters.skipped++;
|
|
103
108
|
trace?.event("copy", "skipped-unchanged", filename, { group: "common" });
|
|
104
109
|
continue;
|
|
105
110
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
// 사용자가 손댄 적 없고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다(종전과 동일).
|
|
112
|
+
if (commonClass.newFiles.includes(filename) || commonClass.upstream.includes(filename)) {
|
|
113
|
+
copyFileSync(join(commonDir, filename), join(workflowsDir, filename));
|
|
114
|
+
counters.copied++;
|
|
115
|
+
counters.copiedFiles.push(filename);
|
|
116
|
+
trace?.event("copy", commonClass.upstream.includes(filename) ? "upstream-updated" : "copied",
|
|
117
|
+
filename, { group: "common" });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// 여기부터는 changed — "지금 템플릿 렌더 결과 ≠ 설치본"이다. 둘로 갈린다.
|
|
121
|
+
const dst = join(workflowsDir, filename);
|
|
122
|
+
const modified = isUserModified(baseline, filename, readFileSync(dst, "utf8"));
|
|
123
|
+
if (modified === null) {
|
|
124
|
+
// 판정 불가(기준점 없음 = 기존 통합 레포). common의 종전 계약은 "항상 최신으로 갱신"이라
|
|
125
|
+
// 여기서 skip하면 기존 레포가 업데이트를 영영 못 받는다. 계약은 지키되 되돌릴 수단을
|
|
126
|
+
// 남긴다 — 덮어쓰기 전 .bak. (#560: common/deploy조차 .bak을 남기는데 본체만 없었다)
|
|
127
|
+
renameSync(dst, dst + ".bak");
|
|
128
|
+
copyFileSync(join(commonDir, filename), dst);
|
|
129
|
+
counters.copied++;
|
|
130
|
+
counters.copiedFiles.push(filename);
|
|
131
|
+
trace?.event("copy", "replaced-bak", filename, { group: "common", reason: "baseline-absent" });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// 사용자가 손댄 것이 확인된 파일 — 결정에 따라 처리(미지정이면 유지).
|
|
135
|
+
applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace);
|
|
110
136
|
}
|
|
111
137
|
}
|
|
112
138
|
|
|
@@ -170,14 +196,14 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
170
196
|
// (7) 기준점 기록 (#557) — 다음 업데이트가 "누가 바꿨는지"를 가릴 근거.
|
|
171
197
|
// 이번에 실제로 쓴 파일만 installed를 갱신한다. 유지(skip)한 파일에 우리가 쓴 것처럼
|
|
172
198
|
// 기록하면 다음 업데이트에서 사용자 수정이 조용히 덮인다.
|
|
173
|
-
recordBaseline(workflowsDir, targetRoot, counters, baseline, context.templateVersion, context.now);
|
|
199
|
+
recordBaseline(workflowsDir, targetRoot, counters, baseline, context.templateVersion, context.now, trace);
|
|
174
200
|
|
|
175
201
|
return counters;
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
// 설치 직후의 디스크 내용을 기준점으로 남긴다. 실패해도 통합을 막지 않는다 —
|
|
179
205
|
// 기준점이 없으면 다음 업데이트가 종전 2-way 판정으로 폴백할 뿐이다.
|
|
180
|
-
function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVersion, now) {
|
|
206
|
+
function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVersion, now, trace = null) {
|
|
181
207
|
try {
|
|
182
208
|
const entries = new Map();
|
|
183
209
|
for (const f of counters.copiedFiles || []) {
|
|
@@ -188,6 +214,7 @@ function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVe
|
|
|
188
214
|
entries.set(f, { installed: sha256(content), rendered: sha256(content) });
|
|
189
215
|
}
|
|
190
216
|
if (entries.size === 0 && previous) return; // 새로 쓴 게 없으면 기존 기준점을 건드리지 않는다
|
|
217
|
+
trace?.event("baseline", "recorded", "", { files: entries.size, hadPrevious: !!previous });
|
|
191
218
|
writeBaseline(targetRoot, {
|
|
192
219
|
templateVersion: templateVersion || "unknown",
|
|
193
220
|
installedAt: now || "",
|
|
@@ -233,8 +260,22 @@ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
|
|
|
233
260
|
// 설치 시점 기준점(#557) — 없으면 null이고 classify가 종전 2-way로 폴백한다.
|
|
234
261
|
const baseline = readBaseline(targetRoot);
|
|
235
262
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
236
|
-
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 →
|
|
263
|
+
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (common → 타입 순회 → server-deploy)
|
|
237
264
|
const branches = { defaultBranch: branch || "main", deployBranch: deployBranch || "develop" }; // #477 — 엔진과 동일 기준
|
|
265
|
+
|
|
266
|
+
// common (#560) — 사용자가 손댄 것이 "확인된" 파일만 질문 대상이다.
|
|
267
|
+
// 기준점이 없어 판정 불가인 파일은 엔진이 .bak을 남기고 덮어쓰므로 질문하지 않는다.
|
|
268
|
+
const commonDir = join(projectTypesDir, "common");
|
|
269
|
+
if (exists(commonDir)) {
|
|
270
|
+
const commonEnv = { type: "common", projectPath: ".", repoName, resolvers, branches };
|
|
271
|
+
for (const f of classify(commonDir, workflowsDir, commonEnv, baseline).changed) {
|
|
272
|
+
const dst = join(workflowsDir, f);
|
|
273
|
+
if (!existsSync(dst)) continue;
|
|
274
|
+
if (isUserModified(baseline, f, readFileSync(dst, "utf8")) !== true) continue;
|
|
275
|
+
conflicts.push({ filename: f, type: "common" });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
238
279
|
for (const type of types) {
|
|
239
280
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, branches };
|
|
240
281
|
const typeDir = join(projectTypesDir, type);
|
|
@@ -279,7 +320,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
279
320
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
|
|
280
321
|
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 }); }
|
|
281
322
|
// upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
|
|
282
|
-
for (const f of upstream) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type }); }
|
|
323
|
+
for (const f of upstream) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type, reason: "baseline-match" }); }
|
|
283
324
|
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
284
325
|
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
|
|
285
326
|
}
|
|
@@ -290,7 +331,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
290
331
|
const { newFiles, unchanged, changed, upstream } = classify(serverDeployDir, workflowsDir, envOpts, baseline);
|
|
291
332
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
|
|
292
333
|
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` }); }
|
|
293
|
-
for (const f of upstream) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy
|
|
334
|
+
for (const f of upstream) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy`, reason: "baseline-match" }); }
|
|
294
335
|
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
|
|
295
336
|
}
|
|
296
337
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 마이그레이션 가이드 (#493) — Layer 1 큐레이션 문서.
|
|
2
|
-
// 마법사(full/workflows) 실행이 끝나면 대상 레포의
|
|
2
|
+
// 마법사(full/workflows) 실행이 끝나면 대상 레포의 .github/.projectops/logs/PROJECTOPS-MIGRATION-GUIDE.md에
|
|
3
3
|
// "고정 헤더(최초 1회) + 실행 엔트리(append-only)"를 남긴다. 사람용 동적 체크리스트와
|
|
4
4
|
// AI Agent용 yaml 메타데이터를 한 엔트리에 담고, run-trace(#494)의 events를 단일 소스로 소비한다.
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -156,7 +156,7 @@ export function renderGuideEntry(report) {
|
|
|
156
156
|
L.push(`template: { from: ${yq(from)}, to: ${yq(to)} }`);
|
|
157
157
|
L.push(`mode: ${r.mode || "full"}`);
|
|
158
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 ?? "")}, semver_auto: ${r.options?.semverAuto === true} }`);
|
|
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 ?? "")}, semver_auto: ${r.options?.semverAuto === true}, app_release: ${r.options?.appRelease === true} }`);
|
|
160
160
|
L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
|
|
161
161
|
L.push("workflows:");
|
|
162
162
|
L.push(` added: ${ylist(wf.added)}`);
|
package/src/core/run-trace.js
CHANGED
|
@@ -5,7 +5,18 @@
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { writeText } from "./fsutil.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// 진단 로그 위치 (#561). docs/ 아래(추적 대상)에서 옮겼다 — 이 기록은 사람이 읽는 문서가
|
|
9
|
+
// 아니라 Agent가 "지난 실행에서 무슨 일이 있었나"를 확인하는 자료다.
|
|
10
|
+
// 폴더 안에 .gitignore를 함께 써서 폴더가 자기 규칙을 들고 다닌다(루트 .gitignore 무수정).
|
|
11
|
+
// 형제인 .github/.projectops/baseline.json은 팀원 공유 자산이라 계속 추적된다.
|
|
12
|
+
export const MIGRATION_DIR = ".github/.projectops/logs";
|
|
13
|
+
export const LOGS_GITIGNORE = [
|
|
14
|
+
"# projectops 실행 진단 로그 — 저장소에 추적하지 않습니다.",
|
|
15
|
+
"# 이 폴더는 마법사가 실행할 때마다 기록을 남기며, 커밋 대상이 아닙니다.",
|
|
16
|
+
"*",
|
|
17
|
+
"!.gitignore",
|
|
18
|
+
"",
|
|
19
|
+
].join("\n");
|
|
9
20
|
export const TRACE_SCHEMA = 1;
|
|
10
21
|
|
|
11
22
|
// 민감값 가드 — PAT·토큰·시크릿·비밀번호는 어떤 이벤트에도 남기지 않는다 (#494 안전 규칙).
|
|
@@ -20,6 +31,60 @@ export function scrubDetail(detail) {
|
|
|
20
31
|
return out;
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
// 단계 소요 시간. 시계를 고정 주입한 경우(테스트)는 생략해 결과가 흔들리지 않게 한다.
|
|
35
|
+
function elapsed(t0, clockIso) {
|
|
36
|
+
return clockIso ? {} : { ms: Date.now() - t0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 이벤트를 서버 로그 스타일 한 줄로 만든다 (#561).
|
|
40
|
+
// [시각] 레벨 phase/action 대상 key=value ...
|
|
41
|
+
// 레벨은 action에서 유추한다 — 조치가 필요한 것(미치환·취소·실패)만 눈에 띄어야 한다.
|
|
42
|
+
// 레벨은 "무엇을 먼저 봐야 하는가"로 나눈다.
|
|
43
|
+
// ERROR — 실패. 반드시 조치해야 한다.
|
|
44
|
+
// WARN — 그대로 두면 나중에 실패할 수 있는 것, 사용자가 판단해야 하는 것.
|
|
45
|
+
// INFO — 실행의 큰 줄기. 단계 경계와 확정된 판단.
|
|
46
|
+
// DEBUG — 건별 상세(파일 하나, 치환 하나). 기본값은 INFO지만 전부 파일에는 남는다.
|
|
47
|
+
//
|
|
48
|
+
// DEBUG가 파일에 남는 이유: 문제가 터진 뒤에 "그때 DEBUG를 켰더라면"은 소용이 없다.
|
|
49
|
+
// 화면은 조용히 두되 파일에는 전부 적는다.
|
|
50
|
+
const ERROR_ACTIONS = new Set(["error", "failed"]);
|
|
51
|
+
const WARN_ACTIONS = new Set([
|
|
52
|
+
"unresolved", "cancelled", "skipped-conflict", "leftover-old-gen",
|
|
53
|
+
"neutralized", "detected", "replaced-bak",
|
|
54
|
+
]);
|
|
55
|
+
const DEBUG_ACTIONS = new Set([
|
|
56
|
+
"copied", "skipped-unchanged", "excluded", "substituted", "branch-substituted",
|
|
57
|
+
"required-key", "project-path", "upstream-updated", "template-added",
|
|
58
|
+
"scripts", "config", "util", "templates",
|
|
59
|
+
]);
|
|
60
|
+
export function levelOf(action) {
|
|
61
|
+
if (ERROR_ACTIONS.has(action)) return "ERROR";
|
|
62
|
+
if (WARN_ACTIONS.has(action)) return "WARN";
|
|
63
|
+
if (DEBUG_ACTIONS.has(action)) return "DEBUG";
|
|
64
|
+
return "INFO";
|
|
65
|
+
}
|
|
66
|
+
export function formatLogLine(e) {
|
|
67
|
+
const time = String(e.ts || "").replace(/^.*T/, "").replace(/Z$/, "");
|
|
68
|
+
const level = levelOf(e.action).padEnd(5);
|
|
69
|
+
const tag = `${e.phase}/${e.action}`.padEnd(24);
|
|
70
|
+
const parts = [`[${time}] ${level} ${tag} ${e.target || ""}`.trimEnd()];
|
|
71
|
+
if (e.detail && typeof e.detail === "object") {
|
|
72
|
+
const kv = Object.entries(e.detail)
|
|
73
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
74
|
+
.map(([k, v]) => `${k}=${Array.isArray(v) ? (v.join("|") || "[]") : v}`);
|
|
75
|
+
if (kv.length) parts.push(` ${kv.join(" ")}`);
|
|
76
|
+
}
|
|
77
|
+
return parts.join("") + "\n";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 색상·커서 제어 시퀀스 제거 (#561). 로그 파일은 에디터·Agent가 읽으므로 이스케이프가
|
|
81
|
+
// 그대로 남으면 판독을 방해한다. 터미널 출력 자체는 건드리지 않는다(사본만 정제).
|
|
82
|
+
// eslint-disable-next-line no-control-regex
|
|
83
|
+
const ANSI_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
84
|
+
export function stripAnsi(text) {
|
|
85
|
+
return String(text).replace(ANSI_RE, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
23
88
|
// now("YYYY-MM-DD HH:MM:SS") → 파일명 스탬프 "YYYYMMDD_HHMMSS". 형식이 아니면 "run" 폴백(테스트 주입 clock 안전).
|
|
24
89
|
export function stampFromNow(now) {
|
|
25
90
|
const digits = String(now ?? "").replace(/[^0-9]/g, "");
|
|
@@ -32,6 +97,8 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
32
97
|
const events = [];
|
|
33
98
|
const lines = [];
|
|
34
99
|
let restore = null;
|
|
100
|
+
let finalized = false;
|
|
101
|
+
let signalsArmed = false;
|
|
35
102
|
|
|
36
103
|
const nowIso = () => clockIso ?? new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
37
104
|
|
|
@@ -40,11 +107,14 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
40
107
|
lines,
|
|
41
108
|
|
|
42
109
|
// 이벤트 1건 기록. detail은 민감키 스크럽 후 저장.
|
|
110
|
+
// 같은 내용을 사람이 읽는 로그 라인으로도 남긴다 (#561) — 터미널에 보이지 않는 내부
|
|
111
|
+
// 동작까지 .log 한 파일에서 시간순으로 따라갈 수 있어야, 문제가 생겼을 때 바로 대응된다.
|
|
43
112
|
event(phase, action, target = "", detail = null) {
|
|
44
113
|
const e = { ts: nowIso(), phase, action, target };
|
|
45
114
|
const d = scrubDetail(detail);
|
|
46
115
|
if (d != null && (typeof d !== "object" || Object.keys(d).length > 0)) e.detail = d;
|
|
47
116
|
events.push(e);
|
|
117
|
+
lines.push(formatLogLine(e));
|
|
48
118
|
return e;
|
|
49
119
|
},
|
|
50
120
|
|
|
@@ -55,7 +125,10 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
55
125
|
const so = process.stdout.write; // 원본 참조 보관 — 복원 시 identity 유지
|
|
56
126
|
const se = process.stderr.write;
|
|
57
127
|
const capture = (chunk) => {
|
|
58
|
-
try {
|
|
128
|
+
try {
|
|
129
|
+
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
130
|
+
lines.push(stripAnsi(s));
|
|
131
|
+
} catch { /* 미러 실패는 실행에 영향 없음 */ }
|
|
59
132
|
};
|
|
60
133
|
process.stdout.write = function (chunk, ...rest) { capture(chunk); return so.apply(process.stdout, [chunk, ...rest]); };
|
|
61
134
|
process.stderr.write = function (chunk, ...rest) { capture(chunk); return se.apply(process.stderr, [chunk, ...rest]); };
|
|
@@ -66,24 +139,106 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
66
139
|
if (restore) { restore(); restore = null; }
|
|
67
140
|
},
|
|
68
141
|
|
|
69
|
-
//
|
|
142
|
+
// 단계 실행 래퍼 (#561) — 서버 로그처럼 "어디에 들어갔다 언제 나왔고 얼마 걸렸는지"를
|
|
143
|
+
// 자동으로 남긴다. 개별 호출부에 start/done을 흩뿌리면 빠뜨리는 자리가 생긴다.
|
|
144
|
+
// 예외가 나면 failed로 기록하고 그대로 던진다 — 삼키지 않는다.
|
|
145
|
+
step(name, fn, detail = null) {
|
|
146
|
+
this.event("step", "start", name, detail);
|
|
147
|
+
const t0 = Date.now();
|
|
148
|
+
try {
|
|
149
|
+
const out = fn();
|
|
150
|
+
this.event("step", "done", name, elapsed(t0, clockIso));
|
|
151
|
+
return out;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
this.event("step", "failed", name, { ...elapsed(t0, clockIso), message: err?.message || String(err) });
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// async 버전 — 대화형 단계(질문 대기 포함)에 쓴다.
|
|
159
|
+
async stepAsync(name, fn, detail = null) {
|
|
160
|
+
this.event("step", "start", name, detail);
|
|
161
|
+
const t0 = Date.now();
|
|
162
|
+
try {
|
|
163
|
+
const out = await fn();
|
|
164
|
+
this.event("step", "done", name, elapsed(t0, clockIso));
|
|
165
|
+
return out;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.event("step", "failed", name, { ...elapsed(t0, clockIso), message: err?.message || String(err) });
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// 강제 종료(Ctrl+C)에도 기록을 남긴다 (#561).
|
|
173
|
+
// SIGINT 기본 동작으로 프로세스가 즉사하면 finally가 돌지 않는다 — 사용자가 끊었을 때야말로
|
|
174
|
+
// "어디까지 갔는지"가 가장 궁금한 순간이라, 여기서 놓치면 로그의 쓸모가 절반이다.
|
|
175
|
+
// 핸들러는 기록만 하고 원래대로 종료시킨다(동작을 바꾸지 않는다).
|
|
176
|
+
armSignals(opts = {}) {
|
|
177
|
+
if (signalsArmed) return () => {};
|
|
178
|
+
signalsArmed = true;
|
|
179
|
+
const self = this;
|
|
180
|
+
const handlers = [];
|
|
181
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
182
|
+
const h = () => {
|
|
183
|
+
self.event("run", "cancelled", sig, { reason: "사용자 강제 종료(신호 수신)" });
|
|
184
|
+
self.finalize(opts);
|
|
185
|
+
process.removeListener(sig, h);
|
|
186
|
+
process.kill(process.pid, sig); // 원래 종료 동작으로 넘긴다
|
|
187
|
+
};
|
|
188
|
+
process.on(sig, h);
|
|
189
|
+
handlers.push([sig, h]);
|
|
190
|
+
}
|
|
191
|
+
return () => {
|
|
192
|
+
for (const [sig, h] of handlers) process.removeListener(sig, h);
|
|
193
|
+
signalsArmed = false;
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// 어떤 경로로 끝나든 기록을 남긴다 (#561) — 정상 완주·중간 취소·예외·강제 종료.
|
|
198
|
+
// 사용자가 중간에 끊었을 때야말로 "어디까지 갔는지"가 가장 궁금한 순간이라,
|
|
199
|
+
// 완주했을 때만 남기는 기록은 쓸모가 절반이다.
|
|
200
|
+
//
|
|
201
|
+
// 두 번 불려도 한 번만 쓴다(정상 경로 + finally 중복 호출 대비). 실패는 삼킨다 —
|
|
202
|
+
// 기록 실패가 종료를 막아선 안 된다.
|
|
203
|
+
finalize(opts = {}) {
|
|
204
|
+
if (finalized) return null;
|
|
205
|
+
finalized = true;
|
|
206
|
+
try {
|
|
207
|
+
this.mirrorStop();
|
|
208
|
+
return this.write(opts);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
// 기록 파일 경로만 계산한다 (쓰지 않음). 완료 화면까지 캡처하려면 write를 화면 출력 뒤로
|
|
215
|
+
// 미뤄야 하는데, 가이드 엔트리는 그 전에 traceFile 경로를 참조해야 해서 둘을 분리했다.
|
|
216
|
+
paths({ fromVersion = "", toVersion = "", now = "" } = {}) {
|
|
217
|
+
const stamp = stampFromNow(now);
|
|
218
|
+
const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
219
|
+
const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
220
|
+
const base = `${stamp}_v${from}_to_v${to}`;
|
|
221
|
+
return { base, traceFile: `${MIGRATION_DIR}/${base}.jsonl`, logFile: `${MIGRATION_DIR}/${base}.log` };
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Layer 2/3 파일 기록 — .github/.projectops/logs/{stamp}_v{from}_to_v{to}.{jsonl,log}
|
|
70
225
|
// 반환: { traceFile, logFile } (targetRoot 기준 상대 경로 — 가이드 메타 포인터용).
|
|
71
226
|
// 이벤트가 0건이면 기록하지 않는다(no-op 실행 오염 방지) — null 반환.
|
|
72
227
|
write({ targetRoot = ".", fromVersion = "", toVersion = "", now = "" } = {}) {
|
|
73
228
|
if (events.length === 0) return null;
|
|
74
|
-
const stamp = stampFromNow(now);
|
|
75
229
|
const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
76
230
|
const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
77
|
-
const
|
|
78
|
-
|
|
231
|
+
const planned = this.paths({ fromVersion, toVersion, now });
|
|
232
|
+
// 폴더 규칙을 매번 보장한다 — 사용자가 지웠거나 폴더가 새로 생겨도 추적되지 않게.
|
|
233
|
+
writeText(join(targetRoot, `${MIGRATION_DIR}/.gitignore`), LOGS_GITIGNORE);
|
|
79
234
|
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");
|
|
235
|
+
writeText(join(targetRoot, planned.traceFile), [header, ...events.map((e) => JSON.stringify(e))].join("\n") + "\n");
|
|
81
236
|
let logFile = null;
|
|
82
237
|
if (lines.length > 0) {
|
|
83
|
-
logFile =
|
|
238
|
+
logFile = planned.logFile;
|
|
84
239
|
writeText(join(targetRoot, logFile), lines.join(""));
|
|
85
240
|
}
|
|
86
|
-
return { traceFile, logFile };
|
|
241
|
+
return { traceFile: planned.traceFile, logFile };
|
|
87
242
|
},
|
|
88
243
|
};
|
|
89
244
|
}
|
package/src/index.js
CHANGED
|
@@ -14,9 +14,9 @@ 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";
|
|
17
|
+
import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
|
|
18
18
|
import { appendGuideEntry } from "./core/migration-guide.js";
|
|
19
|
-
import { resolveProjectPaths } from "./core/paths-resolve.js";
|
|
19
|
+
import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
|
|
20
20
|
import { applicableTargets } from "./core/options-ask.js";
|
|
21
21
|
import { printBannerCompact } from "./ui/banner.js";
|
|
22
22
|
import { printSummary } from "./ui/summary.js";
|
|
@@ -94,23 +94,59 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
94
94
|
return 1;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// 실행 트레이스 (#494/#561) — 감지·판단 단계부터 기록해야 "왜 이렇게 정해졌는지"가 남는다.
|
|
98
|
+
const trace = createRunTrace();
|
|
99
|
+
const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
|
|
100
|
+
let disarmSignals = () => {};
|
|
101
|
+
if (recordArtifacts) {
|
|
102
|
+
trace.mirrorStart();
|
|
103
|
+
// Ctrl+C로 끊어도 여기까지의 기록이 남는다 (#561). 경로는 아래에서 확정되지만
|
|
104
|
+
// 신호는 언제든 올 수 있으므로 targetRoot만으로 먼저 무장한다.
|
|
105
|
+
disarmSignals = trace.armSignals({ targetRoot: cwd, now: "" });
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
// 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh L2208~2239 SSoT)
|
|
98
109
|
const vyPath = join(cwd, "version.yml");
|
|
99
110
|
const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
|
|
111
|
+
trace.event("detect", "existing-install", existing ? "found" : "none", {
|
|
112
|
+
templateVersion: existing?.templateVersion || null,
|
|
113
|
+
version: existing?.version || null,
|
|
114
|
+
types: existing?.types || null,
|
|
115
|
+
});
|
|
100
116
|
|
|
101
117
|
// 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
|
|
102
118
|
const types = opts.types.length ? opts.types : detectTypes(cwd);
|
|
119
|
+
trace.event("detect", "types", types.join(",") || "(없음)", {
|
|
120
|
+
source: opts.types.length ? "cli-flag(--type)" : "auto-detect(마커 파일 스캔)",
|
|
121
|
+
});
|
|
103
122
|
// version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
|
|
104
123
|
const version = (existing?.version) || opts.version || detectVersion(cwd);
|
|
124
|
+
trace.event("detect", "version", version, {
|
|
125
|
+
source: existing?.version ? "version.yml(기존값 보존)"
|
|
126
|
+
: (opts.version ? "cli-flag(--project-version)" : "프로젝트 파일 감지"),
|
|
127
|
+
});
|
|
105
128
|
const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
|
|
106
129
|
const branch = detectDefaultBranch(cwd);
|
|
107
130
|
const repoName = detectRepoName(cwd);
|
|
131
|
+
trace.event("detect", "repo", repoName || "(미상)", { defaultBranch: branch, versionCode });
|
|
108
132
|
// 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
|
|
109
133
|
const paths = await resolveProjectPaths({
|
|
110
134
|
root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
|
|
111
135
|
existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
|
|
112
136
|
});
|
|
113
137
|
|
|
138
|
+
for (const [ty, pth] of paths) {
|
|
139
|
+
// 근거로 "무엇을 보고 그 경로로 정했는지"까지 남긴다 — 경로가 틀렸을 때 추적의 시작점이다.
|
|
140
|
+
const marker = markerForType(ty);
|
|
141
|
+
const at = pth === "." ? marker : `${pth}/${marker}`;
|
|
142
|
+
trace.event("detect", "project-path", ty, {
|
|
143
|
+
path: pth,
|
|
144
|
+
marker: existsSync(join(cwd, at)) ? at : `${at} (없음)`,
|
|
145
|
+
source: parsePathsCsv(opts.pathsCsv).has(ty) ? "cli-flag(--paths)"
|
|
146
|
+
: (existing?.paths?.has(ty) ? "version.yml(저장값)" : "마커 파일 탐색"),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
114
150
|
const { now, today } = clock || utcNow();
|
|
115
151
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
116
152
|
|
|
@@ -129,10 +165,32 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
129
165
|
}
|
|
130
166
|
// 적용 불가 타겟 조용한 정리 (#498) — 대화형과 동일 규칙. 타입에 성립하지 않는 축 값은
|
|
131
167
|
// 복사 결과가 동일하므로 경고 없이 none/교집합으로 정리한다 (모바일 앱/basic 단독 등).
|
|
168
|
+
const beforeCleanup = { deploy: deployTarget, publish: [...publishTargets] };
|
|
132
169
|
if (deployTarget !== "none" && !applicable.deploy.includes(deployTarget)) deployTarget = "none";
|
|
133
170
|
publishTargets = publishTargets.filter((t) => applicable.publish.includes(t));
|
|
134
171
|
if (types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0) intent = "none";
|
|
135
172
|
|
|
173
|
+
// 축 확정 근거 (#561) — "왜 이 값인가"가 가장 헷갈리는 자리다.
|
|
174
|
+
// CLI 플래그 / 저장값 / intent 유도 / 타입 적용성 정리 중 무엇이 이겼는지 남긴다.
|
|
175
|
+
trace.event("resolve", "intent", String(intent ?? "(미설정)"), {
|
|
176
|
+
source: opts.intent != null ? "cli-flag(--intent)"
|
|
177
|
+
: (existing?.options?.intent ? "version.yml(저장값)" : "미지정 → deploy/publish에서 역추론"),
|
|
178
|
+
});
|
|
179
|
+
trace.event("resolve", "deploy", deployTarget, {
|
|
180
|
+
source: opts.deployTarget != null ? "cli-flag(--deploy)"
|
|
181
|
+
: (existing?.options?.deploy ? "version.yml(저장값)" : "기본값"),
|
|
182
|
+
applicableForTypes: applicable.deploy,
|
|
183
|
+
adjusted: beforeCleanup.deploy !== deployTarget
|
|
184
|
+
? `${beforeCleanup.deploy} → ${deployTarget} (선택 타입에 적용 불가)` : null,
|
|
185
|
+
});
|
|
186
|
+
trace.event("resolve", "publish", publishTargets.join(",") || "(없음)", {
|
|
187
|
+
source: opts.publishTargets != null ? "cli-flag(--publish)"
|
|
188
|
+
: (existing?.options?.publish ? "version.yml(저장값)" : "기본값"),
|
|
189
|
+
applicableForTypes: applicable.publish,
|
|
190
|
+
adjusted: beforeCleanup.publish.join(",") !== publishTargets.join(",")
|
|
191
|
+
? `${beforeCleanup.publish.join(",") || "(없음)"} → ${publishTargets.join(",") || "(없음)"} (적용 불가 정리)` : null,
|
|
192
|
+
});
|
|
193
|
+
|
|
136
194
|
const context = createContext({
|
|
137
195
|
mode: opts.mode, force: true, types, version, versionCode, branch,
|
|
138
196
|
paths,
|
|
@@ -160,78 +218,133 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
160
218
|
now, today,
|
|
161
219
|
});
|
|
162
220
|
|
|
221
|
+
// 최종 확정값 스냅샷 (#561) — 이 한 줄로 "무엇이 어떻게 설치될 것인지"가 고정된다.
|
|
222
|
+
trace.event("resolve", "context", opts.mode || "", {
|
|
223
|
+
types, version, versionCode, branch,
|
|
224
|
+
deploy: deployTarget, publish: publishTargets, intent,
|
|
225
|
+
secretBackup: context.includeSecretBackup,
|
|
226
|
+
changelogProvider: context.changelogProvider,
|
|
227
|
+
coderabbit: context.codeReviewCoderabbit,
|
|
228
|
+
deployBranch: context.deployBranch || "(미지정 → develop 폴백)",
|
|
229
|
+
recordMode: context.recordMode,
|
|
230
|
+
});
|
|
231
|
+
trace.event("resolve", "semver-auto", String(context.semverAuto), {
|
|
232
|
+
reason: existing?.options?.semverAuto != null ? "version.yml 저장값 보존"
|
|
233
|
+
: (existing ? "기존 통합 레포 → 예고 없는 버전 상승 방지를 위해 false"
|
|
234
|
+
: "신규 통합 → true"),
|
|
235
|
+
});
|
|
236
|
+
trace.event("resolve", "app-release", String(context.appRelease), {
|
|
237
|
+
reason: existing?.options?.appRelease != null ? "version.yml 저장값 보존" : "미설정(키를 쓰지 않음)",
|
|
238
|
+
});
|
|
239
|
+
|
|
163
240
|
let result = null;
|
|
164
|
-
//
|
|
165
|
-
const trace = createRunTrace();
|
|
166
|
-
const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
|
|
241
|
+
// trace/recordArtifacts는 감지 단계 기록을 위해 위에서 이미 생성했다 (#561)
|
|
167
242
|
let breakingReport = null;
|
|
168
243
|
let migrationsResult = null;
|
|
169
244
|
let orphanPending = [];
|
|
170
|
-
|
|
245
|
+
// 실행 경계(#561) — 로그만 보고 "무엇을 어떤 인자로 돌렸는지"를 알 수 있어야 한다.
|
|
246
|
+
trace.event("run", "start", opts.mode || "", {
|
|
247
|
+
cli: "non-interactive", types, version, branch,
|
|
248
|
+
force: true, deploy: deployTarget, publish: publishTargets, intent,
|
|
249
|
+
});
|
|
171
250
|
try {
|
|
172
|
-
acquireTemplate({ tempDir, source });
|
|
251
|
+
trace.step("acquire-template", () => acquireTemplate({ tempDir, source }), { source: source?.type || "git" });
|
|
173
252
|
context.templateVersion = readTemplateVersion(tempDir);
|
|
253
|
+
trace.event("detect", "template-version", context.templateVersion, { tempDir: PATHS.tempDir });
|
|
174
254
|
|
|
175
255
|
// 비대화형 축약 배너 (#446 확정 — 1줄, 로그 오염 최소)
|
|
176
256
|
printBannerCompact({ version: context.templateVersion, mode: opts.mode });
|
|
177
257
|
|
|
178
258
|
// Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
|
|
179
|
-
const proceed = await runBreakingCheck({
|
|
259
|
+
const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
|
|
180
260
|
cwd, tempDir, templateVersion: context.templateVersion,
|
|
181
261
|
onItems: (items) => { breakingReport = items; },
|
|
182
|
-
});
|
|
183
|
-
|
|
262
|
+
}));
|
|
263
|
+
trace.event("breaking", "result", proceed ? "proceed" : "halt", { items: (breakingReport ?? []).length });
|
|
264
|
+
if (!proceed) {
|
|
265
|
+
trace.event("run", "cancelled", "breaking-gate", { reason: "호환성 경고로 중단" });
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
184
268
|
|
|
185
269
|
// 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
|
|
186
270
|
if (recordArtifacts) {
|
|
187
|
-
migrationsResult = await runMigrations({ targetRoot: cwd });
|
|
271
|
+
migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd }));
|
|
188
272
|
for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
|
|
189
273
|
for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
|
|
190
274
|
}
|
|
191
275
|
|
|
192
276
|
// 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
|
|
193
277
|
if (recordArtifacts) {
|
|
194
|
-
const orphans =
|
|
278
|
+
const orphans = trace.step("orphan-scan",
|
|
279
|
+
() => detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types }),
|
|
280
|
+
{ selectedTypes: types });
|
|
195
281
|
orphanPending = orphans.map((o) => o.filename);
|
|
282
|
+
for (const o of orphans) trace.event("orphan", "detected", o.filename, { type: o.type, action: "안내만(비대화형)" });
|
|
196
283
|
for (const o of orphans) {
|
|
197
284
|
console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
|
|
198
285
|
}
|
|
199
286
|
}
|
|
200
287
|
|
|
201
288
|
switch (opts.mode) {
|
|
202
|
-
case "full": result = runFull(context, tempDir, cwd, { trace }); break;
|
|
203
|
-
case "version": result = runVersion(context, tempDir, cwd); break;
|
|
204
|
-
case "workflows": result = runWorkflows(context, tempDir, cwd, { trace }); break;
|
|
205
|
-
case "issues": result = runIssues(context, tempDir, cwd); break;
|
|
289
|
+
case "full": result = trace.step("install-full", () => runFull(context, tempDir, cwd, { trace })); break;
|
|
290
|
+
case "version": result = trace.step("install-version", () => runVersion(context, tempDir, cwd)); break;
|
|
291
|
+
case "workflows": result = trace.step("install-workflows", () => runWorkflows(context, tempDir, cwd, { trace })); break;
|
|
292
|
+
case "issues": result = trace.step("install-issues", () => runIssues(context, tempDir, cwd)); break;
|
|
206
293
|
default:
|
|
207
294
|
// 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
|
|
208
295
|
break;
|
|
209
296
|
}
|
|
297
|
+
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
|
|
298
|
+
let migrationGuidePath = null;
|
|
299
|
+
// 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
|
|
300
|
+
// 그래야 터미널 미러에 완료 화면까지 담긴다 (#561).
|
|
301
|
+
const files = recordArtifacts
|
|
302
|
+
? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now })
|
|
303
|
+
: null;
|
|
304
|
+
if (recordArtifacts) {
|
|
305
|
+
migrationGuidePath = appendGuideEntry(cwd, {
|
|
306
|
+
now, mode: opts.mode, types, repoName,
|
|
307
|
+
templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
|
|
308
|
+
options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease },
|
|
309
|
+
branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
|
|
310
|
+
breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
|
|
311
|
+
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
312
|
+
traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
|
|
313
|
+
}).guidePath;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
|
|
317
|
+
printSummary({
|
|
318
|
+
mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
|
|
319
|
+
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
320
|
+
verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
|
|
321
|
+
logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
|
|
322
|
+
logFile: files?.logFile ?? null,
|
|
323
|
+
traceFile: files?.traceFile ?? null,
|
|
324
|
+
}, cwd);
|
|
325
|
+
|
|
326
|
+
trace.event("run", "end", opts.mode || "", {
|
|
327
|
+
workflowsCopied: result?.workflows?.copied ?? 0,
|
|
328
|
+
workflowsSkipped: result?.workflows?.skipped ?? 0,
|
|
329
|
+
});
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// 실패 원인을 로그에 남긴다 (#561) — 서버 로그처럼 사후에 바로 짚을 수 있어야 한다.
|
|
332
|
+
trace.event("run", "error", opts.mode || "", {
|
|
333
|
+
message: err?.message || String(err),
|
|
334
|
+
stack: String(err?.stack || "").split("\n").slice(0, 3).join(" | "),
|
|
335
|
+
});
|
|
336
|
+
throw err;
|
|
210
337
|
} finally {
|
|
211
|
-
|
|
338
|
+
// 어떤 경로로 빠져나가든 기록을 남긴다 (#561) — 조기 return·중간 취소·예외 포함.
|
|
339
|
+
// 완료 화면 출력이 try 안에 있어야 그 화면까지 미러에 담긴 채로 여기서 닫힌다.
|
|
340
|
+
if (recordArtifacts) {
|
|
341
|
+
trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
|
|
342
|
+
} else {
|
|
343
|
+
trace.mirrorStop();
|
|
344
|
+
}
|
|
345
|
+
disarmSignals();
|
|
212
346
|
remove(tempDir);
|
|
213
347
|
}
|
|
214
348
|
|
|
215
|
-
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
|
|
216
|
-
let migrationGuidePath = null;
|
|
217
|
-
if (recordArtifacts) {
|
|
218
|
-
const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
|
|
219
|
-
migrationGuidePath = appendGuideEntry(cwd, {
|
|
220
|
-
now, mode: opts.mode, types, repoName,
|
|
221
|
-
templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
|
|
222
|
-
options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto },
|
|
223
|
-
branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
|
|
224
|
-
breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
|
|
225
|
-
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
226
|
-
traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
|
|
227
|
-
}).guidePath;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
|
|
231
|
-
printSummary({
|
|
232
|
-
mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
|
|
233
|
-
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
234
|
-
verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
|
|
235
|
-
}, cwd);
|
|
236
349
|
return 0;
|
|
237
350
|
}
|
package/src/ui/summary.js
CHANGED
|
@@ -130,13 +130,7 @@ export function printSummary(ctx, targetRoot = ".") {
|
|
|
130
130
|
err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
|
|
131
131
|
err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
|
|
132
132
|
// #493 — 이번 실행의 마이그레이션 기록. "뭐가 남았고 AI에게 어떻게 시키는지"가 바로 보이게 행동 유도형으로 안내.
|
|
133
|
-
|
|
134
|
-
err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
|
|
135
|
-
err(" 이번 설치에서 바뀐 내용과 직접 확인해야 할 작업이 이 파일에 정리되어 있습니다.");
|
|
136
|
-
err(" 💡 AI Agent(Claude, Cursor 등)에게 \"마이그레이션 가이드 확인하고");
|
|
137
|
-
err(" 남은 작업 처리해줘\"라고 요청하면 이 문서를 읽고 대신 진행합니다.");
|
|
138
|
-
}
|
|
139
|
-
err("");
|
|
133
|
+
// 실행 기록 위치 (#561) — 무슨 일이 있었는지 나중에 확인할 자리를 알린다.
|
|
140
134
|
|
|
141
135
|
// 설치 후 검증 결과 (#549) — 문제가 있을 때만 펼치고, 정상이면 한 줄로 압축한다.
|
|
142
136
|
const verification = ctx?.verification;
|
|
@@ -204,4 +198,17 @@ export function printSummary(ctx, targetRoot = ".") {
|
|
|
204
198
|
err(`${CYAN}📖 자세한 설정 방법은 다음 파일을 참고하세요:${NC}`);
|
|
205
199
|
err(" → PROJECTOPS-SETUP-GUIDE.md");
|
|
206
200
|
err("");
|
|
201
|
+
// 기록 안내는 완료 화면의 맨 끝에 둔다 (#561) — Secret 목록이 길면 위쪽은 스크롤에 묻혀
|
|
202
|
+
// 사용자가 "로그 얘기가 없다"고 느낀다. 문제가 생겼을 때 가장 먼저 찾는 것이므로 마지막 자리다.
|
|
203
|
+
if (ctx?.logFile || ctx?.logDir || ctx?.migrationGuidePath) {
|
|
204
|
+
err(`${CYAN}📁 이번 실행 기록${NC}`);
|
|
205
|
+
if (ctx?.logFile || ctx?.logDir) {
|
|
206
|
+
err(` 로그: ${ctx.logFile || `${ctx.logDir}/`}`);
|
|
207
|
+
if (ctx.traceFile) err(` 이벤트: ${ctx.traceFile}`);
|
|
208
|
+
}
|
|
209
|
+
if (ctx?.migrationGuidePath) err(` 가이드: ${ctx.migrationGuidePath}`);
|
|
210
|
+
err(" 무엇을 어떤 근거로 정했는지 전부 남아 있습니다 (저장소에 추적되지 않음).");
|
|
211
|
+
err(" 💡 문제가 생기면 AI Agent에게 \"실행 로그 확인해줘\"라고 요청하세요.");
|
|
212
|
+
err("");
|
|
213
|
+
}
|
|
207
214
|
}
|