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.
package/src/index.js CHANGED
@@ -1,13 +1,20 @@
1
1
  // projectops CLI 진입 파이프라인 (.sh main + execute_integration 등가).
2
2
  // 감지 → 다운로드 → 모드 라우팅 → 통합 실행 → 정리. 비대화형(--force) 우선.
3
- import { join } from "node:path";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { readFileSync, existsSync } from "node:fs";
4
6
  import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
5
7
  import { HELP_TEXT } from "./cli/help.js";
6
8
  import { createContext } from "./context.js";
7
9
  import { PATHS } from "./core/paths.js";
8
10
  import { remove } from "./core/fsutil.js";
9
11
  import { acquireTemplate, readTemplateVersion } from "./core/assets.js";
10
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName } from "./core/detect-fs.js";
12
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "./core/detect-fs.js";
13
+ import { parseExisting } from "./core/version-yml.js";
14
+ import { runBreakingCheck } from "./core/breaking-check.js";
15
+ import { resolveProjectPaths } from "./core/paths-resolve.js";
16
+ import { printBannerCompact } from "./ui/banner.js";
17
+ import { printSummary } from "./ui/summary.js";
11
18
  import { runFull } from "./commands/full.js";
12
19
  import { runVersion } from "./commands/version.js";
13
20
  import { runWorkflows } from "./commands/workflows.js";
@@ -15,6 +22,17 @@ import { runIssues } from "./commands/issues.js";
15
22
  import { runInteractive } from "./commands/interactive.js";
16
23
  import { runSkills } from "./commands/skills.js";
17
24
 
25
+ // projectops 패키지 버전 읽기 (-v/--version 출력용). src/../package.json.
26
+ function readPkgVersion() {
27
+ try {
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
30
+ return pkg.version || "unknown";
31
+ } catch {
32
+ return "unknown";
33
+ }
34
+ }
35
+
18
36
  // 결정적 UTC 타임스탬프 (주입 가능 — 테스트/골든용)
19
37
  function utcNow(date = new Date()) {
20
38
  const p = (n) => String(n).padStart(2, "0");
@@ -34,6 +52,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
34
52
  if (e instanceof CliError) { console.error(e.message); return 1; }
35
53
  throw e;
36
54
  }
55
+ if (opts.showVersion) { console.log(readPkgVersion()); return 0; }
37
56
  if (opts.help) { console.log(HELP_TEXT); return 0; }
38
57
 
39
58
  // skills 모드 — IDE 스킬 설치/업데이트/제거 (템플릿 통합 없음).
@@ -63,40 +82,55 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
63
82
  return 1;
64
83
  }
65
84
 
85
+ // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh L2208~2239 SSoT)
86
+ const vyPath = join(cwd, "version.yml");
87
+ const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
88
+
66
89
  // 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
67
90
  const types = opts.types.length ? opts.types : detectTypes(cwd);
68
- const version = opts.version || detectVersion(cwd);
91
+ // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
92
+ const version = (existing?.version) || opts.version || detectVersion(cwd);
93
+ const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
69
94
  const branch = detectDefaultBranch(cwd);
70
95
  const repoName = detectRepoName(cwd);
71
- const paths = parsePathsCsv(opts.pathsCsv);
72
- // paths 미지정 타입은 루트(".") — basic 제외
73
- for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, ".");
96
+ // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
97
+ const paths = await resolveProjectPaths({
98
+ root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
99
+ existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
100
+ });
74
101
 
75
102
  const { now, today } = clock || utcNow();
76
103
  const tempDir = join(cwd, PATHS.tempDir);
77
104
 
78
105
  const context = createContext({
79
- mode: opts.mode, force: true, types, version, branch,
80
- paths, includeNexus: opts.includeNexus === true, includeSecretBackup: opts.includeSecretBackup === true,
106
+ mode: opts.mode, force: true, types, version, versionCode, branch,
107
+ paths,
108
+ // 옵션 워크플로우: CLI 플래그 최우선 → version.yml 저장 옵션(.sh read_template_options 등가) → false
109
+ includeNexus: opts.includeNexus ?? existing?.options?.nexus ?? false,
110
+ includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
81
111
  repoName,
82
- resolvers: {
83
- repo: () => repoName,
84
- "spring-app-yml-dir": () => "",
85
- "spring-app-yml-path": () => "",
86
- "flutter-root": () => paths.get("flutter") || ".",
87
- },
112
+ // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
113
+ resolvers: makeResolvers(cwd, repoName, paths),
88
114
  now, today,
89
115
  });
90
116
 
117
+ let result = null;
91
118
  try {
92
119
  acquireTemplate({ tempDir, source });
93
120
  context.templateVersion = readTemplateVersion(tempDir);
94
121
 
122
+ // 비대화형 축약 배너 (#446 확정 — 1줄, 로그 오염 최소)
123
+ printBannerCompact({ version: context.templateVersion, mode: opts.mode });
124
+
125
+ // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
126
+ const proceed = await runBreakingCheck({ cwd, tempDir, templateVersion: context.templateVersion });
127
+ if (!proceed) return 0;
128
+
95
129
  switch (opts.mode) {
96
- case "full": runFull(context, tempDir, cwd); break;
97
- case "version": runVersion(context, tempDir, cwd); break;
98
- case "workflows": runWorkflows(context, tempDir, cwd); break;
99
- case "issues": runIssues(context, tempDir, cwd); break;
130
+ case "full": result = runFull(context, tempDir, cwd); break;
131
+ case "version": result = runVersion(context, tempDir, cwd); break;
132
+ case "workflows": result = runWorkflows(context, tempDir, cwd); break;
133
+ case "issues": result = runIssues(context, tempDir, cwd); break;
100
134
  default:
101
135
  // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
102
136
  break;
@@ -104,5 +138,11 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
104
138
  } finally {
105
139
  remove(tempDir);
106
140
  }
141
+
142
+ // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
143
+ printSummary({
144
+ mode: opts.mode, types, version,
145
+ counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
146
+ }, cwd);
107
147
  return 0;
108
148
  }
package/src/ui/ansi.js ADDED
@@ -0,0 +1,26 @@
1
+ // 공용 ANSI 헬퍼 — banner/status-cards가 공유 (readline-engine 내부 헬퍼와 독립, 의존성 0)
2
+ const E = "\x1b[";
3
+ export const A = {
4
+ reset: `${E}0m`,
5
+ bold: `${E}1m`,
6
+ dim: `${E}2m`,
7
+ cyan: `${E}36m`,
8
+ green: `${E}32m`,
9
+ yellow: `${E}33m`,
10
+ magenta: `${E}35m`,
11
+ gray: `${E}90m`,
12
+ };
13
+ export const paint = (s, color) => `${color}${s}${A.reset}`;
14
+
15
+ // 대략적 표시 폭 (CJK 2칸 · ANSI 시퀀스 0칸) — 박스 우변 정렬용
16
+ export function visualWidth(s) {
17
+ const plain = String(s).replace(/\x1b\[[0-9;]*m/g, "");
18
+ let w = 0;
19
+ for (const ch of plain) {
20
+ const cp = ch.codePointAt(0);
21
+ // 한글·CJK·이모지 대략 2칸 (터미널 관례)
22
+ w += (cp >= 0x1100 && (cp <= 0x115f || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3)
23
+ || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xff00 && cp <= 0xff60) || cp >= 0x1f300)) ? 2 : 1;
24
+ }
25
+ return w;
26
+ }
@@ -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
@@ -1,13 +1,13 @@
1
- // 대화형 프롬프트 래핑 (.sh interactive_menu/choose_menu/ask_* 등가) — @clack/prompts 기반.
1
+ // 대화형 프롬프트 래핑 (.sh interactive_menu/choose_menu/ask_* 등가).
2
+ // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY에서 Enter가 멈추는 버그로 제거).
2
3
  // 취소(ESC/Ctrl+C)는 각 함수가 CANCEL 심볼을 반환 → 호출부가 정상 종료(exit 0) 처리.
3
- import * as clack from "@clack/prompts";
4
+ import * as engine from "./readline-engine.js";
4
5
 
5
- export const CANCEL = Symbol("cancel");
6
- const wrap = (v) => (clack.isCancel(v) ? CANCEL : v);
6
+ export const CANCEL = engine.CANCEL;
7
7
 
8
8
  // 모드 선택 — 한국어 라벨, 내부 키 반환. 취소 시 CANCEL.
9
9
  export async function selectMode() {
10
- const v = await clack.select({
10
+ return engine.select({
11
11
  message: "무엇을 설치할까요?",
12
12
  options: [
13
13
  { value: "full", label: "전체 설치 — 버전관리 + 자동화 워크플로우 + 이슈·PR 템플릿 (처음이라면 추천)" },
@@ -17,12 +17,11 @@ export async function selectMode() {
17
17
  { value: "skills", label: "AI 스킬만 — Claude·Cursor·Gemini·Codex·PI용 스킬만 설치" },
18
18
  ],
19
19
  });
20
- return wrap(v);
21
20
  }
22
21
 
23
22
  // 프로젝트 확인 화면 메뉴 (계속/수정/취소).
24
23
  export async function confirmProjectMenu() {
25
- const v = await clack.select({
24
+ return engine.select({
26
25
  message: "이 정보로 진행할까요?",
27
26
  options: [
28
27
  { value: "continue", label: "예, 계속 진행" },
@@ -30,7 +29,6 @@ export async function confirmProjectMenu() {
30
29
  { value: "cancel", label: "아니오, 취소" },
31
30
  ],
32
31
  });
33
- return wrap(v);
34
32
  }
35
33
 
36
34
  // 수정 메뉴 — 어떤 항목을 고칠지. showOptional=full/workflows에서만 nexus/secret 노출.
@@ -45,38 +43,60 @@ export async function editMenu({ showOptional = false } = {}) {
45
43
  options.push({ value: "secret", label: "Secret 백업 포함 여부" });
46
44
  }
47
45
  options.push({ value: "done", label: "모두 맞음, 계속" });
48
- const v = await clack.select({ message: "어떤 항목을 수정할까요?", options });
49
- return wrap(v);
46
+ return engine.select({ message: "어떤 항목을 수정할까요?", options });
50
47
  }
51
48
 
52
49
  // 타입 멀티선택.
53
50
  export async function selectTypes(current = []) {
54
51
  const all = ["spring", "flutter", "next", "react", "react-native", "react-native-expo", "node", "python", "basic"];
55
- const v = await clack.multiselect({
52
+ return engine.multiselect({
56
53
  message: "프로젝트 타입을 선택하세요 (Space 토글, Enter 확정)",
57
54
  options: all.map((t) => ({ value: t, label: t })),
58
55
  initialValues: current.length ? current : ["basic"],
59
56
  required: true,
60
57
  });
61
- return wrap(v);
62
58
  }
63
59
 
64
60
  // 텍스트 입력 (빈 입력=기본값 유지).
65
61
  export async function askText(message, defaultValue = "") {
66
- const v = await clack.text({ message, placeholder: defaultValue, defaultValue });
67
- const w = wrap(v);
68
- if (w === CANCEL) return CANCEL;
69
- return w === "" || w == null ? defaultValue : w;
62
+ const v = await engine.text({ message, defaultValue });
63
+ if (v === CANCEL) return CANCEL;
64
+ return v === "" || v == null ? defaultValue : v;
70
65
  }
71
66
 
72
67
  // 예/아니오.
73
68
  export async function askYesNo(message, initial = true) {
74
- const v = await clack.confirm({ message, initialValue: initial });
75
- return wrap(v);
69
+ return engine.confirm({ message, initialValue: initial });
76
70
  }
77
71
 
78
72
  // 배너·안내 출력.
79
- export function intro(text) { clack.intro(text); }
80
- export function outro(text) { clack.outro(text); }
81
- export function note(text, title) { clack.note(text, title); }
82
- export function cancelMessage(text = "취소했습니다.") { clack.cancel(text); }
73
+ export function intro(text) { engine.intro(text); }
74
+ export function outro(text) { engine.outro(text); }
75
+ export function note(text, title) { engine.note(text, title); }
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
+ };