projectops 4.59.1 → 4.60.1

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 (51) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/cli/args.js +2 -2
  4. package/src/commands/doctor.js +3 -1
  5. package/src/commands/full.js +9 -11
  6. package/src/commands/interactive.js +13 -13
  7. package/src/commands/issues.js +1 -1
  8. package/src/commands/skills.js +1 -1
  9. package/src/commands/version.js +5 -6
  10. package/src/commands/workflows.js +3 -3
  11. package/src/context.js +2 -2
  12. package/src/core/agent-guide.js +2 -1
  13. package/src/core/assets.js +3 -3
  14. package/src/core/breaking-check.js +6 -6
  15. package/src/core/breaking.js +2 -2
  16. package/src/core/copy/coderabbit.js +1 -1
  17. package/src/core/copy/gitignore.js +3 -3
  18. package/src/core/copy/readme.js +2 -3
  19. package/src/core/copy/simple.js +4 -2
  20. package/src/core/copy/util.js +2 -2
  21. package/src/core/copy/workflows.js +9 -13
  22. package/src/core/detect-fs.js +6 -6
  23. package/src/core/detect.js +3 -3
  24. package/src/core/exclusions.js +1 -1
  25. package/src/core/fsutil.js +2 -4
  26. package/src/core/ide/adapters/claude.js +3 -3
  27. package/src/core/ide/adapters/codex.js +3 -3
  28. package/src/core/ide/adapters/cursor.js +3 -3
  29. package/src/core/ide/adapters/gemini.js +4 -3
  30. package/src/core/ide/adapters/pi-common.js +8 -7
  31. package/src/core/ide/adapters/pi-harness.js +4 -4
  32. package/src/core/ide/adapters/pi.js +1 -1
  33. package/src/core/ide/runner.js +2 -2
  34. package/src/core/options-ask.js +7 -9
  35. package/src/core/options-schema.js +5 -5
  36. package/src/core/paths-resolve.js +27 -28
  37. package/src/core/paths.js +7 -3
  38. package/src/core/resolve-options.js +14 -0
  39. package/src/core/run-trace.js +1 -1
  40. package/src/core/version-yml.js +44 -25
  41. package/src/core/wizard-env.js +11 -12
  42. package/src/core/wizard-labels.js +15 -17
  43. package/src/i18n/ide.en.js +1 -0
  44. package/src/i18n/ide.ko.js +1 -0
  45. package/src/index.js +8 -8
  46. package/src/ui/banner.js +2 -1
  47. package/src/ui/env-plan.js +22 -25
  48. package/src/ui/prompts.js +1 -1
  49. package/src/ui/readline-engine.js +1 -1
  50. package/src/ui/skills-prompts.js +1 -1
  51. package/src/ui/summary.js +3 -3
@@ -1,6 +1,5 @@
1
- // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
2
- // ⚠️ YAML 재직렬화 금지 — 주석이 데이터. .sh heredoc과 바이트 동일한 템플릿 문자열.
3
- // 실측 기준: template_integrator.sh 2184~2354.
1
+ // version.yml 파싱·생성 (전체 재생성 전략 D4).
2
+ // ⚠️ YAML 재직렬화 금지 — 주석이 데이터. 템플릿 문자열로 바이트 단위 고정.
4
3
  import { execFileSync } from "node:child_process";
5
4
  import { readFileSync } from "node:fs";
6
5
  import { DEPLOY_TARGETS, LABEL_STYLES } from "./constants.js";
@@ -118,6 +117,7 @@ const COMMENTS = {
118
117
  excluded: "workflow files the update must never install (safe to edit by hand)",
119
118
  storeLocales: "store release-note languages, first is the default (missing = one text for every store language)",
120
119
  storeLocalesStore: "same, only for this store when its languages differ from store_locales (optional)",
120
+ legacyDeployBranch: "old name of development_branch, kept equal for older skills",
121
121
  },
122
122
  ko: {
123
123
  versionCode: "app build number",
@@ -135,6 +135,7 @@ const COMMENTS = {
135
135
  excluded: "업데이트가 절대 설치하지 않을 워크플로우 파일 (직접 수정 가능)",
136
136
  storeLocales: "스토어 릴리스 노트 언어 목록, 첫 항목이 기본 언어 (미기재면 모든 스토어 언어에 같은 문구)",
137
137
  storeLocalesStore: "이 스토어만 언어 구성이 store_locales 와 다를 때 쓰는 선택 키",
138
+ legacyDeployBranch: "development_branch 의 구 이름 — 구버전 스킬용으로 같은 값 유지",
138
139
  },
139
140
  };
140
141
  const commentsFor = (lang) => COMMENTS[lang === "ko" ? "ko" : "en"];
@@ -151,7 +152,7 @@ export function resolveUpdatedBy(existingContent = "", cwd = ".") {
151
152
  return m && m[1] ? m[1] : "template_integrator";
152
153
  }
153
154
 
154
- // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
155
+ // metadata.template.options 상태머신 파싱.
155
156
  // 반환(#439 배포/publish 축): { deploy: string|null, publish: string[]|null, secretBackup: bool|null }
156
157
  // deploy: 'docker-ssh'|'vercel'|'none', publish: ['nexus','npm','github-packages'] 부분집합. null=미기재.
157
158
  // 구 키(nexus/npm_publish/synology)는 신 축으로 자동 마이그레이션해 읽는다 (v4.2.0 이전 파일 호환):
@@ -164,16 +165,12 @@ export function parseTemplateOptions(content) {
164
165
  const out = { deploy: null, publish: null, secretBackup: null,
165
166
  changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null, aiPrSummary: null, projectsSync: null,
166
167
  deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null, excludedWorkflows: null, storeLocales: null, storeLocalesIos: null, storeLocalesPlay: null };
167
- // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
168
- for (const line of String(content || "").split("\n")) {
169
- if (line.startsWith("#")) continue;
170
- const m = line.match(/^\s+deploy_branch:\s*"([^"]*)"/);
171
- if (m) { out.deployBranch = m[1]; break; }
172
- }
168
+ // 개발 브랜치는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
169
+ out.deployBranch = readDevelopmentBranch(content);
173
170
  let legacyNexus = null;
174
171
  let legacyNpm = null;
175
172
  let legacySynology = null;
176
- // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
173
+ // 값 정규화: 따옴표 제거 + 트림
177
174
  const strip = (s) => String(s).replace(/["']/g, "").trim();
178
175
  let inTemplate = false;
179
176
  let inOptions = false;
@@ -303,10 +300,10 @@ export function parseTemplateOptions(content) {
303
300
  if (v === "false") legacySynology = false;
304
301
  continue;
305
302
  }
306
- // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
303
+ // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료
307
304
  if (/^\s{0,4}[a-z_]+:/.test(line)) { inOptions = false; inTemplate = false; }
308
305
  }
309
- // 최상위 키 → template 섹션 종료 (.sh L2411~2415)
306
+ // 최상위 키 → template 섹션 종료
310
307
  if (inTemplate && /^[a-z_]+:/.test(line)) { inTemplate = false; inOptions = false; }
311
308
  }
312
309
  // ── 구 키 마이그레이션 — 신 publish 키가 없을 때만 ──
@@ -362,7 +359,23 @@ export function convertLegacySingularType(content) {
362
359
  return lines.join("\n");
363
360
  }
364
361
 
365
- // 기존 version.yml에서 값 추출 (.sh grep/sed 등가, 주석 라인 오탐 방지).
362
+ // 개발(릴리스 소스) 브랜치 = 릴리스 PR head (#915)
363
+ // 키 2개: development_branch(정본) > deploy_branch(구 이름 — 이것만 읽는 구버전 스킬이 있어 함께 기록)
364
+ // 반환: 브랜치명, 둘 다 없으면 null
365
+ export const DEVELOPMENT_BRANCH_KEYS = ["development_branch", "deploy_branch"];
366
+ export function readDevelopmentBranch(content) {
367
+ const lines = String(content || "").split("\n").filter((l) => !l.startsWith("#"));
368
+ for (const key of DEVELOPMENT_BRANCH_KEYS) {
369
+ const re = new RegExp(`^\\s+${key}:\\s*"([^"]*)"`);
370
+ for (const l of lines) {
371
+ const m = l.match(re);
372
+ if (m) return m[1];
373
+ }
374
+ }
375
+ return null;
376
+ }
377
+
378
+ // 기존 version.yml에서 값 추출 (라인 정규식, 주석 라인 오탐 방지).
366
379
  export function parseExisting(content) {
367
380
  const text = String(content || "");
368
381
  const line = (re) => {
@@ -407,9 +420,9 @@ export function parseExisting(content) {
407
420
  if (/^\S/.test(l)) break;
408
421
  }
409
422
  }
410
- // 브랜치 정보 (#456) — default_branch(레포 기본)와 deploy_branch(릴리스 PR head)는 별개.
423
+ // 브랜치 정보 (#456) — default_branch(레포 기본)와 개발 브랜치(릴리스 PR head)는 별개.
411
424
  const defaultBranch = line(/^\s*default_branch:\s*"([^"]*)"/);
412
- const deployBranch = line(/^\s*deploy_branch:\s*"([^"]*)"/);
425
+ const deployBranch = readDevelopmentBranch(text);
413
426
  // 선택 워크플로우 옵션 (metadata.template.options — nexus/secret_backup)
414
427
  const options = parseTemplateOptions(text);
415
428
  return { version, versionCode, types, paths, templateVersion, templateMode, options, defaultBranch, deployBranch };
@@ -422,9 +435,11 @@ export function parseExisting(content) {
422
435
  // project_type(단수)는 v4.1.0 에서 없앴다 — 남기면 version_manager 가 거부한다. 그래서 보존하지 않고 버린다.
423
436
  const OWNED_TOP = new Set(["version", "version_code", "project_types", "project_type", "project_paths", "metadata", "deploy"]);
424
437
  // metadata.template.options 아래에서 생성기가 직접 쓰는 키. 여기에 없는 것만 보존한다.
438
+ // buildVersionYml 이 쓰는 6칸 키와 같아야 한다 — 빠지면 재실행마다 그 키가 두 번 쓰인다 (대조: test/version-yml-preserve.test.js).
425
439
  // nexus · npm_publish · synology 는 옛 키다 — 파서가 신형(publish · secret_backup)으로 바꿔 쓰므로 그대로 두면 이중 기록이 된다.
426
- const OWNED_OPTION = new Set(["intent", "deploy", "publish", "secret_backup", "semver_auto", "projects_sync", "close_on_release",
427
- "language", "label_style", "app_release", "excluded_workflows", "store_locales", "code_review", "changelog",
440
+ export const OWNED_OPTION_KEYS = new Set(["intent", "deploy", "publish", "secret_backup", "semver_auto", "projects_sync", "close_on_release",
441
+ "language", "label_style", "app_release", "excluded_workflows", "store_locales", "store_locales_ios", "store_locales_play",
442
+ "code_review", "changelog",
428
443
  "nexus", "npm_publish", "synology"]);
429
444
 
430
445
  // indent 칸으로 시작하는 키 한 줄과 그 자식(더 깊은 들여쓰기·빈 줄·그 아래의 주석)을 한 덩어리로 자른다.
@@ -476,7 +491,7 @@ export function extractCustomYml(content) {
476
491
  if (tp >= 0) {
477
492
  const tpEnd = end(tp, 2);
478
493
  const op = find(tp + 1, tpEnd, /^ {4}options:/);
479
- if (op >= 0) optionBlocks = keyBlocks(lines.slice(op + 1, end(op, 4)), 6, (k) => OWNED_OPTION.has(k));
494
+ if (op >= 0) optionBlocks = keyBlocks(lines.slice(op + 1, end(op, 4)), 6, (k) => OWNED_OPTION_KEYS.has(k));
480
495
  }
481
496
  }
482
497
  return { optionBlocks, topBlocks };
@@ -526,7 +541,7 @@ export function mergeDeployValues(existingContent, fresh) {
526
541
  return merged;
527
542
  }
528
543
 
529
- // version.yml 전체 생성 (.sh create_version_yml + save_template_options 신규 케이스 등가).
544
+ // version.yml 전체 생성 (metadata > template 옵션 > deploy > 사용자 블록).
530
545
  // opts: { version, types:[], paths:Map, pathMarkers?:Map, branch, versionCode, now, today, templateOptions? }
531
546
  // primary 타입은 별도 키 없이 project_types[0]이다 (v4.1.0 SSOT — 단수 project_type 키 제거).
532
547
  // now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입
@@ -557,13 +572,17 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
557
572
  out += ` last_updated: "${now}"\n`;
558
573
  out += ` last_updated_by: "${String(updatedBy || "template_integrator").replace(/"/g, "")}"\n`;
559
574
  out += ` default_branch: "${branch}"\n`;
560
- // deploy_branch: 릴리스 PR의 head 브랜치(#456). default_branch(레포 기본)와 별개 개념.
561
- // 지정된 경우에만 출력 — 미지정이면 스킬이 develop로 폴백(하위호환).
562
- if (deployBranch) out += ` deploy_branch: "${deployBranch}"\n`;
575
+ // 개발(릴리스 소스) 브랜치 = 릴리스 PR head (#456). default_branch(레포 기본)와 별개 개념.
576
+ // 지정된 경우에만 출력 — 미지정이면 스킬이 develop로 폴백.
577
+ // deploy_branch 병기: 그 키만 읽는 구버전 스킬(changelog_cli)이 develop로 폴백하지 않게 (#915)
578
+ if (deployBranch) {
579
+ out += ` development_branch: "${deployBranch}"\n`;
580
+ out += ` deploy_branch: "${deployBranch}" # ${C.legacyDeployBranch}\n`;
581
+ }
563
582
  out += ` integrated_from: "projectops"\n`;
564
583
  out += ` integration_date: "${today}"\n`;
565
584
 
566
- // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
585
+ // template 옵션 블록. templateOptions 지정 시.
567
586
  if (templateOptions) {
568
587
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
569
588
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true, aiPrSummary = true, intent = null, mode = null,
@@ -613,7 +632,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
613
632
  for (const b of customOptionBlocks) out += `${b}\n`;
614
633
  }
615
634
 
616
- // deploy 블록 (.sh update_version_yml_deploy). deployValues: Map<type, Map<key,value>>.
635
+ // deploy 블록. deployValues: Map<type, Map<key,value>>.
617
636
  // WF ask 값이 있는 타입만. 반드시 template 뒤의 별도 최상위 키로 둔다 — metadata 와 template
618
637
  // 사이에 끼면 template 이 deploy 의 자식이 되어 metadata.template.options 구조가 깨진다 (#670).
619
638
  const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
@@ -1,9 +1,8 @@
1
- // @wizard env 토큰 엔진 (.sh configure_workflow_env / _wf_set_env / _wf_is_unchanged 등가).
1
+ // @wizard env 토큰 엔진: 값 치환, 잔여 토큰 치환, 미변경 판정.
2
2
  // ⚠️ YAML 파싱/재직렬화 금지 — 라인 단위 문자열 처리 (포맷·주석 보존이 unchanged 판정 전제).
3
- // 실측 기준: template_integrator.sh 3282~3360, 3003~3012.
4
3
  import { substituteBranches } from "./branch-sub.js";
5
4
 
6
- // KEY 정규식: .sh는 [A-Z_]+ (대문자+언더스코어만). ask/auto 마커가 있는 라인만 대상.
5
+ // KEY 정규식: [A-Z_]+ (대문자+언더스코어만). ask/auto 마커가 있는 라인만 대상.
7
6
  const MARKER_RE = /#\s*@wizard\s+(ask|auto):(.*)$/;
8
7
  const KEY_RE = /^(\s*)([A-Z_]+):/;
9
8
  const PATHS_ANCHOR_RE = /#\s*@wizard\s+paths-anchor/;
@@ -17,8 +16,8 @@ export function parseWizardLine(line) {
17
16
  return { indent: km[1], key: km[2], action: marker[1], arg: marker[2].trim() };
18
17
  }
19
18
 
20
- // .sh _wf_set_env 등가: `KEY: "..."` 따옴표 안 값 치환 + 그 줄 끝 `# @wizard ...` 주석 제거.
21
- // 라인 하나에 대해 수행. value가 빈문자면 (.sh는 [ -n "$_val" ] 가드) 치환 스킵.
19
+ // `KEY: "..."` 따옴표 안 값 치환 + 그 줄 끝 `# @wizard ...` 주석 제거.
20
+ // 라인 하나에 대해 수행. value가 빈문자면 치환 스킵.
22
21
  export function setEnvLine(line, key, value) {
23
22
  if (value === "" || value == null) return line;
24
23
  // CRLF 안전: 라인 끝 \r을 분리해 처리 후 복원 (autocrlf 프로젝트 대응)
@@ -54,21 +53,21 @@ export function isPlaceholderDefault(value) {
54
53
  return /(^|\.)example\.(com|org|net)$/i.test(String(value || "").trim());
55
54
  }
56
55
 
57
- // resolver — .sh resolve_token 등가. 값 계산은 주입된 resolvers로 위임(순수성 유지).
56
+ // resolver — 값 계산은 주입된 resolvers로 위임(순수성 유지).
58
57
  // resolvers: { repo, "spring-app-yml-dir"(type), "spring-app-yml-path"(type), "flutter-root" }
59
58
  export function resolveToken(name, type, resolvers = {}) {
60
59
  const fn = resolvers[name];
61
60
  return typeof fn === "function" ? (fn(type) ?? "") : "";
62
61
  }
63
62
 
64
- // 잔여 전역 토큰 치환 (.sh 3347~3351) — 파일 본문·수집값(#489)·카드 표시가 전부 같은 규칙을 쓴다.
63
+ // 잔여 전역 토큰 치환 — 파일 본문·수집값(#489)·카드 표시가 전부 같은 규칙을 쓴다.
65
64
  // 파일에 실제 써지는 값과 version.yml deploy 블록 기억값이 어긋나지 않게 하는 단일 지점.
66
65
  export function resolveGlobalTokens(s, repoName = "") {
67
66
  if (typeof s !== "string" || (!s.includes("__PROJECT_NAME__") && !s.includes("__APP_ARTIFACT_NAME__"))) return s;
68
67
  return s.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
69
68
  }
70
69
 
71
- // 파일 전체 치환 (configure_workflow_env 등가).
70
+ // 파일 전체 치환.
72
71
  // content: 원본 워크플로우 텍스트. 반환: 치환된 텍스트.
73
72
  // opts:
74
73
  // type - 프로젝트 타입 (resolver·값 조회용)
@@ -98,7 +97,7 @@ export function substituteEnv(content, opts = {}) {
98
97
  if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
99
98
  else if (isPlaceholderDefault(def)) continue; // 예시값은 쓰지 않는다 — 토큰·마커 그대로 (#810)
100
99
  else val = def;
101
- // ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
100
+ // ask 키만 수집 (auto는 저장 안 함). deploy 블록용.
102
101
  // #489 — 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
103
102
  // version.yml deploy 블록이 설치본과 항상 바이트 일치하게 한다.
104
103
  if (collectAsks) collectAsks.set(p.key, resolveGlobalTokens(val, repoName));
@@ -112,10 +111,10 @@ export function substituteEnv(content, opts = {}) {
112
111
  }
113
112
  let out = lines.join(usesCRLF ? "\r\n" : "\n");
114
113
 
115
- // 잔여 전역 토큰 (.sh 3347~3351)
114
+ // 잔여 전역 토큰
116
115
  out = resolveGlobalTokens(out, repoName);
117
116
 
118
- // paths-anchor (.sh 3353~3360): 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
117
+ // paths-anchor: 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
119
118
  if (PATHS_ANCHOR_RE.test(out) && projectPath && projectPath !== ".") {
120
119
  const eol = out.includes("\r\n") ? "\r\n" : "\n";
121
120
  out = out.split(/\r?\n/).map((line) => {
@@ -129,7 +128,7 @@ export function substituteEnv(content, opts = {}) {
129
128
  return out;
130
129
  }
131
130
 
132
- // .sh _wf_is_unchanged 등가: 원본을 "기본값으로 가상 치환한 최종형"과 설치본을 바이트 비교.
131
+ // 미변경 판정: 원본을 "기본값으로 가상 치환한 최종형"과 설치본을 바이트 비교.
133
132
  export function isUnchanged(templateContent, installedContent, opts = {}) {
134
133
  // 브랜치 치환(#477)도 가상 비교에 포함 — 치환 설치본이 "변경됨"으로 오판되어 매 업데이트마다
135
134
  // 재복사(.bak churn)되는 것을 막는다. opts.branches 미지정/표준값이면 no-op.
@@ -1,15 +1,13 @@
1
- // wizard-prompts.yml 라벨 메타 파서 (.sh _wf_labels_path/_wf_read_field/wf_field/wf_workflow_name 등가).
2
- // 실측 기준: template_integrator.sh 2809~2814(_wf_labels_path), 2895~2932(wf_workflow_name),
3
- // 2934~2960(_wf_read_field), 2962~2970(wf_field).
4
- // ⚠️ YAML 라이브러리 금지 — .sh와 동일하게 라인 기반 파싱만 한다(외부 의존성 0 + 포맷 관용성 동일).
1
+ // wizard-prompts.yml 라벨 메타 파서: 파일 위치, 필드 조회, 워크플로우 표시 이름.
2
+ // ⚠️ YAML 라이브러리 금지 — 라인 기반 파싱만 한다(외부 의존성 0 + 포맷 관용성).
5
3
  import { join } from "node:path";
6
4
  import * as nodeFs from "node:fs";
7
5
  import { PATHS } from "./paths.js";
8
6
 
9
- // wizard-prompts.yml 위치 (레포 루트 기준 상대경로 — .sh LABELS_FILE 상수)
7
+ // wizard-prompts.yml 위치 (레포 루트 기준 상대경로)
10
8
  export const LABELS_FILE = ".github/config/wizard-prompts.yml";
11
9
 
12
- // 따옴표 감싸진 값이면 벗기고 trim ("값" → 값). .sh sub(/^"/)/sub(/"$/) 등가.
10
+ // 따옴표 감싸진 값이면 벗기고 trim ("값" → 값).
13
11
  function unquote(s) {
14
12
  const t = s.trim();
15
13
  if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) return t.slice(1, -1);
@@ -19,7 +17,7 @@ function unquote(s) {
19
17
  // wizard-prompts.yml 텍스트 → 파싱 객체 (순수 함수 — 테스트 직접 사용 가능).
20
18
  // 반환: { fields: Map<조회키, {label?,help?,example?}>, workflowNames: [{key,value}] }
21
19
  // - 조회키: "PROJECT_NAME" 또는 "flutter.APP_ARTIFACT_NAME" (dotted 타입 오버라이드)
22
- // - 구형 1줄(KEY: "라벨")은 fields의 label로 흡수 (.sh _wf_read_field 형식1 등가 — label만 의미)
20
+ // - 구형 1줄(KEY: "라벨")은 fields의 label로 흡수 (label만 의미)
23
21
  export function parseWizardPrompts(text) {
24
22
  const fields = new Map();
25
23
  const workflowNames = [];
@@ -28,10 +26,10 @@ export function parseWizardPrompts(text) {
28
26
 
29
27
  for (const raw of String(text).split(/\r?\n/)) {
30
28
  const line = raw.replace(/\r$/, "");
31
- if (!line.trim() || line.trim().startsWith("#")) continue; // 빈 줄·주석은 블록을 끊지 않음(.sh awk도 동일하게 무해)
29
+ if (!line.trim() || line.trim().startsWith("#")) continue; // 빈 줄·주석은 블록을 끊지 않음
32
30
 
33
31
  if (!/^\s/.test(line)) {
34
- // 최상위 키 라인 — 이전 블록 종료 (.sh awk `/^[^[:space:]]/ { inblk=0 }` 등가)
32
+ // 최상위 키 라인 — 이전 블록 종료
35
33
  current = null; inWfNames = false;
36
34
  const m = line.match(/^([A-Za-z_][A-Za-z0-9_.\-]*):(.*)$/);
37
35
  if (!m) continue;
@@ -40,7 +38,7 @@ export function parseWizardPrompts(text) {
40
38
  if (key === "_workflow_names") { inWfNames = true; continue; }
41
39
  const entry = fields.get(key) || {};
42
40
  if (rest) {
43
- // 구형 1줄: KEY: "라벨" (.sh 형식1 — label로만 사용)
41
+ // 구형 1줄: KEY: "라벨" (label로만 사용)
44
42
  const q = rest.match(/^"([^"]*)"\s*$/);
45
43
  if (q) entry.label = q[1];
46
44
  }
@@ -53,20 +51,20 @@ export function parseWizardPrompts(text) {
53
51
  const m = line.match(/^\s+([A-Za-z_][A-Za-z0-9_.\-]*):\s*(.*)$/);
54
52
  if (!m) continue;
55
53
  if (inWfNames) {
56
- // " KEY: "값"" 형식만 인정 (.sh _wf_load_workflow_names의 `*:\ \"*\"` case 등가)
54
+ // " KEY: "값"" 형식만 인정
57
55
  const q = m[2].match(/^"(.*)"\s*$/);
58
56
  if (q) workflowNames.push({ key: m[1], value: q[1] });
59
57
  continue;
60
58
  }
61
59
  if (current && (m[1] === "label" || m[1] === "help" || m[1] === "example")) {
62
- // 블록 내 첫 등장만 채택 (.sh awk `print line; exit` — 첫 매치 사용)
60
+ // 블록 내 첫 등장만 채택
63
61
  if (current[m[1]] == null) current[m[1]] = unquote(m[2]);
64
62
  }
65
63
  }
66
64
  return { fields, workflowNames };
67
65
  }
68
66
 
69
- // wizard-prompts.yml을 찾아 읽고 파싱 (.sh _wf_labels_path 등가).
67
+ // wizard-prompts.yml을 찾아 읽고 파싱.
70
68
  // 우선순위: 대상 프로젝트(targetRoot) → 다운로드 원본(tempDir) 폴백 → null.
71
69
  // WHY 폴백: 신규 통합에서는 copy_config_folder가 configure_workflow_env보다 늦게 실행되어
72
70
  // 대상에 아직 파일이 없다 — 폴백 없으면 label/help/example이 전부 빈값(KEY명만 출력)이 된다.
@@ -80,7 +78,7 @@ export function loadWizardPrompts(targetRoot = ".", tempDir = "", fs = nodeFs) {
80
78
  return null;
81
79
  }
82
80
 
83
- // 필드 조회 (.sh wf_field 등가). field: "label" | "help" | "example".
81
+ // 필드 조회. field: "label" | "help" | "example".
84
82
  // 우선순위: "{type}.KEY" 블록 → "KEY" 블록(구형 1줄 포함) → 폴백(label이면 KEY명, 아니면 "").
85
83
  export function wfField(prompts, type, key, field) {
86
84
  if (prompts && prompts.fields) {
@@ -92,11 +90,11 @@ export function wfField(prompts, type, key, field) {
92
90
  return field === "label" ? key : "";
93
91
  }
94
92
 
95
- // 워크플로우 파일명 → 사람이 읽는 짧은 이름 (.sh wf_workflow_name 등가).
93
+ // 워크플로우 파일명 → 사람이 읽는 짧은 이름.
96
94
  // _workflow_names에서 "키가 파일명에 포함되면" 그 값 사용 — 최장 키 우선(REACT-CI vs REACT-CICD 구분).
97
- // 미매칭이면 .yaml/.yml 확장자만 제거해 반환 (.sh `${_base%.y*ml}` 등가).
95
+ // 미매칭이면 .yaml/.yml 확장자만 제거해 반환.
98
96
  export function workflowDisplayName(prompts, filename) {
99
- const base = String(filename).split("/").pop().split("\\").pop(); // 경로 제거 (.sh `${_file##*/}`)
97
+ const base = String(filename).split("/").pop().split("\\").pop(); // 경로 제거
100
98
  let best = null; let bestLen = 0;
101
99
  for (const { key, value } of prompts?.workflowNames ?? []) {
102
100
  if (base.includes(key) && key.length > bestLen) { best = value; bestLen = key.length; }
@@ -65,6 +65,7 @@ export default {
65
65
  "ide.harness.already": " Persona Harness: already enabled (kept)",
66
66
  "ide.harness.enabledDone": " Persona Harness enabled. Restart PI to apply.",
67
67
  "ide.harness.noSettings": " PI settings.json is missing. Run PI once and try again.",
68
+ "ide.harness.invalidSettings": " PI settings.json could not be parsed, so it was left untouched. Fix the JSON in {file} and try again.",
68
69
  "ide.harness.needPackage": " The harness loader is missing. Install or update the PI package first.",
69
70
  "ide.harness.removeNone": " Persona Harness is not enabled, skipping",
70
71
  "ide.harness.keepSkill": " Leaving the PI skill as is and only unregistering the harness.",
@@ -65,6 +65,7 @@ export default {
65
65
  "ide.harness.already": " Persona Harness: 이미 활성화됨 (유지)",
66
66
  "ide.harness.enabledDone": " Persona Harness 활성화 완료 — PI 재시작 후 적용됩니다.",
67
67
  "ide.harness.noSettings": " PI settings.json이 없습니다 — PI를 한 번 실행한 뒤 다시 시도하세요.",
68
+ "ide.harness.invalidSettings": " PI settings.json을 해석할 수 없어 건드리지 않았습니다 — {file} 의 JSON 문법을 고친 뒤 다시 시도하세요.",
68
69
  "ide.harness.needPackage": " harness loader가 없습니다 — 먼저 PI 패키지를 설치/업데이트하세요.",
69
70
  "ide.harness.removeNone": " Persona Harness가 활성화돼 있지 않아 건너뜁니다",
70
71
  "ide.harness.keepSkill": " PI skill은 그대로 두고 harness 등록만 해제합니다.",
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // projectops CLI 진입 파이프라인 (.sh main + execute_integration 등가).
1
+ // projectops CLI 진입 파이프라인 (인자 > 감지 > 모드별 통합 > 요약).
2
2
  // 감지 → 다운로드 → 모드 라우팅 → 통합 실행 → 정리. 비대화형(--force) 우선.
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -130,7 +130,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
130
130
  disarmSignals = trace.armSignals({ targetRoot: cwd, now: "" });
131
131
  }
132
132
 
133
- // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh L2208~2239 SSoT)
133
+ // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실
134
134
  const vyPath = join(cwd, "version.yml");
135
135
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
136
136
  trace.event("detect", "existing-install", existing ? "found" : "none", {
@@ -150,11 +150,11 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
150
150
  source: existing?.version ? "version.yml(existing value kept)"
151
151
  : (opts.version ? "cli-flag(--project-version)" : "project file detection"),
152
152
  });
153
- const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
153
+ const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존
154
154
  const branch = detectDefaultBranch(cwd);
155
155
  const repoName = detectRepoName(cwd);
156
156
  trace.event("detect", "repo", repoName || "(unknown)", { defaultBranch: branch, versionCode });
157
- // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
157
+ // 경로 확정 (비대화형 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
158
158
  let cliPaths;
159
159
  try {
160
160
  cliPaths = parsePathsCsv(opts.pathsCsv);
@@ -221,7 +221,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
221
221
  mode: opts.mode, force: true, types, version, versionCode, branch, paths,
222
222
  ...resolved,
223
223
  repoName,
224
- // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
224
+ // 실 resolver 4종 (spring-app-yml 포함)
225
225
  resolvers: makeResolvers(cwd, repoName, paths),
226
226
  now, today,
227
227
  });
@@ -263,7 +263,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
263
263
  // 비대화형 축약 배너 (#446 확정 — 1줄, 로그 오염 최소)
264
264
  printBannerCompact({ version: context.templateVersion, mode: opts.mode });
265
265
 
266
- // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
266
+ // Breaking Changes 게이트 (비대화형은 경고 후 진행)
267
267
  const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
268
268
  cwd, tempDir, templateVersion: context.templateVersion,
269
269
  onItems: (items) => { breakingReport = items; },
@@ -301,7 +301,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
301
301
  case "workflows": result = trace.step("install-workflows", () => runWorkflows(context, tempDir, cwd, { trace })); break;
302
302
  case "issues": result = trace.step("install-issues", () => runIssues(context, tempDir, cwd)); break;
303
303
  default:
304
- // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
304
+ // 알 수 없는 모드 → 복사 0건, 에러 아님
305
305
  break;
306
306
  }
307
307
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
@@ -323,7 +323,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
323
323
  }).guidePath;
324
324
  }
325
325
 
326
- // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
326
+ // 완료 요약 (CLI 모드에서도 출력)
327
327
  printSummary({
328
328
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
329
329
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
package/src/ui/banner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Start screen banner (layer 1). Classic boxed title, kept from the original wizard.
2
2
  import { A, paint, visualWidth } from "./ansi.js";
3
3
  import { t, getLang, getLangSource } from "../i18n/index.js";
4
+ import { TEMPLATE_REPO_WEB_URL } from "../core/paths.js";
4
5
 
5
6
  const INNER = 56; // inner width of the box
6
7
 
@@ -22,7 +23,7 @@ export function printBanner({ version, modeLabel }, out = (s) => process.stdout.
22
23
  out(` 🐵 Author : Cassiiopeia\n`);
23
24
  out(` 🪐 Mode : ${modeLabel}\n`);
24
25
  out(` 🌐 Language: ${getLang()} (${t(`cli.langSource.${getLangSource()}`)})\n`);
25
- out(` 📦 Repo : ${paint("github.com/Cassiiopeia/projectops", A.dim)}\n`);
26
+ out(` 📦 Repo : ${paint(TEMPLATE_REPO_WEB_URL.replace("https://", ""), A.dim)}\n`);
26
27
  out("\n");
27
28
  }
28
29
 
@@ -1,7 +1,4 @@
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).
1
+ // @wizard env 계획 질문 UI: 사용처 조립, ask KEY 수집, 필드 카드, 기본값·개별 입력.
5
2
  // io 주입식 — 테스트는 {select, multiselect, text} 스텁을 넘긴다. 기본은 readline-engine 실물.
6
3
  import { join } from "node:path";
7
4
  import { readFileSync } from "node:fs";
@@ -15,10 +12,10 @@ import { t } from "../i18n/index.js";
15
12
 
16
13
  const CANCEL = engine.CANCEL;
17
14
 
18
- // 진행 안내는 stderr로 출력 (.sh print_to_user가 >&2인 것과 동일 — stdout 파이프 오염 방지).
15
+ // 진행 안내는 stderr로 출력 (stdout 파이프 오염 방지).
19
16
  const defaultLog = (s = "") => stderr.write(s + "\n");
20
17
 
21
- // 사용처 문자열 조립 (.sh wf_scope_string 등가).
18
+ // 사용처 문자열 조립.
22
19
  // usages: [{type, workflowName}] — 타입이 여러 개면 타입만 "t1·t2", 하나면 "타입 name1·name2".
23
20
  export function scopeString(usages = []) {
24
21
  const types = []; const names = [];
@@ -30,10 +27,10 @@ export function scopeString(usages = []) {
30
27
  return `${types.join("·")} ${names.join("·")}`.trim();
31
28
  }
32
29
 
33
- // ask KEY 수집 (.sh wf_collect_asks 등가) — 실제 설치되는 워크플로우와 같은 소스를 스캔한다.
30
+ // ask KEY 수집 — 실제 설치되는 워크플로우와 같은 소스를 스캔한다.
34
31
  // tempDir: 다운로드 원본 루트. types: 설치 대상 타입 목록.
35
32
  // opts:
36
- // resolvers - @접두 기본값(@repo 등) 해석용 (.sh는 수집 시점에 resolve_token — 동일)
33
+ // resolvers - @접두 기본값(@repo 등) 해석용 (수집 시점에 해석)
37
34
  // deployTarget - #439 배포 축: docker-ssh(기본)일 때만 server-deploy 스캔 (복사 엔진과 일치)
38
35
  // publishTargets - #439 publish 축: 선택된 타겟의 publish/<target>/ 스캔
39
36
  // prompts - wizard-labels 파싱 객체 (워크플로우 표시명용, null이면 확장자 제거 폴백)
@@ -62,9 +59,9 @@ export function collectAsks(tempDir, types = [], opts = {}) {
62
59
  if (!content.includes("@wizard")) continue;
63
60
  const workflowName = workflowDisplayName(prompts, filename);
64
61
  for (const line of content.split(/\r?\n/)) {
65
- const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
62
+ const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+
66
63
  if (!p || p.action !== "ask") continue;
67
- // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
64
+ // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴
68
65
  // #489 — __PROJECT_NAME__ 등 전역 토큰은 카드 표시 전에 미리 해석한다.
69
66
  // 설치본(파일 복사 시 전역 치환)과 동일한 값을 보여주지 않으면
70
67
  // 사용자가 "리터럴이 그대로 박히나?" 오해하게 된다.
@@ -84,12 +81,12 @@ export function collectAsks(tempDir, types = [], opts = {}) {
84
81
  return { keys, defaults, typeDefaults, usages };
85
82
  }
86
83
 
87
- // KEY가 처음 등장한 type (.sh _wf_first_type_for — 라벨 조회 시 타입 오버라이드 우선순위용)
84
+ // KEY가 처음 등장한 type (라벨 조회 시 타입 오버라이드 우선순위용)
88
85
  function firstTypeFor(usages, key) {
89
86
  return usages.get(key)?.[0]?.type ?? "";
90
87
  }
91
88
 
92
- // KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력 (.sh _wf_print_field_card 등가).
89
+ // KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력.
93
90
  // info: { default, usages } — idx/tot 있으면 "(i/t)" 진행 표시. log 주입 가능(테스트 무음화).
94
91
  export function printFieldCard(prompts, key, info, idx = null, tot = null, log = defaultLog) {
95
92
  const ty = info.usages?.[0]?.type ?? "";
@@ -107,8 +104,8 @@ export function printFieldCard(prompts, key, info, idx = null, tot = null, log =
107
104
  log("");
108
105
  }
109
106
 
110
- // 지정 KEY들을 하나씩 입력받아 values에 기록 (.sh _wf_prefill_interactive 등가).
111
- // 빈 입력(Enter)/ESC → KEY 공통 기본값 유지 (.sh safe_read || _in="" 등가).
107
+ // 지정 KEY들을 하나씩 입력받아 values에 기록.
108
+ // 빈 입력(Enter)/ESC → KEY 공통 기본값 유지.
112
109
  async function promptEach(io, prompts, asks, todoKeys, values, log) {
113
110
  const tot = todoKeys.length;
114
111
  if (tot === 0) return;
@@ -129,15 +126,15 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
129
126
  }
130
127
  }
131
128
 
132
- // 배포 env 설정 계획 (.sh wf_prompt_env_plan 등가).
129
+ // 배포 env 설정 계획.
133
130
  // 반환: { values: Map<key,value>, useDefaults: boolean }
134
- // - useDefaults=true → 호출부는 substituteEnv에 그대로 넘기면 타입별 기본값 경로(.sh _wf_prefill_all 등가)
131
+ // - useDefaults=true → 호출부는 substituteEnv에 그대로 넘기면 타입별 기본값 경로
135
132
  // - useDefaults=false → values에 담긴 키만 사용자 확정값으로 치환, 나머지는 기본값
136
133
  // (⚠️ substituteEnv는 useDefaults=false일 때만 values를 참조하므로 이 플래그를 반드시 함께 전달)
137
134
  // 인자:
138
135
  // tempDir/types/resolvers/deployTarget/publishTargets — collectAsks와 동일 의미
139
136
  // targetRoot — wizard-prompts.yml 1차 탐색 위치(기본 ".")
140
- // force — true면 질문 없이 전부 기본값 (.sh FORCE_MODE 등가)
137
+ // force — true면 질문 없이 전부 기본값
141
138
  // io — {select, multiselect, text} 주입 (기본 readline-engine). 테스트 스텁 지점.
142
139
  // log — 카드·안내 출력 함수 주입 (기본 stderr)
143
140
  export async function promptEnvPlan({
@@ -148,17 +145,17 @@ export async function promptEnvPlan({
148
145
  const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts, repoName });
149
146
  const defaults = asks.defaults;
150
147
 
151
- // 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
148
+ // 수집 키 0개 → 질문 자체가 없음
152
149
  if (asks.keys.length === 0) return { values: new Map(), useDefaults: true };
153
150
 
154
- // 비대화형: force 또는 (io 미주입 && 비TTY) → 전부 기본값 (.sh FORCE_MODE/TTY_AVAILABLE 분기 등가)
151
+ // 비대화형: force 또는 (io 미주입 && 비TTY) → 전부 기본값
155
152
  // io가 주입돼 있으면(테스트/상위 마법사) TTY 여부와 무관하게 대화형으로 진행한다.
156
153
  const interactive = !force && (io != null || stdin.isTTY);
157
154
  if (!interactive) return { values: new Map(defaults), useDefaults: true };
158
155
 
159
156
  const ui = io ?? engine;
160
157
 
161
- // 기본값 미리보기 카드 전체 출력 (.sh 3237~3251)
158
+ // 기본값 미리보기 카드 전체 출력
162
159
  log("");
163
160
  log(t("envPlan.title"));
164
161
  log("");
@@ -179,20 +176,20 @@ export async function promptEnvPlan({
179
176
  { value: "some", label: t("envPlan.optSome") },
180
177
  ],
181
178
  });
182
- // ESC/취소 → 전부 기본값 (.sh `if [ "$_rc" -ne 0 ]` 등가)
179
+ // ESC/취소 → 전부 기본값
183
180
  if (choice === CANCEL || choice == null || choice === "all") {
184
181
  return { values: new Map(defaults), useDefaults: true };
185
182
  }
186
183
 
187
184
  // 사용자가 확정한 키만 values에 담는다 — substituteEnv(useDefaults:false)가
188
- // values에 없는 키는 타입별 기본값으로 채우므로 .sh(_wf_prefill_all 후 덮어쓰기)와 등가.
185
+ // values에 없는 키는 타입별 기본값으로 채운다 (기본값 위에 입력값 덮어쓰기).
189
186
  const values = new Map();
190
187
  if (choice === "each") {
191
188
  await promptEach(ui, prompts, asks, asks.keys, values, log);
192
189
  return { values, useDefaults: false };
193
190
  }
194
191
 
195
- // some: 바꿀 항목만 멀티선택 → 고른 것만 입력 (.sh 3266~3277)
192
+ // some: 바꿀 항목만 멀티선택 → 고른 것만 입력
196
193
  const options = asks.keys.map((key) => ({
197
194
  value: key,
198
195
  label: `${wfField(prompts, firstTypeFor(asks.usages, key), key, "label")} ${t("envPlan.defaultTag", { value: defaults.get(key) })}`,
@@ -202,11 +199,11 @@ export async function promptEnvPlan({
202
199
  options,
203
200
  initialValues: [],
204
201
  });
205
- // ESC/빈 선택 → 전부 기본값 (.sh: _wf_prefill_all만 수행)
202
+ // ESC/빈 선택 → 전부 기본값
206
203
  if (selected === CANCEL || !Array.isArray(selected) || selected.length === 0) {
207
204
  return { values: new Map(defaults), useDefaults: true };
208
205
  }
209
- // 수집 키 순서 유지 + WF_ASK_KEYS 멤버만 인정 (.sh _wf_prefill_interactive 필터 등가)
206
+ // 수집 키 순서 유지 + 수집된 ask KEY만 인정
210
207
  const todo = asks.keys.filter((k) => selected.includes(k));
211
208
  await promptEach(ui, prompts, asks, todo, values, log);
212
209
  return { values, useDefaults: false };
package/src/ui/prompts.js CHANGED
@@ -1,4 +1,4 @@
1
- // 대화형 프롬프트 래핑 (.sh interactive_menu/choose_menu/ask_* 등가).
1
+ // 대화형 프롬프트 래핑 (메뉴·선택·질문).
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";
@@ -1,7 +1,7 @@
1
1
  // node:readline 기반 대화형 프롬프트 엔진 (@clack/prompts 대체).
2
2
  // WHY: @clack/prompts 1.7.0 이 Windows TTY 콘솔에서 Enter(return) 키를 처리하지 못하고
3
3
  // 멈추는 버그가 있다(실측 확정). node:readline 의 keypress 이벤트는 Windows에서 정상 동작한다.
4
- // .sh/.ps1 이 자체 메뉴를 구현한 것과 동일한 접근. 외부 의존성 0 → 내부망에서도 안전.
4
+ // 외부 의존성 0 → 내부망에서도 안전.
5
5
  //
6
6
  // 계약: 취소(ESC/Ctrl+C)는 CANCEL 심볼 반환. 각 함수 async.
7
7
  import { emitKeypressEvents } from "node:readline";
@@ -1,4 +1,4 @@
1
- // IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가).
1
+ // IDE Skills 대화형 프롬프트.
2
2
  // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY Enter 버그로 제거).
3
3
  import * as engine from "./readline-engine.js";
4
4
  import { t } from "../i18n/index.js";