project-auto-wizard 0.2.0 → 0.3.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.
Files changed (43) hide show
  1. package/README.md +15 -4
  2. package/package.json +1 -1
  3. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  4. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  5. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
  6. package/payload/scripts/issue_helper.py +334 -0
  7. package/payload/scripts/truncate_release_notes.py +89 -0
  8. package/payload/version.yml.template +1 -0
  9. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +47 -0
  10. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +4 -4
  11. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +1 -1
  12. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +1 -1
  13. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +1 -1
  14. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +6 -6
  15. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +1 -1
  16. package/payload/workflows/spring/{PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml → nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml} +8 -2
  17. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +8 -6
  18. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +2 -2
  19. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +8 -8
  20. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +2 -2
  21. package/src/cli/args.js +9 -0
  22. package/src/cli/help.js +2 -1
  23. package/src/commands/dry-run.js +7 -16
  24. package/src/commands/full.js +75 -16
  25. package/src/commands/interactive.js +47 -6
  26. package/src/commands/uninstall.js +3 -1
  27. package/src/core/copy/simple.js +2 -2
  28. package/src/core/copy/workflows.js +32 -29
  29. package/src/core/deploy-style.js +90 -0
  30. package/src/core/detect-fs.js +36 -7
  31. package/src/core/detect.js +68 -3
  32. package/src/core/install-log.js +182 -0
  33. package/src/core/options-ask.js +5 -2
  34. package/src/core/paths-resolve.js +4 -8
  35. package/src/core/removal-plan.js +1 -1
  36. package/src/core/verify.js +86 -0
  37. package/src/core/version-yml.js +31 -4
  38. package/src/core/wizard-env.js +6 -3
  39. package/src/index.js +15 -2
  40. package/src/ui/env-plan.js +63 -33
  41. package/src/ui/prompts.js +41 -2
  42. package/src/ui/status-cards.js +7 -3
  43. package/src/ui/summary.js +56 -6
@@ -9,7 +9,7 @@
9
9
  // io.log(line) → 안내 출력 (없으면 stderr)
10
10
  import { existsSync, readdirSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { markerForType as baseMarkerForType, extraMarkers } from "./detect.js";
12
+ import { markerForType as baseMarkerForType, resolveMarker } from "./detect.js";
13
13
  import { normalizePath, CliError } from "../cli/args.js";
14
14
 
15
15
  // 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
@@ -24,15 +24,11 @@ export function markerForType(type) {
24
24
  return KNOWN_MARKER_TYPES.has(type) ? baseMarkerForType(type) : "";
25
25
  }
26
26
 
27
- // 디렉토리에 실재하는 마커 파일명 반환 — 보조 마커 포함, 없으면 대표 마커 (표시용).
27
+ // 디렉토리에 실재하는 마커 파일명 반환 — resolveMarker의 fs 구동판.
28
28
  // (.sh existing_marker_in_dir L1232~1245: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
29
29
  export function existingMarkerInDir(type, dir) {
30
- const primary = markerForType(type);
31
- const names = primary ? [primary, ...extraMarkers(type)] : [];
32
- for (const n of names) {
33
- if (existsSync(join(dir, n))) return n;
34
- }
35
- return primary;
30
+ if (!markerForType(type)) return ""; // .sh 등가: 미지 타입은 빈 문자열
31
+ return resolveMarker(type, (n) => existsSync(join(dir, n)));
36
32
  }
37
33
 
38
34
  // maxdepth 3 재귀 파일 탐색 — 매치 파일의 "디렉토리" 상대경로(루트는 ".")를 수집.
@@ -63,7 +63,7 @@ export function planRemoval(payloadRoot, targetRoot = ".") {
63
63
  // (b) 관리 마커로 시작하는 것 — payload에서 이름이 바뀌거나 삭제된 파일도 인식 (issue #20 L12).
64
64
  for (const name of markedWorkflowNames(wfDir)) removedWf.add(name);
65
65
  }
66
- for (const s of ["version_manager.py", "changelog_manager.py"]) {
66
+ for (const s of ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"]) {
67
67
  if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
68
68
  }
69
69
  // baseline은 마법사가 만든 내부 상태 파일이다 — 설치물을 지우면 함께 사라져야 한다.
@@ -0,0 +1,86 @@
1
+ // 설치 후 검증 (이슈 #81, #80) — 설치된 워크플로우를 다시 읽어 "이대로 돌아가는가"를 본다.
2
+ //
3
+ // 왜 설치 전이 아니라 후인가: 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에
4
+ // 의존한다. 최종 디스크 내용을 보는 것이 실제로 배포될 것과 같은 것을 보는 유일한 방법이다.
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+
8
+ // 치환 대상이 아닌 토큰 — 워크플로우 스크립트 안의 heredoc 구분자다. 값이 아니라 문법이므로
9
+ // 미치환 검사에서 제외한다. (예: cat <<'__SUH_FILE_CONTENT_EOF__')
10
+ const SENTINEL_RE = /^__SUH_[A-Z0-9_]*__$/;
11
+ const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g;
12
+
13
+ // 주석으로 죽어 있는 줄 — 실행되지 않으므로 검사 대상이 아니다.
14
+ // 템플릿에는 "[선택] ..." 예시 스텝이 통째로 주석 처리돼 들어 있는데, 이걸 세면
15
+ // 쓰지도 않는 Secret을 "등록하세요"라고 안내하게 된다.
16
+ const isCommented = (line) => /^\s*#/.test(line);
17
+
18
+ // 미치환 플레이스홀더 스캔 (이슈 #81).
19
+ // 종전에는 auto 토큰 계산이 실패해도(예: application.yaml을 못 찾아 경로가 빈 문자열) 그 줄을
20
+ // 건드리지 않고 넘어가, __APPLICATION_YML_DIR__ 이 그대로 남은 워크플로우가 "설치 성공"으로
21
+ // 끝났다. 배포 시점에야 그 이름의 디렉토리가 만들어지며 문제가 드러난다.
22
+ //
23
+ // 반환: [{ filename, line, token, text }] — 사람이 바로 고칠 수 있게 줄 번호까지 준다.
24
+ export function scanUnsubstituted(workflowsDir, filenames = []) {
25
+ const found = [];
26
+ for (const filename of filenames) {
27
+ const p = join(workflowsDir, filename);
28
+ if (!existsSync(p)) continue;
29
+ let content;
30
+ try { content = readFileSync(p, "utf8"); } catch { continue; }
31
+ content.split(/\r?\n/).forEach((text, i) => {
32
+ if (isCommented(text)) return;
33
+ for (const token of text.match(PLACEHOLDER_RE) || []) {
34
+ if (SENTINEL_RE.test(token)) continue;
35
+ found.push({ filename, line: i + 1, token, text: text.trim() });
36
+ }
37
+ });
38
+ }
39
+ return found;
40
+ }
41
+
42
+ // GITHUB_TOKEN은 Actions가 자동 주입하므로 사용자가 등록할 대상이 아니다.
43
+ const AUTO_SECRETS = new Set(["GITHUB_TOKEN"]);
44
+ // 없어도 워크플로우가 도는 secret — 폴백이 문서화돼 있다. 필수와 섞어 "등록해야 동작합니다"라고
45
+ // 하면 안내 자체를 못 믿게 되므로 분리한다.
46
+ // AI_API_KEY → 없으면 GitHub Models(무료) → 규칙 fallback
47
+ // WORKFLOW_PAT → 없으면 GITHUB_TOKEN
48
+ export const OPTIONAL_SECRETS = new Set(["AI_API_KEY", "WORKFLOW_PAT"]);
49
+ const SECRET_RE = /secrets\.([A-Z][A-Z0-9_]*)/g;
50
+
51
+ // 설치된 워크플로우가 요구하는 GitHub Secret 목록 (이슈 #80).
52
+ // 완료 화면이 WORKFLOW_PAT와 권한만 안내하는 바람에, 배포 워크플로우가 실제로 필요로 하는
53
+ // SERVER_HOST·SSH_KEY 같은 값이 하나도 안내되지 않았다. 설치 직후 상태로는 배포가 돌지 않는데
54
+ // 그 사실이 어디에도 드러나지 않는다.
55
+ //
56
+ // 반환: Map<secretName, string[] 그 secret을 쓰는 파일명>
57
+ export function collectRequiredSecrets(workflowsDir, filenames = []) {
58
+ const out = new Map();
59
+ for (const filename of filenames) {
60
+ const p = join(workflowsDir, filename);
61
+ if (!existsSync(p)) continue;
62
+ let content;
63
+ try { content = readFileSync(p, "utf8"); } catch { continue; }
64
+ for (const line of content.split(/\r?\n/)) {
65
+ if (isCommented(line)) continue;
66
+ for (const m of line.matchAll(SECRET_RE)) {
67
+ const name = m[1];
68
+ if (AUTO_SECRETS.has(name) || OPTIONAL_SECRETS.has(name)) continue;
69
+ if (!out.has(name)) out.set(name, []);
70
+ const users = out.get(name);
71
+ if (!users.includes(filename)) users.push(filename);
72
+ }
73
+ }
74
+ }
75
+ return new Map([...out.entries()].sort(([a], [b]) => a.localeCompare(b)));
76
+ }
77
+
78
+ // SSH 인증 방식에 따라 둘 중 하나만 필요한 secret — 사용자가 이미 답한 값으로 목록을 좁힌다.
79
+ // 안 쓸 secret까지 "등록하세요"라고 하면 안내 자체를 신뢰하지 않게 된다.
80
+ export function narrowSecretsBySshAuth(secrets, sshAuthMethod) {
81
+ if (!sshAuthMethod) return secrets;
82
+ const drop = sshAuthMethod === "key" ? "SERVER_PASSWORD" : "SSH_KEY";
83
+ const out = new Map(secrets);
84
+ out.delete(drop);
85
+ return out;
86
+ }
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_DEPLOY_STYLE } from "./deploy-style.js";
1
2
  import { escapeYamlDoubleQuoted } from "./wizard-env.js";
2
3
 
3
4
  // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
@@ -38,9 +39,11 @@ export function parseExtraTopLevel(content) {
38
39
  // 구 synology·coderabbit 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다(파싱 에러 없음).
39
40
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
40
41
  export function parseTemplateOptions(content) {
41
- const out = { nexus: null, secretBackup: null, semverAuto: null };
42
+ const out = { nexus: null, secretBackup: null, semverAuto: null, deployStyle: null };
42
43
  // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
43
- const strip = (s) => String(s).replace(/["']/g, "").trim();
44
+ // 인라인 주석(` # ...`) 먼저 떼고 따옴표·공백을 정리한다. 문자열 값을 받는 키(deploy_style)
45
+ // 주석을 안 떼면 "simple # simple | nginx ..." 가 통째로 값이 된다.
46
+ const strip = (s) => String(s).replace(/\s+#.*$/, "").replace(/["']/g, "").trim();
44
47
  let inTemplate = false;
45
48
  let inOptions = false;
46
49
  for (const line of String(content || "").split("\n")) {
@@ -61,6 +64,8 @@ export function parseTemplateOptions(content) {
61
64
  if (v === "false") out.secretBackup = false;
62
65
  continue;
63
66
  }
67
+ m = line.match(/^\s+deploy_style:\s*(.+)/);
68
+ if (m) { const v = strip(m[1]); if (v) out.deployStyle = v; continue; }
64
69
  m = line.match(/^\s+semver_auto:\s*(.+)/);
65
70
  if (m) {
66
71
  const v = strip(m[1]);
@@ -128,7 +133,9 @@ export function parseExisting(content) {
128
133
 
129
134
  // metadata.template.branches 블록 파싱. 셋 다 있어야 유효 — 아니면 null.
130
135
  export function parseTemplateBranches(content) {
131
- const strip = (s) => String(s).replace(/["']/g, "").trim();
136
+ // 인라인 주석(` # ...`) 먼저 떼고 따옴표·공백을 정리한다. 문자열 값을 받는 키(deploy_style)
137
+ // 주석을 안 떼면 "simple # simple | nginx ..." 가 통째로 값이 된다.
138
+ const strip = (s) => String(s).replace(/\s+#.*$/, "").replace(/["']/g, "").trim();
132
139
  let inTemplate = false;
133
140
  let inBranches = false;
134
141
  const out = { main: "", develop: "", mode: "" };
@@ -168,7 +175,7 @@ export function buildVersionYml({
168
175
  const b = branches || { main: branch || "main", develop: "develop", mode: "pr-flow" };
169
176
  const {
170
177
  templateVersion = "unknown", includeNexus = false, includeSecretBackup = false,
171
- includeSemverAuto = true, optionsDate = today,
178
+ includeSemverAuto = true, deployStyle = "", optionsDate = today,
172
179
  } = templateOptions || {};
173
180
 
174
181
  // project_paths 블록 (full-line 토큰 {{PROJECT_PATHS}} — 없으면 라인 제거)
@@ -205,6 +212,7 @@ export function buildVersionYml({
205
212
  MAIN_BRANCH: b.main, DEVELOP_BRANCH: b.develop, BRANCH_MODE: b.mode,
206
213
  OPT_NEXUS: String(includeNexus), OPT_SECRET_BACKUP: String(includeSecretBackup),
207
214
  OPT_SEMVER_AUTO: String(includeSemverAuto),
215
+ OPT_DEPLOY_STYLE: String(deployStyle || ""),
208
216
  };
209
217
 
210
218
  const out = [];
@@ -225,3 +233,22 @@ export function buildVersionYml({
225
233
  if (!text.endsWith("\n")) text += "\n";
226
234
  return text.replace(/\n{3,}$/, "\n"); // 말미 과잉 빈 줄 정리
227
235
  }
236
+
237
+ // context 하나로 version.yml 최종형을 만든다 — 실제 설치(full)와 미리보기(dry-run)가
238
+ // 같은 함수를 쓰게 해서 "미리보기와 결과가 다른" 상황을 구조적으로 막는다.
239
+ // deployValues는 실제 설치에서만 존재한다(미리보기는 치환을 수행하지 않으므로 빈 Map).
240
+ export function renderVersionYml(context, templateText, { pathMarkers, deployValues = new Map(), extraTopLevel = [] }) {
241
+ const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
242
+ now, today, templateVersion = "unknown", branches = null,
243
+ includeNexus = false, includeSecretBackup = false, includeSemverAuto, deployStyle } = context;
244
+ return buildVersionYml({
245
+ templateText, version, types, paths, pathMarkers, branch, branches, versionCode, now, today,
246
+ deployValues, extraTopLevel,
247
+ templateOptions: {
248
+ templateVersion, includeNexus, includeSecretBackup,
249
+ includeSemverAuto: includeSemverAuto !== false,
250
+ deployStyle: deployStyle || DEFAULT_DEPLOY_STYLE,
251
+ optionsDate: today,
252
+ },
253
+ });
254
+ }
@@ -31,11 +31,14 @@ export function setEnvLine(line, key, value) {
31
31
  // CRLF 안전: 라인 끝 \r을 분리해 처리 후 복원 (autocrlf 프로젝트 대응)
32
32
  const cr = line.endsWith("\r") ? "\r" : "";
33
33
  const body = cr ? line.slice(0, -1) : line;
34
- const escaped = escapeYamlDoubleQuoted(value);
35
34
  // 값 치환: KEY: "기존값" → KEY: "value"
35
+ // 홑따옴표(KEY: '기존값')도 받는다 — 템플릿에 두 표기가 섞여 있는데 겹따옴표만 보면
36
+ // 홑따옴표 줄의 @wizard 마커가 아무 경고 없이 무시된다(이슈 #81과 같은 실패 형태).
37
+ // 치환 결과는 겹따옴표로 통일하고, 값은 그에 맞게 이스케이프한다.
38
+ const escaped = escapeYamlDoubleQuoted(value);
36
39
  let out = body.replace(
37
- new RegExp(`^(\\s*${key}:\\s*")[^"]*(")`),
38
- (_m, p1, p2) => `${p1}${escaped}${p2}`,
40
+ new RegExp(`^(\\s*${key}:\\s*)(["'])(?:(?!\\2).)*\\2`),
41
+ (_m, head) => `${head}"${escaped}"`,
39
42
  );
40
43
  // 그 줄 끝 # @wizard ... 주석 제거 (앞 공백째)
41
44
  out = out.replace(/(\S)[^\S\r\n]*#[^\S\r\n]*@wizard[^\S\r\n].*$/, "$1");
package/src/index.js CHANGED
@@ -8,8 +8,9 @@ import { createInterface } from "node:readline/promises";
8
8
  import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
9
9
  import { HELP_TEXT } from "./cli/help.js";
10
10
  import { createContext } from "./context.js";
11
+ import { DEFAULT_DEPLOY_STYLE, isDeployStyle } from "./core/deploy-style.js";
11
12
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "./core/assets.js";
12
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectBuildNumber } from "./core/detect-fs.js";
13
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectBuildNumber, detectMarkers } from "./core/detect-fs.js";
13
14
  import { parseExisting } from "./core/version-yml.js";
14
15
  import { runBreakingCheck } from "./core/breaking-check.js";
15
16
  import { resolveProjectPaths } from "./core/paths-resolve.js";
@@ -216,7 +217,10 @@ export async function run(argv, {
216
217
  // 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
217
218
  const types = opts.types.length ? opts.types : detectTypes(cwd);
218
219
  // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
219
- const version = (existing?.version) || opts.version || detectVersion(cwd);
220
+ // 비대화형이므로 폴백 안내는 CLI 문구(--project-version) 그대로 쓴다 (이슈 #80).
221
+ const detectWarnings = [];
222
+ const version = (existing?.version) || opts.version
223
+ || detectVersion(cwd, { warn: (m) => { detectWarnings.push(m); console.error(m); } });
220
224
  const versionCode = existing?.versionCode ?? detectBuildNumber(cwd, { types }) ?? 1; // 기존 빌드번호 보존, 신규 통합 시 프로젝트 파일에서 감지 (.sh L2208~2221, 이슈 #41)
221
225
  const branch = detectDefaultBranch(cwd);
222
226
  const repoName = detectRepoName(cwd);
@@ -267,6 +271,11 @@ export async function run(argv, {
267
271
  // 실 resolver 4종 (.sh resolve_token 등가)
268
272
  resolvers: makeResolvers(cwd, repoName, paths),
269
273
  now, today,
274
+ // 설치 로그(#79)용 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
275
+ markers: detectMarkers(cwd, types), detectWarnings,
276
+ deployStyle: opts.deployStyle
277
+ || (isDeployStyle(existing?.options?.deployStyle) ? existing.options.deployStyle : DEFAULT_DEPLOY_STYLE),
278
+ previousTemplateVersion: existing?.templateVersion || "",
270
279
  });
271
280
 
272
281
  context.templateVersion = readTemplateVersion();
@@ -293,6 +302,10 @@ export async function run(argv, {
293
302
  mode: opts.mode, types, version, versionCode, branches,
294
303
  copiedFiles: result?.workflows?.copiedFiles ?? [],
295
304
  gitignoreUpdated: result?.gitignoreUpdated === true,
305
+ unresolved: result?.unresolved ?? [],
306
+ secrets: result?.secrets ?? new Map(),
307
+ installLogPath: result?.installLog?.path ?? "",
308
+ cleanup: result?.cleanup ?? null,
296
309
  });
297
310
  return 0;
298
311
  }
@@ -10,6 +10,7 @@ import { PAYLOAD } from "../core/paths.js";
10
10
  import { exists, listYamlFiles } from "../core/fsutil.js";
11
11
  import { parseWizardLine, resolveToken } from "../core/wizard-env.js";
12
12
  import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
13
+ import { deployFilter } from "../core/deploy-style.js";
13
14
  import * as engine from "./readline-engine.js";
14
15
 
15
16
  const CANCEL = engine.CANCEL;
@@ -38,40 +39,47 @@ export function scopeString(usages = []) {
38
39
  // 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
39
40
  // usages:Map<key,[{type,workflowName}]> }
40
41
  export function collectAsks(payloadRoot, types = [], opts = {}) {
41
- const { resolvers = {}, includeNexus = false, prompts = null } = opts;
42
+ const { resolvers = {}, includeNexus = false, includeSecretBackup = false, deployStyle = "", prompts = null } = opts;
43
+ // 설치하지 않을 배포 워크플로우의 질문까지 묻지 않는다 — 질문 수는 설치 범위를 따라간다.
44
+ const keepDeploy = deployFilter(deployStyle);
42
45
  const baseDir = join(payloadRoot, PAYLOAD.workflowsDir);
43
46
  const keys = [];
44
47
  const defaults = new Map();
45
48
  const typeDefaults = new Map();
46
49
  const usages = new Map();
47
50
 
51
+ // 스캔 단위: [타입, 폴더]. secret-backup은 타입이 아니라 공통이지만 @wizard 마커를 가지므로
52
+ // 포함하기로 한 경우에만 질문 수집 대상이 된다 (이슈 #82) — 종전에는 스캔 대상이 아니어서
53
+ // my-project 같은 예시값이 질문 없이 그대로 설치됐다.
54
+ const units = [];
48
55
  for (const type of types) {
49
56
  const typeDir = join(baseDir, type);
50
57
  if (!exists(typeDir)) continue;
51
58
  // 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (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
- }
59
+ units.push([type, typeDir, null]);
60
+ units.push([type, join(typeDir, includeNexus ? "nexus" : "server-deploy"), includeNexus ? null : keepDeploy]);
61
+ }
62
+ if (includeSecretBackup) units.push(["common", join(baseDir, "common", "secret-backup"), null]);
63
+
64
+ for (const [type, dir, fileFilter] of units) {
65
+ if (!exists(dir)) continue;
66
+ for (const filename of listYamlFiles(dir)) {
67
+ if (fileFilter && !fileFilter(filename)) continue;
68
+ const content = readFileSync(join(dir, filename), "utf8");
69
+ if (!content.includes("@wizard")) continue;
70
+ const workflowName = workflowDisplayName(prompts, filename);
71
+ for (const line of content.split(/\r?\n/)) {
72
+ const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh 동일)
73
+ if (!p || p.action !== "ask") continue;
74
+ // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
75
+ const typeDefault = p.arg.startsWith("@")
76
+ ? resolveToken(p.arg.slice(1), type, resolvers)
77
+ : p.arg;
78
+ typeDefaults.set(`${type}|${p.key}`, typeDefault);
79
+ if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
80
+ const list = usages.get(p.key) || [];
81
+ list.push({ type, workflowName });
82
+ usages.set(p.key, list);
75
83
  }
76
84
  }
77
85
  }
@@ -83,6 +91,23 @@ function firstTypeFor(usages, key) {
83
91
  return usages.get(key)?.[0]?.type ?? "";
84
92
  }
85
93
 
94
+ // 최종 답변 목록 (이슈 #79, #80) — 완료 요약과 설치 로그가 같은 데이터를 쓰도록 여기서 만든다.
95
+ // isDefault는 "기본값 그대로인가"다. 나중에 배포가 안 될 때 제일 먼저 확인하게 되는 정보라
96
+ // 값만 남기면 부족하다.
97
+ function buildAnswers(prompts, asks, values, useDefaults) {
98
+ return asks.keys.map((key) => {
99
+ const def = asks.defaults.get(key) ?? "";
100
+ const chosen = useDefaults ? def : (values.get(key) ?? def);
101
+ return {
102
+ key,
103
+ label: wfField(prompts, firstTypeFor(asks.usages, key), key, "label") || key,
104
+ value: chosen,
105
+ isDefault: chosen === def,
106
+ scope: scopeString(asks.usages.get(key) || []),
107
+ };
108
+ });
109
+ }
110
+
86
111
  // KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력 (.sh _wf_print_field_card 등가).
87
112
  // info: { default, usages } — idx/tot 있으면 "(i/t)" 진행 표시. log 주입 가능(테스트 무음화).
88
113
  export function printFieldCard(prompts, key, info, idx = null, tot = null, log = defaultLog) {
@@ -124,7 +149,7 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
124
149
  }
125
150
 
126
151
  // 배포 env 설정 계획 (.sh wf_prompt_env_plan 등가).
127
- // 반환: { values: Map<key,value>, useDefaults: boolean }
152
+ // 반환: { values: Map<key,value>, useDefaults: boolean, answers: [{key,label,value,isDefault,scope}] }
128
153
  // - useDefaults=true → 호출부는 substituteEnv에 그대로 넘기면 타입별 기본값 경로(.sh _wf_prefill_all 등가)
129
154
  // - useDefaults=false → values에 담긴 키만 사용자 확정값으로 치환, 나머지는 기본값
130
155
  // (⚠️ substituteEnv는 useDefaults=false일 때만 values를 참조하므로 이 플래그를 반드시 함께 전달)
@@ -136,19 +161,22 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
136
161
  // log — 카드·안내 출력 함수 주입 (기본 stderr)
137
162
  export async function promptEnvPlan({
138
163
  payloadRoot, types = [], io = null, force = false, resolvers = {},
139
- includeNexus = false, targetRoot = ".", repoName = "", log = defaultLog,
164
+ includeNexus = false, includeSecretBackup = false, deployStyle = "", targetRoot = ".", repoName = "", log = defaultLog,
140
165
  } = {}) {
141
166
  const prompts = loadWizardPrompts(targetRoot, payloadRoot);
142
- const asks = collectAsks(payloadRoot, types, { resolvers, includeNexus, prompts });
167
+ const asks = collectAsks(payloadRoot, types, { resolvers, includeNexus, includeSecretBackup, deployStyle, prompts });
143
168
  const defaults = asks.defaults;
144
169
 
145
170
  // 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
146
- if (asks.keys.length === 0) return { values: new Map(), useDefaults: true };
171
+ if (asks.keys.length === 0) return { values: new Map(), useDefaults: true, answers: [] };
147
172
 
148
173
  // 비대화형: force 또는 (io 미주입 && 비TTY) → 전부 기본값 (.sh FORCE_MODE/TTY_AVAILABLE 분기 등가)
149
174
  // io가 주입돼 있으면(테스트/상위 마법사) TTY 여부와 무관하게 대화형으로 진행한다.
150
175
  const interactive = !force && (io != null || stdin.isTTY);
151
- if (!interactive) return { values: new Map(defaults), useDefaults: true };
176
+ if (!interactive) {
177
+ const values = new Map(defaults);
178
+ return { values, useDefaults: true, answers: buildAnswers(prompts, asks, values, true) };
179
+ }
152
180
 
153
181
  const ui = io ?? engine;
154
182
 
@@ -175,7 +203,8 @@ export async function promptEnvPlan({
175
203
  });
176
204
  // ESC/취소 → 전부 기본값 (.sh `if [ "$_rc" -ne 0 ]` 등가)
177
205
  if (choice === CANCEL || choice == null || choice === "all") {
178
- return { values: new Map(defaults), useDefaults: true };
206
+ const values = new Map(defaults);
207
+ return { values, useDefaults: true, answers: buildAnswers(prompts, asks, values, true) };
179
208
  }
180
209
 
181
210
  // 사용자가 확정한 키만 values에 담는다 — substituteEnv(useDefaults:false)가
@@ -183,7 +212,7 @@ export async function promptEnvPlan({
183
212
  const values = new Map();
184
213
  if (choice === "each") {
185
214
  await promptEach(ui, prompts, asks, asks.keys, values, log);
186
- return { values, useDefaults: false };
215
+ return { values, useDefaults: false, answers: buildAnswers(prompts, asks, values, false) };
187
216
  }
188
217
 
189
218
  // some: 바꿀 항목만 멀티선택 → 고른 것만 입력 (.sh 3266~3277)
@@ -198,10 +227,11 @@ export async function promptEnvPlan({
198
227
  });
199
228
  // ESC/빈 선택 → 전부 기본값 (.sh: _wf_prefill_all만 수행)
200
229
  if (selected === CANCEL || !Array.isArray(selected) || selected.length === 0) {
201
- return { values: new Map(defaults), useDefaults: true };
230
+ const values = new Map(defaults);
231
+ return { values, useDefaults: true, answers: buildAnswers(prompts, asks, values, true) };
202
232
  }
203
233
  // 수집 키 순서 유지 + WF_ASK_KEYS 멤버만 인정 (.sh _wf_prefill_interactive 필터 등가)
204
234
  const todo = asks.keys.filter((k) => selected.includes(k));
205
235
  await promptEach(ui, prompts, asks, todo, values, log);
206
- return { values, useDefaults: false };
236
+ return { values, useDefaults: false, answers: buildAnswers(prompts, asks, values, false) };
207
237
  }
package/src/ui/prompts.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY에서 Enter가 멈추는 버그로 제거).
3
3
  // 취소(ESC/Ctrl+C)는 각 함수가 CANCEL 심볼을 반환 → 호출부가 정상 종료(exit 0) 처리.
4
4
  import * as engine from "./readline-engine.js";
5
+ import { DEPLOY_STYLES } from "../core/deploy-style.js";
5
6
 
6
7
  export const CANCEL = engine.CANCEL;
7
8
 
@@ -47,17 +48,55 @@ export async function editMenu({ showOptional = false } = {}) {
47
48
  return engine.select({ message: "어떤 항목을 수정할까요?", options });
48
49
  }
49
50
 
51
+ const ALL_TYPES = ["spring", "flutter", "next", "react", "react-native", "react-native-expo", "node", "python", "basic"];
52
+
50
53
  // 타입 멀티선택.
51
54
  export async function selectTypes(current = []) {
52
- const all = ["spring", "flutter", "next", "react", "react-native", "react-native-expo", "node", "python", "basic"];
53
55
  return engine.multiselect({
54
56
  message: "프로젝트 타입을 선택하세요 (Space 토글, Enter 확정)",
55
- options: all.map((t) => ({ value: t, label: t })),
57
+ options: ALL_TYPES.map((t) => ({ value: t, label: t })),
56
58
  initialValues: current.length ? current : ["basic"],
57
59
  required: true,
58
60
  });
59
61
  }
60
62
 
63
+ // 감지 직후 타입 확정 (이슈 #78). selectTypes와 달리 감지 근거 파일을 라벨에 붙여
64
+ // "왜 이렇게 판단했는지"를 보여준다 — 근거가 보여야 맞는지 틀린지 판단할 수 있다.
65
+ // 감지 결과가 맞으면 Enter 한 번으로 끝난다.
66
+ export async function confirmTypes({ types = [], markers = null } = {}) {
67
+ const detected = new Set(types);
68
+ engine.note(
69
+ "선택한 타입에 따라 설치되는 CI/CD 워크플로우와 버전 동기화 대상 파일이 달라집니다.\n" +
70
+ "감지 결과가 맞으면 그대로 Enter를 누르세요.",
71
+ "프로젝트 타입 확정",
72
+ );
73
+ return engine.multiselect({
74
+ message: "이 프로젝트의 타입입니다 (Space 토글, Enter 확정)",
75
+ options: ALL_TYPES.map((t) => {
76
+ const marker = markers?.get?.(t);
77
+ // 감지된 타입만 근거를 붙인다 — 나머지는 후보로만 나열한다.
78
+ return { value: t, label: detected.has(t) && marker ? `${t} — ${marker} 발견` : t };
79
+ }),
80
+ initialValues: types.length ? types : ["basic"],
81
+ required: true,
82
+ });
83
+ }
84
+
85
+ // 배포 방식 선택 (이슈 #80). 서버 배포 CD 워크플로우는 서로 대체재라 하나만 쓴다.
86
+ // 고른 것만 설치하고 push 트리거까지 켜준다 — 종전에는 넷을 다 깔고 SIMPLE만 켜져 있어,
87
+ // 무중단을 원한 사람은 설치 후 YAML을 직접 고쳐야 했다.
88
+ export async function selectDeployStyle() {
89
+ engine.note(
90
+ "서버 배포 워크플로우는 서로 대체재입니다 (Nginx와 Traefik을 동시에 쓰지 않습니다).\n" +
91
+ "고른 방식만 설치하고 자동 실행(push 트리거)까지 켭니다. PR 프리뷰는 선택과 무관하게 함께 설치됩니다.",
92
+ "배포 방식",
93
+ );
94
+ return engine.select({
95
+ message: "서버 배포는 어떤 방식으로 할까요?",
96
+ options: DEPLOY_STYLES.map((s) => ({ value: s.value, label: s.label })),
97
+ });
98
+ }
99
+
61
100
  // 텍스트 입력 (빈 입력=기본값 유지).
62
101
  export async function askText(message, defaultValue = "") {
63
102
  const v = await engine.text({ message, defaultValue });
@@ -2,24 +2,28 @@
2
2
  // (층5의 Breaking Changes 박스는 core/breaking-check.js가 담당.
3
3
  // 원본의 층4 IDE Skills 상태는 project-auto-wizard 스코프 제외 — Agent Skills 미포함)
4
4
  import { A, paint } from "./ansi.js";
5
- import { markerForType } from "../core/detect.js";
6
5
 
7
6
  const GUT = paint("│", A.gray);
8
7
  const HEAD = paint("◆", A.cyan);
9
8
  const OK = paint("✓", A.green);
10
9
 
11
10
  // 층2 — 감지 로그 (.ps1 감지 진행 표시 등가)
12
- export function printDetectionLog({ types = [], version = "", branch = "" }, out = (s) => process.stderr.write(s)) {
11
+ // markers: Map<type, 실제 발견 파일> (이슈 #77).
12
+ // warnings: 감지 도중 나온 경고. 감지 함수를 먼저 호출한 뒤 박스를 그리는 구조라 경고가
13
+ // 박스 위로 새어나가 앞선 질문에 대한 경고처럼 보였다 — 박스 안에서 출력한다 (이슈 #80).
14
+ export function printDetectionLog({ types = [], version = "", branch = "", markers = new Map(), warnings = [] },
15
+ out = (s) => process.stderr.write(s)) {
13
16
  out(`${paint("┌", A.gray)} 🔍 프로젝트를 살펴보는 중...\n`);
14
17
  if (types.length && !(types.length === 1 && types[0] === "basic")) {
15
18
  for (const t of types) {
16
- const marker = markerForType(t);
19
+ const marker = markers.get(t);
17
20
  out(`${GUT} ${OK} ${marker ? `${marker} 발견 → ` : ""}${paint(t, A.bold)} 감지\n`);
18
21
  }
19
22
  } else {
20
23
  out(`${GUT} ${paint("─", A.dim)} 마커 파일 없음 → ${paint("basic", A.bold)} (직접 선택 가능)\n`);
21
24
  }
22
25
  out(`${GUT} ${OK} 버전: ${paint(`v${version}`, A.green)} · 브랜치: ${paint(branch, A.green)}\n`);
26
+ for (const w of warnings) out(`${GUT} ${paint(w, A.yellow)}\n`);
23
27
  out(`${GUT}\n`);
24
28
  }
25
29
 
package/src/ui/summary.js CHANGED
@@ -6,7 +6,9 @@ import { paint, A, colorEnabled } from "./ansi.js";
6
6
  const SEPARATOR = "────────────────────────────────────────";
7
7
 
8
8
  export function printSummary(ctx) {
9
- const { mode, types = [], version = "", versionCode = null, copiedFiles = [], branches = null, gitignoreUpdated = false } = ctx || {};
9
+ const { mode, types = [], version = "", versionCode = null, copiedFiles = [], branches = null, gitignoreUpdated = false,
10
+ // 설치 후 검증·기록 (#79, #80, #81)
11
+ answers = [], unresolved = [], secrets = new Map(), installLogPath = "", cleanup = null } = ctx || {};
10
12
  const err = (s = "") => process.stderr.write(`${s}\n`);
11
13
  // 색상은 ansi.js의 공용 가드로 통일 (NO_COLOR + stderr TTY 여부)
12
14
  const enabled = colorEnabled(process.stderr);
@@ -87,14 +89,38 @@ export function printSummary(ctx) {
87
89
  err("");
88
90
  err(" 🔧 .github/scripts/");
89
91
  err(" ├─ version_manager.py");
90
- err(" └─ changelog_manager.py");
92
+ err(" ├─ changelog_manager.py");
93
+ err(" ├─ truncate_release_notes.py");
94
+ err(" └─ issue_helper.py");
91
95
  err("");
92
96
 
97
+ // 입력한 환경설정 값 (#80) — 마지막으로 눈으로 검산할 기회. 종전에는 답변이 워크플로우
98
+ // YAML 안으로만 사라져, 오타를 내도 배포가 실패한 뒤에야 알 수 있었다.
99
+ if (answers.length) {
100
+ err(" ⚙️ 적용된 환경설정:");
101
+ for (const a of answers) {
102
+ const mark = a.isDefault ? paint(" (기본값)", A.dim, enabled) : "";
103
+ err(` • ${a.label}: ${paint(a.value, A.green, enabled)}${mark}`);
104
+ }
105
+ err("");
106
+ }
107
+ // 배포 방식을 바꿔 재설치한 경우, 이전 CD를 어떻게 처리했는지 알린다 (#80).
108
+ if (cleanup?.removed?.length || cleanup?.backedUp?.length) {
109
+ err(" 🧹 이전 배포 방식 정리:");
110
+ for (const f of cleanup.removed || []) err(` • ${f} ${paint("삭제 (손대지 않은 파일)", A.dim, enabled)}`);
111
+ for (const f of cleanup.backedUp || []) err(` • ${f} → ${f}.bak ${paint("수정하신 내용이 있어 백업", A.dim, enabled)}`);
112
+ err("");
113
+ }
114
+ if (installLogPath) {
115
+ err(` 📋 설치 기록: ${installLogPath}`);
116
+ err(" → 나중에 '무엇을 어떤 값으로 설치했는지' 확인할 때 이 파일을 보세요");
117
+ err("");
118
+ }
119
+
93
120
  // 프로젝트 타입별 안내
94
121
  if (types.includes("spring")) {
95
122
  err(" 💡 Spring 프로젝트 추가 설정:");
96
- err(" • build.gradle의 버전 정보가 자동 동기화됩니다");
97
- err(" • CI/CD 워크플로우에서 GitHub Secrets 설정이 필요합니다");
123
+ err(" • build.gradle / build.gradle.kts / pom.xml 의 버전 정보가 자동 동기화됩니다");
98
124
  err("");
99
125
  }
100
126
 
@@ -106,12 +132,36 @@ export function printSummary(ctx) {
106
132
  err("");
107
133
  err(paint(paint("⚠️ 다음 작업을 확인해주세요:", A.yellow, enabled), A.bold, enabled));
108
134
  err("");
109
- err(" 1️⃣ 릴리스 automerge용 PAT (선택 — 없으면 GITHUB_TOKEN 사용)");
135
+
136
+ let step = 0;
137
+ const num = () => ["1️⃣ ", "2️⃣ ", "3️⃣ ", "4️⃣ ", "5️⃣ "][step++] || " •";
138
+
139
+ // 미치환 플레이스홀더 (#81) — 이 상태로는 해당 워크플로우가 동작하지 않으므로 제일 먼저 알린다.
140
+ if (unresolved.length) {
141
+ err(` ${num()} ${paint("값이 채워지지 않은 항목이 있습니다 — 직접 채워야 동작합니다", A.red, enabled)}`);
142
+ for (const u of unresolved) {
143
+ err(` → ${u.filename}:${u.line} ${paint(u.token, A.bold, enabled)}`);
144
+ }
145
+ err("");
146
+ }
147
+
148
+ // 설치된 워크플로우가 실제로 요구하는 Secret (#80) — 종전에는 하나도 안내되지 않아
149
+ // "설치 성공"인데 배포는 돌지 않는 상태로 끝났다.
150
+ if (secrets.size) {
151
+ err(` ${num()} 아래 GitHub Secret을 등록해야 배포 워크플로우가 동작합니다 (${secrets.size}개)`);
152
+ err(" → Settings > Secrets and variables > Actions");
153
+ for (const [name, users] of secrets) {
154
+ err(` → ${paint(name, A.bold, enabled)} ${paint(users.join(", "), A.dim, enabled)}`);
155
+ }
156
+ err("");
157
+ }
158
+
159
+ err(` ${num()} 릴리스 automerge용 PAT (선택 — 없으면 GITHUB_TOKEN 사용)`);
110
160
  err(" → Repository Settings > Secrets > Actions");
111
161
  err(" → Secret Name: WORKFLOW_PAT (Scopes: repo, workflow)");
112
162
  err(" → GITHUB_TOKEN 머지는 후속 워크플로우를 트리거하지 않습니다");
113
163
  err("");
114
- err(" 2️⃣ GitHub Actions 권한 확인");
164
+ err(` ${num()} GitHub Actions 권한 확인`);
115
165
  err(" → Settings > Actions > Workflow permissions: Read and write");
116
166
  err("");
117
167
  err(SEPARATOR);