projectops 4.0.2 → 4.0.4

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.
@@ -0,0 +1,257 @@
1
+ // node:readline 기반 대화형 프롬프트 엔진 (@clack/prompts 대체).
2
+ // WHY: @clack/prompts 1.7.0 이 Windows TTY 콘솔에서 Enter(return) 키를 처리하지 못하고
3
+ // 멈추는 버그가 있다(실측 확정). node:readline 의 keypress 이벤트는 Windows에서 정상 동작한다.
4
+ // .sh/.ps1 이 자체 메뉴를 구현한 것과 동일한 접근. 외부 의존성 0 → 내부망에서도 안전.
5
+ //
6
+ // 계약: 취소(ESC/Ctrl+C)는 CANCEL 심볼 반환. 각 함수 async.
7
+ import { emitKeypressEvents } from "node:readline";
8
+ import { stdin, stdout } from "node:process";
9
+
10
+ export const CANCEL = Symbol("cancel");
11
+
12
+ // ── ANSI 헬퍼 (picocolors 대체 — 의존성 0) ───────────────────────────
13
+ const ESC = "\x1b[";
14
+ const c = {
15
+ reset: `${ESC}0m`, dim: `${ESC}2m`, bold: `${ESC}1m`,
16
+ cyan: `${ESC}36m`, green: `${ESC}32m`, gray: `${ESC}90m`, yellow: `${ESC}33m`,
17
+ };
18
+ const paint = (s, color) => `${color}${s}${c.reset}`;
19
+ const hideCursor = () => stdout.write(`${ESC}?25l`);
20
+ const showCursor = () => stdout.write(`${ESC}?25h`);
21
+
22
+ // 심볼 (clack 톤 유지)
23
+ const S_ACTIVE = paint("●", c.green);
24
+ const S_INACTIVE = paint("○", c.dim);
25
+ const S_CHECK_ON = paint("◼", c.green);
26
+ const S_CHECK_OFF = paint("◻", c.dim);
27
+ const S_BAR = paint("│", c.gray);
28
+ const S_Q = paint("◆", c.cyan);
29
+ const S_DONE = paint("◇", c.green);
30
+
31
+ // 여러 줄 지운 뒤 커서를 블록 시작으로 되돌리는 렌더러.
32
+ // prevLines 만큼 위로 올라가 지우고 새로 그린다.
33
+ function makeRenderer() {
34
+ let prevLines = 0;
35
+ return {
36
+ render(lines) {
37
+ if (prevLines > 0) stdout.write(`${ESC}${prevLines}A`); // 위로
38
+ stdout.write(`${ESC}0J`); // 커서 아래 전부 지우기
39
+ stdout.write(lines.join("\n") + "\n");
40
+ prevLines = lines.length;
41
+ },
42
+ reset() { prevLines = 0; },
43
+ };
44
+ }
45
+
46
+ // raw keypress 세션 공통 래퍼. onKey(str,key) → true 반환 시 종료.
47
+ // 반환값은 finalize()가 만든다. 취소 시 CANCEL.
48
+ function keySession(renderFn, onKey) {
49
+ return new Promise((resolve) => {
50
+ const wasRaw = stdin.isTTY ? stdin.isRaw : false;
51
+ emitKeypressEvents(stdin);
52
+ if (stdin.isTTY) stdin.setRawMode(true);
53
+ stdin.resume();
54
+ hideCursor();
55
+
56
+ const cleanup = () => {
57
+ stdin.removeListener("keypress", handler);
58
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
59
+ stdin.pause();
60
+ showCursor();
61
+ };
62
+
63
+ const handler = (str, key) => {
64
+ key = key || {};
65
+ // 취소: Ctrl+C / ESC
66
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
67
+ cleanup();
68
+ resolve(CANCEL);
69
+ return;
70
+ }
71
+ const done = onKey(str, key);
72
+ if (done !== undefined) {
73
+ cleanup();
74
+ resolve(done);
75
+ } else {
76
+ renderFn();
77
+ }
78
+ };
79
+ stdin.on("keypress", handler);
80
+ renderFn(); // 최초 렌더
81
+ });
82
+ }
83
+
84
+ // ── 단일 선택 (방향키 + Enter) ───────────────────────────────────────
85
+ // options: [{value,label,hint?}]. 반환: 선택 value 또는 CANCEL.
86
+ export async function select({ message, options, initialIndex = 0 }) {
87
+ if (!stdin.isTTY) {
88
+ // 비-TTY: 기본값(첫 항목) 반환 — 파이프 환경 방어
89
+ return options[initialIndex]?.value;
90
+ }
91
+ const r = makeRenderer();
92
+ let idx = Math.max(0, Math.min(initialIndex, options.length - 1));
93
+
94
+ const draw = () => {
95
+ const lines = [S_BAR, `${S_Q} ${paint(message, c.bold)}`];
96
+ options.forEach((o, i) => {
97
+ const sel = i === idx;
98
+ const marker = sel ? S_ACTIVE : S_INACTIVE;
99
+ const label = sel ? paint(o.label, c.cyan) : o.label;
100
+ const hint = o.hint && sel ? paint(` (${o.hint})`, c.dim) : "";
101
+ lines.push(`${S_BAR} ${marker} ${label}${hint}`);
102
+ });
103
+ lines.push(paint(`└ ↑/↓ 이동 · Enter 확정 · ESC 취소`, c.gray));
104
+ r.render(lines);
105
+ };
106
+
107
+ const result = await keySession(draw, (str, key) => {
108
+ if (key.name === "up" || key.name === "k") { idx = (idx - 1 + options.length) % options.length; return; }
109
+ if (key.name === "down" || key.name === "j") { idx = (idx + 1) % options.length; return; }
110
+ // 숫자 점프 (1-9)
111
+ if (/^[1-9]$/.test(str || "")) {
112
+ const n = Number(str) - 1;
113
+ if (n < options.length) { idx = n; return; }
114
+ return;
115
+ }
116
+ if (key.name === "return" || key.name === "enter") return options[idx].value;
117
+ return; // 그 외 키: 무시하고 계속
118
+ });
119
+
120
+ // 확정 화면 다시 그리기 (◇ 완료 심볼 + 선택값)
121
+ if (result !== CANCEL) {
122
+ const chosen = options.find((o) => o.value === result);
123
+ r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(chosen?.label ?? "", c.dim)}`]);
124
+ }
125
+ return result;
126
+ }
127
+
128
+ // ── 다중 선택 (Space 토글 + Enter) ──────────────────────────────────
129
+ // options: [{value,label,hint?,disabled?}]. 반환: 선택 value 배열 또는 CANCEL.
130
+ export async function multiselect({ message, options, initialValues = [], required = false }) {
131
+ if (!stdin.isTTY) {
132
+ return initialValues.length ? [...initialValues] : (required ? [options[0]?.value].filter(Boolean) : []);
133
+ }
134
+ const r = makeRenderer();
135
+ let idx = 0;
136
+ const chosen = new Set(initialValues);
137
+ let warn = "";
138
+
139
+ const draw = () => {
140
+ const lines = [S_BAR, `${S_Q} ${paint(message, c.bold)}`];
141
+ options.forEach((o, i) => {
142
+ const cur = i === idx;
143
+ const box = chosen.has(o.value) ? S_CHECK_ON : S_CHECK_OFF;
144
+ const pointer = cur ? paint("❯", c.cyan) : " ";
145
+ const label = cur ? paint(o.label, c.cyan) : (o.disabled ? paint(o.label, c.dim) : o.label);
146
+ const hint = o.hint ? paint(` (${o.hint})`, c.dim) : "";
147
+ lines.push(`${S_BAR} ${pointer} ${box} ${label}${hint}`);
148
+ });
149
+ if (warn) lines.push(paint(` ${warn}`, c.yellow));
150
+ lines.push(paint(`└ ↑/↓ 이동 · Space 토글 · Enter 확정 · ESC 취소`, c.gray));
151
+ r.render(lines);
152
+ };
153
+
154
+ const result = await keySession(draw, (str, key) => {
155
+ warn = "";
156
+ if (key.name === "up" || key.name === "k") { idx = (idx - 1 + options.length) % options.length; return; }
157
+ if (key.name === "down" || key.name === "j") { idx = (idx + 1) % options.length; return; }
158
+ if (key.name === "space" || str === " ") {
159
+ const o = options[idx];
160
+ if (o.disabled) { warn = "선택할 수 없는 항목입니다."; return; }
161
+ if (chosen.has(o.value)) chosen.delete(o.value); else chosen.add(o.value);
162
+ return;
163
+ }
164
+ if (key.name === "return" || key.name === "enter") {
165
+ if (required && chosen.size === 0) { warn = "최소 1개 이상 선택하세요."; return; }
166
+ return [...chosen];
167
+ }
168
+ return;
169
+ });
170
+
171
+ if (result !== CANCEL) {
172
+ const labels = options.filter((o) => chosen.has(o.value)).map((o) => o.label).join(", ") || "(없음)";
173
+ r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(labels, c.dim)}`]);
174
+ }
175
+ return result;
176
+ }
177
+
178
+ // ── 텍스트 입력 (Enter 확정, 빈 입력=기본값) ─────────────────────────
179
+ // 반환: 입력 문자열(빈 입력 시 defaultValue) 또는 CANCEL.
180
+ export async function text({ message, defaultValue = "" }) {
181
+ if (!stdin.isTTY) return defaultValue;
182
+ return new Promise((resolve) => {
183
+ const wasRaw = stdin.isTTY ? stdin.isRaw : false;
184
+ emitKeypressEvents(stdin);
185
+ if (stdin.isTTY) stdin.setRawMode(true);
186
+ stdin.resume();
187
+ let buf = "";
188
+
189
+ const prompt = () => {
190
+ stdout.write(`\r${ESC}0K`); // 줄 초기화
191
+ const shown = buf.length ? buf : paint(defaultValue || "", c.dim);
192
+ stdout.write(`${S_Q} ${paint(message, c.bold)} ${shown}`);
193
+ };
194
+
195
+ const cleanup = () => {
196
+ stdin.removeListener("keypress", handler);
197
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
198
+ stdin.pause();
199
+ stdout.write("\n");
200
+ };
201
+
202
+ const handler = (str, key) => {
203
+ key = key || {};
204
+ if ((key.ctrl && key.name === "c") || key.name === "escape") { cleanup(); resolve(CANCEL); return; }
205
+ if (key.name === "return" || key.name === "enter") {
206
+ cleanup();
207
+ resolve(buf.length ? buf : defaultValue);
208
+ return;
209
+ }
210
+ if (key.name === "backspace") { buf = buf.slice(0, -1); prompt(); return; }
211
+ // 일반 문자 (제어문자 제외)
212
+ if (str && !key.ctrl && !key.meta && str.length === 1 && str >= " ") { buf += str; prompt(); return; }
213
+ };
214
+ stdin.on("keypress", handler);
215
+ prompt();
216
+ });
217
+ }
218
+
219
+ // ── Y/N 확인 (←→ 또는 y/n, Enter 확정) ──────────────────────────────
220
+ // 반환: true/false 또는 CANCEL.
221
+ export async function confirm({ message, initialValue = true }) {
222
+ if (!stdin.isTTY) return initialValue;
223
+ const r = makeRenderer();
224
+ let val = initialValue;
225
+
226
+ const draw = () => {
227
+ const yes = val ? paint("● 예", c.green) : paint("○ 예", c.dim);
228
+ const no = !val ? paint("● 아니오", c.green) : paint("○ 아니오", c.dim);
229
+ r.render([S_BAR, `${S_Q} ${paint(message, c.bold)}`, `${S_BAR} ${yes} ${no}`,
230
+ paint(`└ ←/→ 또는 y/n · Enter 확정 · ESC 취소`, c.gray)]);
231
+ };
232
+
233
+ const result = await keySession(draw, (str, key) => {
234
+ if (key.name === "left" || key.name === "right" || key.name === "tab") { val = !val; return; }
235
+ if ((str || "").toLowerCase() === "y") { val = true; return; }
236
+ if ((str || "").toLowerCase() === "n") { val = false; return; }
237
+ if (key.name === "return" || key.name === "enter") return val;
238
+ return;
239
+ });
240
+
241
+ if (result !== CANCEL) {
242
+ r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(result ? "예" : "아니오", c.dim)}`]);
243
+ }
244
+ return result;
245
+ }
246
+
247
+ // ── 출력 헬퍼 (clack intro/outro/note/cancel 대체) ──────────────────
248
+ export function intro(text) { stdout.write(`\n${paint("┌", c.gray)} ${paint(text, c.bold)}\n`); }
249
+ export function outro(text) { stdout.write(`${paint("└", c.gray)} ${paint(text, c.green)}\n\n`); }
250
+ export function cancelMessage(text = "취소했습니다.") { stdout.write(`${paint("■", c.yellow)} ${paint(text, c.yellow)}\n`); }
251
+ export function note(text, title = "") {
252
+ const lines = String(text).split("\n");
253
+ stdout.write(`${paint("○", c.cyan)} ${paint(title, c.bold)}\n`);
254
+ for (const l of lines) stdout.write(`${S_BAR} ${l}\n`);
255
+ stdout.write(`${S_BAR}\n`);
256
+ }
257
+ export function log(text = "") { stdout.write(`${text}\n`); }
@@ -1,12 +1,12 @@
1
- // IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가) — @clack/prompts 기반.
2
- import * as clack from "@clack/prompts";
1
+ // IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가).
2
+ // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY Enter 버그로 제거).
3
+ import * as engine from "./readline-engine.js";
3
4
 
4
- export const CANCEL = Symbol("cancel");
5
- const wrap = (v) => (clack.isCancel(v) ? CANCEL : v);
5
+ export const CANCEL = engine.CANCEL;
6
6
 
7
7
  // 동작 선택: 설치/업데이트 · 제거 · 그대로. 취소=skip.
8
8
  export async function selectAction() {
9
- const v = await clack.select({
9
+ const v = await engine.select({
10
10
  message: "AI 스킬을 어떻게 할까요?",
11
11
  options: [
12
12
  { value: "apply", label: "설치 / 업데이트 — 최신 상태로 맞추기" },
@@ -14,8 +14,7 @@ export async function selectAction() {
14
14
  { value: "skip", label: "그대로 두기" },
15
15
  ],
16
16
  });
17
- const w = wrap(v);
18
- return w === CANCEL ? "skip" : w;
17
+ return v === CANCEL ? "skip" : v;
19
18
  }
20
19
 
21
20
  // IDE 멀티셀렉트. choices=[{id,label,disabled}], preselect=[id...], action.
@@ -24,14 +23,14 @@ export async function selectTargets(choices, preselect = [], action = "apply") {
24
23
  value: c.id,
25
24
  label: c.label + (c.disabled ? " (미감지)" : ""),
26
25
  hint: c.disabled ? "CLI 없음" : undefined,
26
+ disabled: c.disabled,
27
27
  }));
28
- const v = await clack.multiselect({
28
+ return engine.multiselect({
29
29
  message: `${action === "apply" ? "설치 / 업데이트" : "제거"}할 IDE를 고르세요 (Space 토글, Enter 확정)`,
30
30
  options,
31
31
  initialValues: preselect,
32
32
  required: false,
33
33
  });
34
- return wrap(v);
35
34
  }
36
35
 
37
- export function note(text, title) { clack.note(text, title); }
36
+ export function note(text, title) { engine.note(text, title); }
@@ -0,0 +1,86 @@
1
+ // 첫 화면 상태 표시 층 (#446 층2~5) — 감지 로그 · 분석 카드 · IDE 상태 · 신규/업데이트 판별
2
+ // (층5의 Breaking Changes 박스는 core/breaking-check.js가 담당)
3
+ import { A, paint } from "./ansi.js";
4
+ import { ADAPTERS } from "../core/ide/registry.js";
5
+ import { markerForType } from "../core/detect.js";
6
+
7
+ const GUT = paint("│", A.gray);
8
+ const HEAD = paint("◆", A.cyan);
9
+ const OK = paint("✓", A.green);
10
+
11
+ // 층2 — 감지 로그 (.ps1 감지 진행 표시 등가)
12
+ export function printDetectionLog({ types = [], version = "", branch = "" }, out = (s) => process.stdout.write(s)) {
13
+ out(`${paint("┌", A.gray)} 🔍 프로젝트를 살펴보는 중...\n`);
14
+ if (types.length && !(types.length === 1 && types[0] === "basic")) {
15
+ for (const t of types) {
16
+ const marker = markerForType(t);
17
+ out(`${GUT} ${OK} ${marker ? `${marker} 발견 → ` : ""}${paint(t, A.bold)} 감지\n`);
18
+ }
19
+ } else {
20
+ out(`${GUT} ${paint("─", A.dim)} 마커 파일 없음 → ${paint("basic", A.bold)} (직접 선택 가능)\n`);
21
+ }
22
+ out(`${GUT} ${OK} 버전: ${paint(`v${version}`, A.green)} · 브랜치: ${paint(branch, A.green)}\n`);
23
+ out(`${GUT}\n`);
24
+ }
25
+
26
+ // 층3 — 프로젝트 분석 개요 카드 (.ps1 Print-ProjectAnalysis 등가+)
27
+ export function printAnalysisCard({ mode = "", modeLabel = "", types = [], version = "", branch = "",
28
+ includeNexus = null, includeSecretBackup = null, paths = new Map(), showOptional = false },
29
+ out = (s) => process.stdout.write(s)) {
30
+ out(`${HEAD} ${paint("프로젝트 분석 결과", A.bold)}\n`);
31
+ const row = (icon, label, value) => out(`${GUT} ${icon} ${label.padEnd(10)} ${value}\n`);
32
+ row("📂", types.length > 1 ? "타입(멀티)" : "타입", paint(types.join(", ") || "basic", A.bold));
33
+ row("🌙", "버전", paint(`v${version}`, A.green));
34
+ row("🌿", "브랜치", branch);
35
+ if (modeLabel || mode) row("💫", "통합 모드", modeLabel || mode);
36
+ if (showOptional) {
37
+ row("📦", "Nexus", includeNexus === true ? paint("포함", A.green) : paint("제외", A.dim));
38
+ row("🔐", "Secret백업", includeSecretBackup === true ? paint("포함", A.green) : paint("제외", A.dim));
39
+ }
40
+ // 모노레포 경로 — 루트가 아닌 항목이 하나라도 있으면 표시
41
+ const nonRoot = [...paths.entries()].filter(([, p]) => p && p !== ".");
42
+ if (nonRoot.length) {
43
+ row("📁", "경로", [...paths.entries()].map(([t, p]) => `${t}→${p}`).join(", "));
44
+ }
45
+ out(`${GUT}\n`);
46
+ }
47
+
48
+ // 층4 — IDE Skills 현재 상태 수집 (어댑터 detect 순회 — 예외 없음 계약)
49
+ export function collectIdeStatuses(io) {
50
+ return ADAPTERS.map((a) => {
51
+ let st;
52
+ try { st = a.detect(io); } catch { st = { installed: false, version: null, cliMissing: true, note: "감지 실패" }; }
53
+ return { id: a.id, label: a.label, ...st };
54
+ });
55
+ }
56
+
57
+ // 층4 — IDE Skills 현재 상태 표시
58
+ export function printIdeStatus(statuses, out = (s) => process.stdout.write(s)) {
59
+ if (!statuses?.length) return;
60
+ out(`${HEAD} ${paint("AI 에이전트 스킬 상태", A.bold)}\n`);
61
+ for (const s of statuses) {
62
+ let mark, detail;
63
+ if (s.installed) {
64
+ mark = OK;
65
+ detail = paint(`설치됨${s.version ? ` v${s.version}` : ""}${s.scope ? ` (${s.scope})` : ""}`, A.green);
66
+ } else if (s.cliMissing) {
67
+ mark = paint("⚠", A.yellow);
68
+ detail = paint(s.note || "CLI 없음", A.yellow);
69
+ } else {
70
+ mark = paint("─", A.dim);
71
+ detail = paint("미설치", A.dim);
72
+ }
73
+ out(`${GUT} ${mark} ${s.label.padEnd(14)} ${detail}${s.note && !s.cliMissing ? paint(` ${s.note}`, A.dim) : ""}\n`);
74
+ }
75
+ out(`${GUT}\n`);
76
+ }
77
+
78
+ // 층5 — 신규 통합 vs 업데이트 판별 라인 (Breaking 박스는 breaking-check.js)
79
+ export function printInstallKind({ currentTemplateVersion = "", templateVersion = "" }, out = (s) => process.stdout.write(s)) {
80
+ if (currentTemplateVersion) {
81
+ out(`${GUT} ♻️ ${paint("업데이트", A.bold)} — 템플릿 ${paint(`v${currentTemplateVersion}`, A.dim)} → ${paint(`v${templateVersion}`, A.green)}\n`);
82
+ } else {
83
+ out(`${GUT} 🆕 ${paint("신규 통합", A.bold)} — 이 프로젝트에 처음 설치합니다 (템플릿 ${paint(`v${templateVersion}`, A.green)})\n`);
84
+ }
85
+ out(`${GUT}\n`);
86
+ }
@@ -0,0 +1,173 @@
1
+ // 완료 요약 출력 (.sh print_summary L5438~5626 등가). 전부 stderr.
2
+ // ctx: { mode, types:[], version, counters:{ workflows, utilModules } }
3
+ import { existsSync, readdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import {
6
+ PATHS, WORKFLOW_PREFIX, WORKFLOW_COMMON_PREFIX, WORKFLOW_TEMPLATE_INIT,
7
+ } from "../core/paths.js";
8
+ import { listYamlFiles } from "../core/fsutil.js";
9
+
10
+ const SEPARATOR = "────────────────────────────────────────";
11
+
12
+ export function printSummary(ctx, targetRoot = ".") {
13
+ const { mode, types = [], version = "", counters = {} } = ctx || {};
14
+ const err = (s = "") => process.stderr.write(`${s}\n`);
15
+ // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
16
+ const isTty = !!process.stderr.isTTY;
17
+ const YELLOW = isTty ? "\x1b[1;33m" : "";
18
+ const CYAN = isTty ? "\x1b[0;36m" : "";
19
+ const NC = isTty ? "\x1b[0m" : "";
20
+ const utilModulesCopied = counters.utilModules ?? 0;
21
+ const workflowsCopied = counters.workflows ?? 0;
22
+
23
+ err("");
24
+ err(SEPARATOR);
25
+ err("");
26
+ err("✨ projectops Setup Complete!");
27
+ err("");
28
+ err(SEPARATOR);
29
+ err("");
30
+ err("통합된 기능:");
31
+
32
+ // 모드별 체크리스트 (.sh L5449~5481)
33
+ switch (mode) {
34
+ case "full":
35
+ err(" ✅ 버전 관리 시스템 (version.yml)");
36
+ err(" ✅ README.md 자동 버전 업데이트");
37
+ err(" ✅ GitHub Actions 워크플로우");
38
+ if (utilModulesCopied > 0) err(` ✅ 유틸리티 모듈 (${utilModulesCopied} 개)`);
39
+ err(" ✅ 이슈/PR/Discussion 템플릿");
40
+ err(" ✅ CodeRabbit AI 리뷰 설정");
41
+ err(" ✅ .gitignore 필수 항목");
42
+ err(" ✅ 템플릿 설정 가이드 (SETUP-GUIDE.md)");
43
+ break;
44
+ case "version":
45
+ err(" ✅ 버전 관리 시스템 (version.yml)");
46
+ err(" ✅ README.md 자동 버전 업데이트");
47
+ err(" ✅ .gitignore 필수 항목");
48
+ err(" ✅ 템플릿 설정 가이드 (SETUP-GUIDE.md)");
49
+ break;
50
+ case "workflows":
51
+ err(" ✅ GitHub Actions 워크플로우");
52
+ if (utilModulesCopied > 0) err(` ✅ 유틸리티 모듈 (${utilModulesCopied} 개)`);
53
+ err(" ✅ 템플릿 설정 가이드 (SETUP-GUIDE.md)");
54
+ break;
55
+ case "issues":
56
+ err(" ✅ 이슈/PR/Discussion 템플릿");
57
+ break;
58
+ case "skills":
59
+ err(" ✅ Agent Skill 설치 (Claude, Cursor, Gemini, Codex, PI)");
60
+ break;
61
+ }
62
+
63
+ // skills 모드: 파일/워크플로우 추가 없으므로 간결하게 종료 (.sh L5484~5491)
64
+ if (mode === "skills") {
65
+ err("");
66
+ err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
67
+ err("");
68
+ err(SEPARATOR);
69
+ err("");
70
+ return;
71
+ }
72
+
73
+ err("");
74
+ err("추가된 파일:");
75
+ err(` 📄 version.yml (버전: ${version}, 타입: ${types.join(",")})`);
76
+ err(" 📝 README.md (버전 섹션 추가)");
77
+ err("");
78
+ err("추가된 워크플로우:");
79
+
80
+ // 실제 복사된 워크플로우와 기존 파일 구분 (.sh L5505~5534)
81
+ const commonWorkflows = [];
82
+ const typeWorkflows = [];
83
+ const existingWorkflows = [];
84
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
85
+ if (existsSync(workflowsDir)) {
86
+ const typePrefixes = types.map((t) => `${WORKFLOW_PREFIX}-${t.toUpperCase()}-`);
87
+ for (const filename of listYamlFiles(workflowsDir)) {
88
+ if (!filename.startsWith(`${WORKFLOW_PREFIX}-`)) continue; // PROJECT-*.{yaml,yml}만
89
+ if (filename === WORKFLOW_TEMPLATE_INIT) {
90
+ // TEMPLATE-INITIALIZER는 템플릿 전용 기존 파일로 분류
91
+ existingWorkflows.push(filename);
92
+ } else if (filename.startsWith(`${WORKFLOW_COMMON_PREFIX}-`)) {
93
+ commonWorkflows.push(filename);
94
+ } else if (typePrefixes.some((p) => filename.startsWith(p))) {
95
+ typeWorkflows.push(filename);
96
+ }
97
+ }
98
+ }
99
+
100
+ if (commonWorkflows.length > 0 || typeWorkflows.length > 0) {
101
+ err(` 📦 새로 설치됨 (${workflowsCopied}개):`);
102
+ for (const wf of commonWorkflows) err(` 📌 ${wf}`);
103
+ for (const wf of typeWorkflows) err(` 🎯 ${wf}`);
104
+ }
105
+ if (existingWorkflows.length > 0) {
106
+ err("");
107
+ err(" 🔧 기존 파일 유지됨:");
108
+ for (const wf of existingWorkflows) err(` 📌 ${wf} (템플릿 전용)`);
109
+ }
110
+
111
+ err("");
112
+ err(" 🔧 .github/scripts/");
113
+ err(" ├─ version_manager.sh");
114
+ err(" └─ changelog_manager.py");
115
+ err("");
116
+
117
+ // util 모듈 목록 (.sh L5566~5581) — 타입별 .github/util/{type}/*/ 스캔
118
+ if (utilModulesCopied > 0) {
119
+ err(" 🧙 유틸리티 모듈:");
120
+ for (const t of types) {
121
+ const utilDir = join(targetRoot, ".github/util", t);
122
+ if (!existsSync(utilDir)) continue;
123
+ let entries = [];
124
+ try { entries = readdirSync(utilDir, { withFileTypes: true }); } catch { /* 무시 */ }
125
+ for (const e of entries) {
126
+ if (e.isDirectory()) err(` ├─ ${e.name} (${t})`);
127
+ }
128
+ }
129
+ err("");
130
+ }
131
+
132
+ // 프로젝트 타입별 안내 (.sh L5583~5599)
133
+ if (types.includes("spring")) {
134
+ err(" 💡 Spring 프로젝트 추가 설정:");
135
+ err(" • build.gradle의 버전 정보가 자동 동기화됩니다");
136
+ err(" • CI/CD 워크플로우에서 GitHub Secrets 설정이 필요합니다");
137
+ err(" • 자세한 설정 방법: .github/workflows/project-types/spring/README.md");
138
+ err("");
139
+ }
140
+ if (types.includes("flutter") && utilModulesCopied > 0) {
141
+ err(" 💡 Flutter 배포 마법사 사용법:");
142
+ err(" • iOS TestFlight: .github/util/flutter/ios-testflight-setup-wizard/index.html");
143
+ err(" • Android Play Store: .github/util/flutter/android-playstore-setup-wizard/index.html");
144
+ err(" • 브라우저에서 열어 필요한 정보 입력 후 파일 생성");
145
+ err("");
146
+ }
147
+
148
+ err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
149
+ err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
150
+ err("");
151
+
152
+ // 필수 3가지 작업 안내 (.sh L5605~5625 — 원문 유지)
153
+ err(SEPARATOR);
154
+ err("");
155
+ err(`${YELLOW}⚠️ 다음 3가지 작업을 완료해주세요:${NC}`);
156
+ err("");
157
+ err(" 1️⃣ GitHub Personal Access Token 설정");
158
+ err(" → Repository Settings > Secrets > Actions");
159
+ err(" → Secret Name: _GITHUB_PAT_TOKEN");
160
+ err(" → Scopes: repo, workflow");
161
+ err("");
162
+ err(" 2️⃣ develop 브랜치 생성");
163
+ err(" → git checkout -b develop && git push -u origin develop");
164
+ err("");
165
+ err(" 3️⃣ CodeRabbit 활성화");
166
+ err(" → https://coderabbit.ai 방문하여 저장소 활성화");
167
+ err("");
168
+ err(SEPARATOR);
169
+ err("");
170
+ err(`${CYAN}📖 자세한 설정 방법은 다음 파일을 참고하세요:${NC}`);
171
+ err(" → SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md");
172
+ err("");
173
+ }