projectops 4.0.3 → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/full.js +3 -3
- package/src/commands/interactive.js +145 -32
- package/src/commands/workflows.js +2 -2
- package/src/core/breaking-check.js +65 -0
- package/src/core/copy/workflows.js +81 -18
- package/src/core/detect-fs.js +43 -1
- package/src/core/options-ask.js +100 -0
- package/src/core/paths-resolve.js +261 -0
- package/src/core/version-yml.js +40 -1
- package/src/core/wizard-labels.js +106 -0
- package/src/index.js +44 -18
- package/src/ui/ansi.js +26 -0
- package/src/ui/banner.js +30 -0
- package/src/ui/env-plan.js +207 -0
- package/src/ui/prompts.js +26 -0
- package/src/ui/status-cards.js +86 -0
- package/src/ui/summary.js +173 -0
package/src/ui/banner.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 첫 화면 배너 (#446 층1) — 클래식 박스형 (사용자 확정 시안 A)
|
|
2
|
+
// .ps1 Print-Banner 계승 + 브랜딩을 projectops로 갱신.
|
|
3
|
+
import { A, paint, visualWidth } from "./ansi.js";
|
|
4
|
+
|
|
5
|
+
const INNER = 56; // 박스 내부 폭
|
|
6
|
+
|
|
7
|
+
function boxLine(out, content = "") {
|
|
8
|
+
const pad = Math.max(0, INNER - visualWidth(content));
|
|
9
|
+
out(paint("║", A.cyan) + content + " ".repeat(pad) + paint("║", A.cyan) + "\n");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 대화형 첫 화면 배너 — 박스 타이틀 + 메타 4줄 (.ps1 Print-Banner 등가, #446 확정 시안 A)
|
|
13
|
+
export function printBanner({ version, modeLabel }, out = (s) => process.stdout.write(s)) {
|
|
14
|
+
out("\n");
|
|
15
|
+
out(paint(`╔${"═".repeat(INNER)}╗`, A.cyan) + "\n");
|
|
16
|
+
boxLine(out);
|
|
17
|
+
boxLine(out, ` ${paint("✦", A.yellow)} ${paint("P R O J E C T O P S", A.bold)} ${paint("✦", A.yellow)}`);
|
|
18
|
+
boxLine(out);
|
|
19
|
+
out(paint(`╚${"═".repeat(INNER)}╝`, A.cyan) + "\n");
|
|
20
|
+
out(` 🌙 Version : ${paint(`v${version}`, A.green)}\n`);
|
|
21
|
+
out(` 🐵 Author : Cassiiopeia\n`);
|
|
22
|
+
out(` 🪐 Mode : ${modeLabel}\n`);
|
|
23
|
+
out(` 📦 Repo : ${paint("github.com/Cassiiopeia/projectops", A.dim)}\n`);
|
|
24
|
+
out("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 비대화형(--force/CI) 축약 배너 — 1줄 (사용자 확정: 로그 오염 최소 + 버전 추적)
|
|
28
|
+
export function printBannerCompact({ version, mode }, out = (s) => process.stdout.write(s)) {
|
|
29
|
+
out(`${paint("✦", A.yellow)} ${paint("projectops", A.bold)} v${version} — ${mode} 모드 (--force)\n`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// @wizard env 계획 질문 UI (.sh wf_scope_string/wf_collect_asks/_wf_print_field_card/
|
|
2
|
+
// _wf_prefill_all/_wf_prefill_interactive/wf_prompt_env_plan 등가).
|
|
3
|
+
// 실측 기준: template_integrator.sh 3059~3085(scope), 3087~3143(collect), 3152~3169(card),
|
|
4
|
+
// 3220~3280(prompt 본체 wf_prompt_env_plan).
|
|
5
|
+
// io 주입식 — 테스트는 {select, multiselect, text} 스텁을 넘긴다. 기본은 readline-engine 실물.
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { stdin, stderr } from "node:process";
|
|
9
|
+
import { PATHS } from "../core/paths.js";
|
|
10
|
+
import { exists, listYamlFiles } from "../core/fsutil.js";
|
|
11
|
+
import { parseWizardLine, resolveToken } from "../core/wizard-env.js";
|
|
12
|
+
import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
|
|
13
|
+
import * as engine from "./readline-engine.js";
|
|
14
|
+
|
|
15
|
+
const CANCEL = engine.CANCEL;
|
|
16
|
+
|
|
17
|
+
// 진행 안내는 stderr로 출력 (.sh print_to_user가 >&2인 것과 동일 — stdout 파이프 오염 방지).
|
|
18
|
+
const defaultLog = (s = "") => stderr.write(s + "\n");
|
|
19
|
+
|
|
20
|
+
// 사용처 문자열 조립 (.sh wf_scope_string 등가).
|
|
21
|
+
// usages: [{type, workflowName}] — 타입이 여러 개면 타입만 "t1·t2", 하나면 "타입 name1·name2".
|
|
22
|
+
export function scopeString(usages = []) {
|
|
23
|
+
const types = []; const names = [];
|
|
24
|
+
for (const { type, workflowName } of usages) {
|
|
25
|
+
if (!types.includes(type)) types.push(type);
|
|
26
|
+
if (!names.includes(workflowName)) names.push(workflowName);
|
|
27
|
+
}
|
|
28
|
+
if (types.length > 1) return types.join("·");
|
|
29
|
+
return `${types.join("·")} ${names.join("·")}`.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ask KEY 수집 (.sh wf_collect_asks 등가) — 실제 설치되는 워크플로우와 같은 소스를 스캔한다.
|
|
33
|
+
// tempDir: 다운로드 원본 루트. types: 설치 대상 타입 목록.
|
|
34
|
+
// opts:
|
|
35
|
+
// resolvers - @접두 기본값(@repo 등) 해석용 (.sh는 수집 시점에 resolve_token — 동일)
|
|
36
|
+
// includeNexus - true면 server-deploy 제외 + nexus/ 포함 (복사 엔진과 스캔 범위 일치)
|
|
37
|
+
// prompts - wizard-labels 파싱 객체 (워크플로우 표시명용, null이면 확장자 제거 폴백)
|
|
38
|
+
// 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
|
|
39
|
+
// usages:Map<key,[{type,workflowName}]> }
|
|
40
|
+
export function collectAsks(tempDir, types = [], opts = {}) {
|
|
41
|
+
const { resolvers = {}, includeNexus = false, prompts = null } = opts;
|
|
42
|
+
const baseDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
43
|
+
const keys = [];
|
|
44
|
+
const defaults = new Map();
|
|
45
|
+
const typeDefaults = new Map();
|
|
46
|
+
const usages = new Map();
|
|
47
|
+
|
|
48
|
+
for (const type of types) {
|
|
49
|
+
const typeDir = join(baseDir, type);
|
|
50
|
+
if (!exists(typeDir)) continue;
|
|
51
|
+
// 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (nexus 아니면) server-deploy + (nexus면) nexus
|
|
52
|
+
const dirs = [typeDir];
|
|
53
|
+
if (!includeNexus) dirs.push(join(typeDir, "server-deploy"));
|
|
54
|
+
else dirs.push(join(typeDir, "nexus"));
|
|
55
|
+
|
|
56
|
+
for (const dir of dirs) {
|
|
57
|
+
if (!exists(dir)) continue;
|
|
58
|
+
for (const filename of listYamlFiles(dir)) {
|
|
59
|
+
const content = readFileSync(join(dir, filename), "utf8");
|
|
60
|
+
if (!content.includes("@wizard")) continue;
|
|
61
|
+
const workflowName = workflowDisplayName(prompts, filename);
|
|
62
|
+
for (const line of content.split(/\r?\n/)) {
|
|
63
|
+
const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
|
|
64
|
+
if (!p || p.action !== "ask") continue;
|
|
65
|
+
// 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
|
|
66
|
+
const typeDefault = p.arg.startsWith("@")
|
|
67
|
+
? resolveToken(p.arg.slice(1), type, resolvers)
|
|
68
|
+
: p.arg;
|
|
69
|
+
typeDefaults.set(`${type}|${p.key}`, typeDefault);
|
|
70
|
+
if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
|
|
71
|
+
const list = usages.get(p.key) || [];
|
|
72
|
+
list.push({ type, workflowName });
|
|
73
|
+
usages.set(p.key, list);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { keys, defaults, typeDefaults, usages };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// KEY가 처음 등장한 type (.sh _wf_first_type_for — 라벨 조회 시 타입 오버라이드 우선순위용)
|
|
82
|
+
function firstTypeFor(usages, key) {
|
|
83
|
+
return usages.get(key)?.[0]?.type ?? "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력 (.sh _wf_print_field_card 등가).
|
|
87
|
+
// info: { default, usages } — idx/tot 있으면 "(i/t)" 진행 표시. log 주입 가능(테스트 무음화).
|
|
88
|
+
export function printFieldCard(prompts, key, info, idx = null, tot = null, log = defaultLog) {
|
|
89
|
+
const t = info.usages?.[0]?.type ?? "";
|
|
90
|
+
const label = wfField(prompts, t, key, "label");
|
|
91
|
+
const help = wfField(prompts, t, key, "help");
|
|
92
|
+
const ex = wfField(prompts, t, key, "example");
|
|
93
|
+
const scope = scopeString(info.usages || []);
|
|
94
|
+
const head = idx != null && tot != null
|
|
95
|
+
? ` ▸ (${idx}/${tot}) ${label} [${scope}]`
|
|
96
|
+
: ` ▸ ${label} [${scope}]`;
|
|
97
|
+
log(head);
|
|
98
|
+
if (help) log(` ${help}`);
|
|
99
|
+
if (ex) log(` 예) ${ex}`);
|
|
100
|
+
log(` 기본값: ${info.default ?? ""}`);
|
|
101
|
+
log("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 지정 KEY들을 하나씩 입력받아 values에 기록 (.sh _wf_prefill_interactive 등가).
|
|
105
|
+
// 빈 입력(Enter)/ESC → KEY 공통 기본값 유지 (.sh safe_read || _in="" 등가).
|
|
106
|
+
async function promptEach(io, prompts, asks, todoKeys, values, log) {
|
|
107
|
+
const tot = todoKeys.length;
|
|
108
|
+
if (tot === 0) return;
|
|
109
|
+
log("");
|
|
110
|
+
log(" 값을 입력하세요. 그대로 두려면 아무것도 입력하지 말고 Enter를 누르면 기본값이 적용됩니다.");
|
|
111
|
+
log("");
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (const key of todoKeys) {
|
|
114
|
+
i++;
|
|
115
|
+
const def = asks.defaults.get(key) ?? "";
|
|
116
|
+
printFieldCard(prompts, key, { default: def, usages: asks.usages.get(key) || [] }, i, tot, log);
|
|
117
|
+
let input = await io.text({ message: `↳ 값 입력 (Enter=기본값 «${def}» 유지):`, defaultValue: def });
|
|
118
|
+
if (input === CANCEL || input == null || input === "") input = def;
|
|
119
|
+
values.set(key, input);
|
|
120
|
+
const label = wfField(prompts, firstTypeFor(asks.usages, key), key, "label");
|
|
121
|
+
log(` → ${label} = ${input}`);
|
|
122
|
+
log("");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 배포 env 설정 계획 (.sh wf_prompt_env_plan 등가).
|
|
127
|
+
// 반환: { values: Map<key,value>, useDefaults: boolean }
|
|
128
|
+
// - useDefaults=true → 호출부는 substituteEnv에 그대로 넘기면 타입별 기본값 경로(.sh _wf_prefill_all 등가)
|
|
129
|
+
// - useDefaults=false → values에 담긴 키만 사용자 확정값으로 치환, 나머지는 기본값
|
|
130
|
+
// (⚠️ substituteEnv는 useDefaults=false일 때만 values를 참조하므로 이 플래그를 반드시 함께 전달)
|
|
131
|
+
// 인자:
|
|
132
|
+
// tempDir/types/resolvers/includeNexus — collectAsks와 동일 의미
|
|
133
|
+
// targetRoot — wizard-prompts.yml 1차 탐색 위치(기본 ".")
|
|
134
|
+
// force — true면 질문 없이 전부 기본값 (.sh FORCE_MODE 등가)
|
|
135
|
+
// io — {select, multiselect, text} 주입 (기본 readline-engine). 테스트 스텁 지점.
|
|
136
|
+
// log — 카드·안내 출력 함수 주입 (기본 stderr)
|
|
137
|
+
export async function promptEnvPlan({
|
|
138
|
+
tempDir, types = [], io = null, force = false, resolvers = {},
|
|
139
|
+
includeNexus = false, targetRoot = ".", repoName = "", log = defaultLog,
|
|
140
|
+
} = {}) {
|
|
141
|
+
const prompts = loadWizardPrompts(targetRoot, tempDir);
|
|
142
|
+
const asks = collectAsks(tempDir, types, { resolvers, includeNexus, prompts });
|
|
143
|
+
const defaults = asks.defaults;
|
|
144
|
+
|
|
145
|
+
// 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
|
|
146
|
+
if (asks.keys.length === 0) return { values: new Map(), useDefaults: true };
|
|
147
|
+
|
|
148
|
+
// 비대화형: force 또는 (io 미주입 && 비TTY) → 전부 기본값 (.sh FORCE_MODE/TTY_AVAILABLE 분기 등가)
|
|
149
|
+
// io가 주입돼 있으면(테스트/상위 마법사) TTY 여부와 무관하게 대화형으로 진행한다.
|
|
150
|
+
const interactive = !force && (io != null || stdin.isTTY);
|
|
151
|
+
if (!interactive) return { values: new Map(defaults), useDefaults: true };
|
|
152
|
+
|
|
153
|
+
const ui = io ?? engine;
|
|
154
|
+
|
|
155
|
+
// 기본값 미리보기 카드 전체 출력 (.sh 3237~3251)
|
|
156
|
+
log("");
|
|
157
|
+
log("▶ 배포 워크플로우 환경설정을 채웁니다");
|
|
158
|
+
log("");
|
|
159
|
+
log(" 설치되는 배포 워크플로우가 사용할 값입니다. 항목마다 '무엇에 쓰이는지·설명·예시'와");
|
|
160
|
+
log(" 기본값을 함께 보여드립니다. 그대로 둬도 되고, 원하는 것만 바꿀 수 있습니다.");
|
|
161
|
+
log("");
|
|
162
|
+
const tot = asks.keys.length;
|
|
163
|
+
asks.keys.forEach((key, i) => {
|
|
164
|
+
printFieldCard(prompts, key, { default: defaults.get(key), usages: asks.usages.get(key) || [] }, i + 1, tot, log);
|
|
165
|
+
});
|
|
166
|
+
log(" ─────────────────────────────────────────────");
|
|
167
|
+
|
|
168
|
+
const choice = await ui.select({
|
|
169
|
+
message: "어떻게 채울까요?",
|
|
170
|
+
options: [
|
|
171
|
+
{ value: "all", label: "① 위 기본값 그대로 전부 설치 (입력 없이 바로 진행)" },
|
|
172
|
+
{ value: "each", label: "② 하나씩 직접 입력 (모든 항목을 순서대로)" },
|
|
173
|
+
{ value: "some", label: "③ 몇 개만 골라서 바꾸기 (고른 것만 입력 · 나머지는 기본값)" },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
// ESC/취소 → 전부 기본값 (.sh `if [ "$_rc" -ne 0 ]` 등가)
|
|
177
|
+
if (choice === CANCEL || choice == null || choice === "all") {
|
|
178
|
+
return { values: new Map(defaults), useDefaults: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 사용자가 확정한 키만 values에 담는다 — substituteEnv(useDefaults:false)가
|
|
182
|
+
// values에 없는 키는 타입별 기본값으로 채우므로 .sh(_wf_prefill_all 후 덮어쓰기)와 등가.
|
|
183
|
+
const values = new Map();
|
|
184
|
+
if (choice === "each") {
|
|
185
|
+
await promptEach(ui, prompts, asks, asks.keys, values, log);
|
|
186
|
+
return { values, useDefaults: false };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// some: 바꿀 항목만 멀티선택 → 고른 것만 입력 (.sh 3266~3277)
|
|
190
|
+
const options = asks.keys.map((key) => ({
|
|
191
|
+
value: key,
|
|
192
|
+
label: `${wfField(prompts, firstTypeFor(asks.usages, key), key, "label")} (기본: ${defaults.get(key)})`,
|
|
193
|
+
}));
|
|
194
|
+
const selected = await ui.multiselect({
|
|
195
|
+
message: "바꿀 항목을 고르세요 (Space로 선택 · Enter로 확정)",
|
|
196
|
+
options,
|
|
197
|
+
initialValues: [],
|
|
198
|
+
});
|
|
199
|
+
// ESC/빈 선택 → 전부 기본값 (.sh: _wf_prefill_all만 수행)
|
|
200
|
+
if (selected === CANCEL || !Array.isArray(selected) || selected.length === 0) {
|
|
201
|
+
return { values: new Map(defaults), useDefaults: true };
|
|
202
|
+
}
|
|
203
|
+
// 수집 키 순서 유지 + WF_ASK_KEYS 멤버만 인정 (.sh _wf_prefill_interactive 필터 등가)
|
|
204
|
+
const todo = asks.keys.filter((k) => selected.includes(k));
|
|
205
|
+
await promptEach(ui, prompts, asks, todo, values, log);
|
|
206
|
+
return { values, useDefaults: false };
|
|
207
|
+
}
|
package/src/ui/prompts.js
CHANGED
|
@@ -74,3 +74,29 @@ export function intro(text) { engine.intro(text); }
|
|
|
74
74
|
export function outro(text) { engine.outro(text); }
|
|
75
75
|
export function note(text, title) { engine.note(text, title); }
|
|
76
76
|
export function cancelMessage(text = "취소했습니다.") { engine.cancelMessage(text); }
|
|
77
|
+
|
|
78
|
+
// ── #446 첫 화면 UI 5층 + SP2-C 대화형 계층 실물 io ─────────────────
|
|
79
|
+
// runInteractive는 io.<method>?.() 옵셔널 호출 — 테스트 스텁은 이 메서드들을 생략해
|
|
80
|
+
// 시각 층·env 질문을 건너뛴다 (실행 계약은 그대로).
|
|
81
|
+
import { printBanner as _printBanner } from "./banner.js";
|
|
82
|
+
import {
|
|
83
|
+
printDetectionLog as _detLog, printAnalysisCard as _card,
|
|
84
|
+
printIdeStatus as _ideStatus, printInstallKind as _installKind, collectIdeStatuses,
|
|
85
|
+
} from "./status-cards.js";
|
|
86
|
+
import { printSummary as _summary } from "./summary.js";
|
|
87
|
+
import { defaultIo } from "../core/ide/runner.js";
|
|
88
|
+
|
|
89
|
+
export function banner(info) { _printBanner(info); }
|
|
90
|
+
export function detectionLog(info) { _detLog(info); }
|
|
91
|
+
export function analysisCard(info) { _card(info); }
|
|
92
|
+
export function installKind(info) { _installKind(info); }
|
|
93
|
+
export function ideStatus() { _ideStatus(collectIdeStatuses(defaultIo())); }
|
|
94
|
+
export function summary(ctx, targetRoot) { _summary(ctx, targetRoot); }
|
|
95
|
+
|
|
96
|
+
// env 계획·경로 해석·충돌 메뉴가 쓰는 저수준 엔진 io (env-plan/paths-resolve의 io 계약)
|
|
97
|
+
export const engineIo = {
|
|
98
|
+
select: engine.select,
|
|
99
|
+
multiselect: engine.multiselect,
|
|
100
|
+
text: engine.text,
|
|
101
|
+
confirm: engine.confirm,
|
|
102
|
+
};
|
|
@@ -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
|
+
}
|