projectops 4.25.0 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.25.0 (2026-09-30)
10
+ ## 최신 버전 : v4.25.1 (2026-09-30)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.25.0",
3
+ "version": "4.26.0",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -407,6 +407,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
407
407
  io.summary?.({
408
408
  mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
409
409
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
410
+ skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
410
411
  verification: result?.verification, // #549 설치 검증 결과
411
412
  aiPrSummary, codeReviewCoderabbit, // #569 — 고른 것만 안내
412
413
  logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
@@ -9,6 +9,7 @@ import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
9
9
  import { isUnchanged, substituteEnv } from "../wizard-env.js";
10
10
  import { isUserModified, readBaseline, writeBaseline, sha256 } from "../baseline.js";
11
11
  import { substituteBranches } from "../branch-sub.js";
12
+ import { saveIncoming, lineDiffCounts } from "../incoming.js";
12
13
 
13
14
  // 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
14
15
  // values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
@@ -79,7 +80,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
79
80
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
80
81
  if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
81
82
 
82
- const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, copiedFiles: [] };
83
+ const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, copiedFiles: [],
84
+ skippedConflicts: [] }; // #654 — 유지한 사용자 수정본별 { filename, incoming, added, removed }
83
85
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
84
86
  counters.deployValues = deployValues;
85
87
  // 브랜치 전략 (#477) — 표준(main/develop)이면 치환·가상비교 모두 no-op
@@ -87,6 +89,27 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
87
89
  // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
88
90
  const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
89
91
 
92
+ // 사용자 수정본을 유지할 때 새 템플릿 사본을 incoming에 남긴다 (#654).
93
+ // 실패는 경고 한 줄로 흡수한다 — 병합 안내 때문에 복사 전체가 죽으면 안 된다.
94
+ const onSkip = (srcDir, filename, type) => {
95
+ const info = { filename, incoming: "", added: null, removed: null };
96
+ try {
97
+ // 설치 때와 같은 렌더(기본값 치환 + 브랜치 치환)를 거쳐야 diff가 "진짜 차이"만 보여준다
98
+ const rendered = substituteBranches(
99
+ substituteEnv(readFileSync(join(srcDir, filename), "utf8"), { ...envOptsFor(type), useDefaults: true }), branches);
100
+ info.incoming = saveIncoming(targetRoot, filename, rendered);
101
+ const d = lineDiffCounts(readFileSync(join(workflowsDir, filename), "utf8"), rendered);
102
+ info.added = d.added; info.removed = d.removed;
103
+ } catch (e) {
104
+ trace?.event("copy", "incoming-failed", filename, { message: e?.message || String(e) });
105
+ console.error(`⚠️ incoming 저장 실패(${filename}): ${e?.message || e} — 병합 안내 없이 계속합니다.`);
106
+ }
107
+ counters.skippedConflicts.push(info);
108
+ return info;
109
+ };
110
+ // 반환 객체의 열거 가능한 필드를 늘리지 않는다 (기존 호출부·테스트가 counters 모양에 기대고 있다)
111
+ Object.defineProperty(counters, "onSkip", { value: onSkip, enumerable: false });
112
+
90
113
  // (1) common — 타입별과 동일한 판정·보호를 받는다 (#560).
91
114
  //
92
115
  // 종전에는 "unchanged면 스킵, 아니면 무조건 덮어쓰기"였다. 그런데 릴리스 파이프라인처럼
@@ -132,7 +155,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
132
155
  continue;
133
156
  }
134
157
  // 사용자가 손댄 것이 확인된 파일 — 결정에 따라 처리(미지정이면 유지).
135
- applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace);
158
+ applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace, "common");
136
159
  }
137
160
  }
138
161
 
@@ -251,7 +274,7 @@ function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVe
251
274
 
252
275
  // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
253
276
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
254
- function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
277
+ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null, type = "") {
255
278
  const src = join(srcDir, filename);
256
279
  const dst = join(workflowsDir, filename);
257
280
  if (decision === "backup") {
@@ -272,7 +295,12 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace
272
295
  return;
273
296
  }
274
297
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
275
- trace?.event("copy", "skipped-conflict", filename, { decision: decision ?? "skip", note: "사용자 수정본 유지 — 병합 검토 후보" });
298
+ // #654 — 새 템플릿을 incoming에 남기고 diff 줄 수를 detail로 함께 기록
299
+ const inc = counters.onSkip?.(srcDir, filename, type) ?? {};
300
+ trace?.event("copy", "skipped-conflict", filename, {
301
+ decision: decision ?? "skip", note: "사용자 수정본 유지 — 병합 검토 후보",
302
+ incoming: inc.incoming || null, added: inc.added ?? null, removed: inc.removed ?? null,
303
+ });
276
304
  }
277
305
 
278
306
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
@@ -345,7 +373,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
345
373
  // upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
346
374
  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" }); }
347
375
  // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
348
- for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
376
+ for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace, type);
349
377
  }
350
378
 
351
379
  // server-deploy — deploy=docker-ssh일 때만 포함 (#439)
@@ -355,7 +383,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
355
383
  for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
356
384
  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` }); }
357
385
  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" }); }
358
- for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
386
+ for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace, type);
359
387
  }
360
388
 
361
389
  // publish/<target> (opt-in — #439 publish 축. 타입은 파일 위치일 뿐 게이트가 아니다)
@@ -0,0 +1,56 @@
1
+ // 건너뛴 워크플로의 새 템플릿 보관소 (#654).
2
+ // 사용자가 수정한 워크플로는 덮어쓰지 않고 유지한다. 대신 "새 템플릿이 어떻게 다른지"를
3
+ // 비교할 수 있도록 추적되지 않는 자리에 사본을 남긴다.
4
+ // 절대 .github/workflows 에 쓰지 않는다 — 거기 생긴 파일은 GitHub 이 실제 워크플로로 실행한다.
5
+ import { join } from "node:path";
6
+ import { writeText } from "./fsutil.js";
7
+
8
+ export const INCOMING_DIR = ".github/.projectops/incoming";
9
+ // 폴더가 스스로 추적 제외 규칙을 들고 다닌다 (logs/.gitignore 와 같은 방식, 루트 .gitignore 무수정)
10
+ export const INCOMING_GITIGNORE = [
11
+ "# projectops 가 건너뛴 워크플로의 새 템플릿 사본 — 저장소에 추적하지 않습니다.",
12
+ "# 병합 검토용 임시 자료이며 실행할 때마다 최신으로 덮어씁니다.",
13
+ "*",
14
+ "!.gitignore",
15
+ "",
16
+ ].join("\n");
17
+
18
+ const toLines = (s) => {
19
+ const lines = String(s).split(/\r?\n/);
20
+ if (lines.length && lines[lines.length - 1] === "") lines.pop(); // 끝 개행이 만드는 빈 줄 제외
21
+ return lines;
22
+ };
23
+
24
+ // 줄 단위 추가/삭제 개수. LCS 기반이라 줄이 이동만 한 경우도 과대 계상하지 않는다.
25
+ // removed: 사용자본에만 있는 줄, added: 새 템플릿에만 있는 줄.
26
+ export function lineDiffCounts(userText, incomingText) {
27
+ const a = toLines(userText);
28
+ const b = toLines(incomingText);
29
+ // 공통 접두/접미는 LCS에서 뺀다 (워크플로 대부분이 같아 표가 작아진다)
30
+ let s = 0;
31
+ while (s < a.length && s < b.length && a[s] === b[s]) s++;
32
+ let ea = a.length; let eb = b.length;
33
+ while (ea > s && eb > s && a[ea - 1] === b[eb - 1]) { ea--; eb--; }
34
+ const x = a.slice(s, ea); const y = b.slice(s, eb);
35
+ if (x.length * y.length > 25_000_000) {
36
+ // 비정상적으로 큰 파일 — 정확도 대신 크기 차이로 근사한다 (실패시키지 않는다)
37
+ return { added: y.length, removed: x.length };
38
+ }
39
+ let prev = new Array(y.length + 1).fill(0);
40
+ for (let i = 1; i <= x.length; i++) {
41
+ const cur = new Array(y.length + 1).fill(0);
42
+ for (let j = 1; j <= y.length; j++) {
43
+ cur[j] = x[i - 1] === y[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
44
+ }
45
+ prev = cur;
46
+ }
47
+ const common = prev[y.length];
48
+ return { added: y.length - common, removed: x.length - common };
49
+ }
50
+
51
+ // incoming 폴더에 새 템플릿을 저장한다. 반환: 대상 기준 상대 경로(항상 '/').
52
+ export function saveIncoming(targetRoot, filename, content) {
53
+ writeText(join(targetRoot, INCOMING_DIR, ".gitignore"), INCOMING_GITIGNORE);
54
+ writeText(join(targetRoot, INCOMING_DIR, filename), content);
55
+ return `${INCOMING_DIR}/${filename}`;
56
+ }
@@ -52,6 +52,9 @@ export function deriveWorkflowLists(events = []) {
52
52
  added: pick("copied"),
53
53
  replacedBak: pick("replaced-bak"),
54
54
  skippedConflict: pick("skipped-conflict"),
55
+ // #654 — 건너뛴 파일별 새 템플릿 사본 경로와 diff 줄 수 (이벤트 detail에서 파생)
56
+ skippedConflictDetails: events.filter((e) => e.phase === "copy" && e.action === "skipped-conflict")
57
+ .map((e) => ({ file: e.target, incoming: e.detail?.incoming ?? "", added: e.detail?.added ?? null, removed: e.detail?.removed ?? null })),
55
58
  templateAdded: pick("template-added"),
56
59
  excluded: pick("excluded"),
57
60
  };
@@ -112,6 +115,11 @@ export function renderGuideEntry(report) {
112
115
  }
113
116
  if (wf.skippedConflict.length) {
114
117
  checklist.push(`- [ ] **기존 수정본 유지 ${wf.skippedConflict.length}개, 신형과 병합 검토**: ${wf.skippedConflict.map((f) => `\`${f}\``).join(", ")}`);
118
+ // #654 — 새 템플릿 사본이 있으면 비교 방법까지 적는다
119
+ for (const d of wf.skippedConflictDetails.filter((x) => x.incoming)) {
120
+ const cnt = d.added != null && d.removed != null ? ` (추가 ${d.added}줄, 삭제 ${d.removed}줄)` : "";
121
+ checklist.push(` - \`${d.file}\`${cnt}: \`diff -u .github/workflows/${d.file} ${d.incoming}\``);
122
+ }
115
123
  }
116
124
  if (wf.added.length || wf.replacedBak.length) {
117
125
  checklist.push(`- [ ] **새/갱신 CICD가 요구하는 GitHub Secrets 등록 확인** (Settings → Secrets → Actions, \`_GITHUB_PAT_TOKEN\` 포함)`);
@@ -162,6 +170,11 @@ export function renderGuideEntry(report) {
162
170
  L.push(` added: ${ylist(wf.added)}`);
163
171
  L.push(` replaced_bak: ${ylist(wf.replacedBak)}`);
164
172
  L.push(` skipped_conflict: ${ylist(wf.skippedConflict)}`);
173
+ const inc = wf.skippedConflictDetails.filter((d) => d.incoming);
174
+ if (inc.length) {
175
+ L.push(" skipped_conflict_incoming:");
176
+ for (const d of inc) L.push(` - { file: ${yq(d.file)}, incoming: ${yq(d.incoming)}, added: ${d.added ?? "null"}, removed: ${d.removed ?? "null"} }`);
177
+ }
165
178
  L.push(` template_added: ${ylist(wf.templateAdded)}`);
166
179
  if (legacyNeutralized.length) {
167
180
  L.push(" legacy_neutralized:");
@@ -50,7 +50,7 @@ function elapsed(t0, clockIso) {
50
50
  const ERROR_ACTIONS = new Set(["error", "failed"]);
51
51
  const WARN_ACTIONS = new Set([
52
52
  "unresolved", "cancelled", "skipped-conflict", "leftover-old-gen",
53
- "neutralized", "detected", "replaced-bak",
53
+ "neutralized", "detected", "replaced-bak", "incoming-failed",
54
54
  ]);
55
55
  const DEBUG_ACTIONS = new Set([
56
56
  "copied", "skipped-unchanged", "excluded", "substituted", "branch-substituted",
package/src/index.js CHANGED
@@ -321,6 +321,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
321
321
  printSummary({
322
322
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
323
323
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
324
+ skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
324
325
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
325
326
  // #569 — 고른 것만 안내하려면 선택값이 필요하다
326
327
  aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,
package/src/ui/summary.js CHANGED
@@ -90,6 +90,26 @@ export function printSummary(ctx, targetRoot = ".") {
90
90
  err(" 📦 새로 설치·갱신된 워크플로우 없음 — 모두 최신 상태");
91
91
  }
92
92
 
93
+ // #654 — 사용자 수정본을 유지한 워크플로: 무엇이 얼마나 다른지, 어떻게 병합하는지 안내한다.
94
+ // 건너뛴 것이 없으면 아무것도 출력하지 않는다 (기존 출력 유지).
95
+ const kept = ctx?.skippedConflicts ?? [];
96
+ if (kept.length > 0) {
97
+ err("");
98
+ err(` ${YELLOW}✋ 수정한 워크플로 ${kept.length}개는 덮어쓰지 않고 그대로 두었습니다${NC}`);
99
+ for (const k of kept) {
100
+ const cnt = k.added != null && k.removed != null ? ` (새 템플릿 기준 추가 ${k.added}줄, 삭제 ${k.removed}줄)` : "";
101
+ err(` 📝 ${k.filename}${cnt}`);
102
+ if (k.incoming) {
103
+ err(` diff -u .github/workflows/${k.filename} ${k.incoming}`);
104
+ err(` git diff --no-index .github/workflows/${k.filename} ${k.incoming}`);
105
+ }
106
+ }
107
+ if (kept.some((k) => k.incoming)) {
108
+ err(" 💡 새 템플릿 사본은 저장소에 추적되지 않는 .github/.projectops/incoming/ 에 있습니다.");
109
+ err(" 필요한 변경만 워크플로에 직접 옮겨 반영하세요.");
110
+ }
111
+ }
112
+
93
113
  err("");
94
114
  err(" 🔧 .github/scripts/");
95
115
  err(" ├─ version_manager.sh");