projectops 4.36.4 → 4.38.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.
@@ -1,8 +1,17 @@
1
1
  // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
2
2
  // ⚠️ YAML 재직렬화 금지 — 주석이 데이터. .sh heredoc과 바이트 동일한 템플릿 문자열.
3
3
  // 실측 기준: template_integrator.sh 2184~2354.
4
+ import { execFileSync } from "node:child_process";
5
+ import { DEPLOY_TARGETS, LABEL_STYLES } from "./constants.js";
6
+ import { REPO_LANGUAGES } from "./repo-language.js";
4
7
 
5
- const HEADER = `# ===================================================================
8
+ // 값 목록으로 만든 매칭 패턴 — 목록에 언어·표기를 추가하면 파서가 저절로 읽는다.
9
+ // (예전엔 `(en|ko)` 가 박혀 있어 세 번째 언어를 저장하면 다음 업데이트에서 못 읽고 ko 로 되돌아갔다.)
10
+ const alt = (values) => values.map((v) => v.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")).join("|");
11
+ const LANGUAGE_RE = new RegExp(`^\\s+language:\\s*["']?(${alt(REPO_LANGUAGES)})["']?`);
12
+ const LABEL_STYLE_RE = new RegExp(`^\\s+label_style:\\s*["']?(${alt(LABEL_STYLES)})["']?`);
13
+
14
+ const HEADER_EN = `# ===================================================================
6
15
  # Project version management file
7
16
  # ===================================================================
8
17
  #
@@ -18,11 +27,11 @@ const HEADER = `# ==============================================================
18
27
  # Automatic version updates:
19
28
  # - version_code: incremented by 1 on every build (independent of the rules below)
20
29
  # - How far the version is bumped is decided by metadata.template.options.semver_auto
21
- # semver_auto: true -> decided by the commit titles in the release range
30
+ # semver_auto: true -> decided by the commit titles in the release range (also when the key is missing)
22
31
  # "title : feat! : body" or "feat!:" -> major (x+1.0.0)
23
32
  # "title : feat : body" or "feat:" -> minor (x.y+1.0)
24
33
  # anything else (fix/docs/chore/refactor/test) -> patch (x.y.z+1)
25
- # key missing / false -> always patch +1
34
+ # semver_auto: false -> always patch +1
26
35
  # - You may edit the version value by hand regardless of the rules above
27
36
  #
28
37
  # Files synced per project type:
@@ -45,6 +54,98 @@ const HEADER = `# ==============================================================
45
54
  # ===================================================================
46
55
  `;
47
56
 
57
+ // 한국어 레포(options.language: ko)는 주석도 한국어로 쓴다 (#811).
58
+ // 업데이트(--force 는 화면이 en 고정)마다 한국어 주석이 영문으로 바뀌어 version.yml diff 가 86줄이 됐다.
59
+ const HEADER_KO = `# ===================================================================
60
+ # 프로젝트 버전 관리 파일
61
+ # ===================================================================
62
+ #
63
+ # 이 파일은 다양한 프로젝트 타입에서 버전 정보를 중앙 관리하기 위한 파일입니다.
64
+ # GitHub Actions 워크플로우가 이 파일을 읽어 자동으로 버전을 관리합니다.
65
+ #
66
+ # 사용법:
67
+ # 1. version: "1.0.0" - 사용자에게 표시되는 버전
68
+ # 2. version_code: 1 - Play Store/App Store 빌드 번호 (1부터 자동 증가)
69
+ # 3. project_types: 프로젝트 타입 배열 — 첫 항목이 primary
70
+ # 4. project_paths: 타입별 프로젝트 폴더 (레포 루트 기준 상대경로, 모노레포용)
71
+ #
72
+ # 자동 버전 업데이트:
73
+ # - version_code: 매 빌드마다 자동으로 1씩 증가 (아래 판정과 무관)
74
+ # - 버전 승격 폭은 metadata.template.options.semver_auto 가 정합니다
75
+ # semver_auto: true -> 릴리스 구간 커밋 제목으로 판정 (키가 없어도 같다)
76
+ # "제목 : feat! : 내용" 또는 "feat!:" -> major (x+1.0.0)
77
+ # "제목 : feat : 내용" 또는 "feat:" -> minor (x.y+1.0)
78
+ # 그 외(fix/docs/chore/refactor/test) -> patch (x.y.z+1)
79
+ # semver_auto: false -> 항상 patch +1
80
+ # - 위 판정과 무관하게 version 값을 직접 수정해도 됩니다
81
+ #
82
+ # 프로젝트 타입별 동기화 파일:
83
+ # - spring: build.gradle (version = "x.y.z")
84
+ # - flutter: pubspec.yaml (version: x.y.z+i, buildNumber 포함)
85
+ # - react/node: package.json ("version": "x.y.z")
86
+ # - react-native: iOS Info.plist 또는 Android build.gradle
87
+ # - react-native-expo: app.json (expo.version)
88
+ # - python: pyproject.toml (version = "x.y.z")
89
+ # - basic/기타: version.yml 파일만 사용
90
+ #
91
+ # 연관된 워크플로우:
92
+ # - .github/workflows/PROJECT-VERSION-CONTROL.yaml
93
+ # - .github/workflows/PROJECT-README-VERSION-UPDATE.yaml
94
+ # - .github/workflows/PROJECT-RELEASE-CHANGELOG.yaml
95
+ #
96
+ # 주의사항:
97
+ # - project_types는 최초 설정 후 변경하지 마세요
98
+ # - 버전은 항상 높은 버전으로 자동 동기화됩니다
99
+ # ===================================================================
100
+ `;
101
+
102
+ // 키 옆 한 줄 주석. 언어는 화면 언어가 아니라 레포 문구 언어(options.language)를 따른다.
103
+ const COMMENTS = {
104
+ en: {
105
+ versionCode: "app build number",
106
+ projectTypes: "array of types, first entry is primary, safe to edit by hand",
107
+ projectPaths: "project folder per type (relative to the repo root)",
108
+ mode: "install scope (full/version/workflows), used when re-running update mode",
109
+ intent: "project kind (app/library/both/none/manual), drives the deploy questions",
110
+ semverAuto: "decide the version bump from commit titles (false means always patch)",
111
+ projectsSync: "include the GitHub Projects status sync workflow",
112
+ closeOnRelease: "close issues labelled done when a release is merged (false or missing means do not close)",
113
+ language: "issue/PR template language (en | ko)",
114
+ labelStyle: "status label style (en: status: todo / ko: Korean names)",
115
+ appRelease: "whether releases go through App Store / Play Store review",
116
+ deploy: "deploy settings remembered by the wizard (non-sensitive, safe to edit by hand)",
117
+ excluded: "workflow files the update must never install (safe to edit by hand)",
118
+ },
119
+ ko: {
120
+ versionCode: "app build number",
121
+ projectTypes: "멀티타입 배열 — 첫 항목이 primary, 직접 편집 가능",
122
+ projectPaths: "타입별 프로젝트 폴더 (레포 루트 기준 상대경로)",
123
+ mode: "통합 범위(full/version/workflows) — 업데이트 모드 재실행 기준",
124
+ intent: "프로젝트 성격(app/library/both/none/manual) — 배포 질문 유도 기준",
125
+ semverAuto: "커밋 제목으로 버전 승격 폭 결정 (false면 항상 patch)",
126
+ projectsSync: "GitHub Projects 상태 동기화 워크플로우 포함 여부",
127
+ closeOnRelease: "릴리스 머지 때 완료 라벨 이슈를 닫는다 (false/미기재면 닫지 않음)",
128
+ language: "이슈/PR 템플릿 언어 (en | ko)",
129
+ labelStyle: "상태 라벨 표기 (en: status: todo / ko: 작업전)",
130
+ appRelease: "앱스토어·플레이스토어 심사로 이어지는 배포인가",
131
+ deploy: "마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)",
132
+ excluded: "업데이트가 절대 설치하지 않을 워크플로우 파일 (직접 수정 가능)",
133
+ },
134
+ };
135
+ const commentsFor = (lang) => COMMENTS[lang === "ko" ? "ko" : "en"];
136
+
137
+ // last_updated_by 결정 (#811): 실행한 git 사용자 → 기존 값 → template_integrator.
138
+ // 예전엔 업데이트마다 "template_integrator"로 덮어써 누가 바꿨는지가 사라졌다.
139
+ export function resolveUpdatedBy(existingContent = "", cwd = ".") {
140
+ try {
141
+ const name = execFileSync("git", ["config", "user.name"],
142
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
143
+ if (name) return name;
144
+ } catch { /* git 없음·설정 없음(CI) — 기존 값으로 */ }
145
+ const m = String(existingContent || "").match(/^\s+last_updated_by:\s*"([^"]*)"/m);
146
+ return m && m[1] ? m[1] : "template_integrator";
147
+ }
148
+
48
149
  // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
49
150
  // 반환(#439 배포/publish 축): { deploy: string|null, publish: string[]|null, secretBackup: bool|null }
50
151
  // deploy: 'docker-ssh'|'vercel'|'none', publish: ['nexus','npm','github-packages'] 부분집합. null=미기재.
@@ -57,7 +158,7 @@ const HEADER = `# ==============================================================
57
158
  export function parseTemplateOptions(content) {
58
159
  const out = { deploy: null, publish: null, secretBackup: null,
59
160
  changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null, aiPrSummary: null, projectsSync: null,
60
- deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null };
161
+ deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null, excludedWorkflows: null };
61
162
  // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
62
163
  for (const line of String(content || "").split("\n")) {
63
164
  if (line.startsWith("#")) continue;
@@ -105,7 +206,7 @@ export function parseTemplateOptions(content) {
105
206
  let m = line.match(/^\s+deploy:\s*(.+)/);
106
207
  if (m) {
107
208
  const v = strip(m[1]);
108
- if (["docker-ssh", "vercel", "none"].includes(v)) out.deploy = v;
209
+ if (DEPLOY_TARGETS.includes(v)) out.deploy = v;
109
210
  continue;
110
211
  }
111
212
  m = line.match(/^\s+publish:\s*\[([^\]]*)\]/);
@@ -127,7 +228,7 @@ export function parseTemplateOptions(content) {
127
228
  if (v === "false") out.secretBackup = false;
128
229
  continue;
129
230
  }
130
- // semver 자동 승격(#546). null(미기재)은 "기존 통합 레포" 신호 — 호출부가 OFF로 해석한다.
231
+ // semver 자동 승격(#546). null(미기재)이면 호출부가 ON(true)으로 해석한다 (#814). 명시적 false 만 끈다.
131
232
  // 캡처를 true|false로 한정한다(intent와 같은 방식) — 값 뒤 인라인 주석이 값에 딸려오면
132
233
  // strip()은 따옴표·공백만 걷어내므로 "true # ..."가 되어 매칭이 조용히 실패한다.
133
234
  m = line.match(/^\s+semver_auto:\s*["']?(true|false)["']?/);
@@ -136,13 +237,13 @@ export function parseTemplateOptions(content) {
136
237
  continue;
137
238
  }
138
239
  // 레포 문구 언어(#769) — en | ko. 미기재는 "기존 통합 레포" 신호라 호출부가 ko 로 해석한다.
139
- m = line.match(/^\s+language:\s*["']?(en|ko)["']?/);
240
+ m = line.match(LANGUAGE_RE);
140
241
  if (m) {
141
242
  out.language = m[1];
142
243
  continue;
143
244
  }
144
245
  // 상태 라벨 표기(#776) — en(영문 표준) | ko(기존 한글). 미기재는 "기존 통합 레포" 신호라 호출부가 ko로 해석한다.
145
- m = line.match(/^\s+label_style:\s*["']?(en|ko)["']?/);
246
+ m = line.match(LABEL_STYLE_RE);
146
247
  if (m) {
147
248
  out.labelStyle = m[1];
148
249
  continue;
@@ -159,6 +260,12 @@ export function parseTemplateOptions(content) {
159
260
  out.closeOnRelease = m[1] === "true";
160
261
  continue;
161
262
  }
263
+ // 설치하지 않을 워크플로우 (#810) — 인라인 배열. 업데이트가 지운 파일을 되살리지 않게 하는 명시 수단이다.
264
+ m = line.match(/^\s+excluded_workflows:\s*\[([^\]]*)\]/);
265
+ if (m) {
266
+ out.excludedWorkflows = m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
267
+ continue;
268
+ }
162
269
  // 프로젝트 성격(#553) — 앱 심사로 이어지는 레포인가. 워크플로우와 스킬이 같은 값을 본다.
163
270
  m = line.match(/^\s+app_release:\s*["']?(true|false)["']?/);
164
271
  if (m) {
@@ -329,17 +436,19 @@ export function mergeDeployValues(existingContent, fresh) {
329
436
  // today = "YYYY-MM-DD" (UTC)
330
437
  // pathMarkers = Map<type, markerFilename> (project_paths 주석용)
331
438
  // templateOptions = { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate } (template 블록)
332
- export function buildVersionYml({ version, types = [], paths = new Map(), pathMarkers = new Map(), branch = "main", deployBranch = "", versionCode = 1, now, today, templateOptions = null, deployValues = new Map() }) {
439
+ export function buildVersionYml({ version, types = [], paths = new Map(), pathMarkers = new Map(), branch = "main", deployBranch = "", versionCode = 1, now, today, templateOptions = null, deployValues = new Map(), updatedBy = "template_integrator" }) {
440
+ const lang = templateOptions?.language === "ko" ? "ko" : "en";
441
+ const C = commentsFor(lang);
333
442
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(",")}]` : `["basic"]`;
334
443
 
335
- let out = HEADER + "\n";
444
+ let out = (lang === "ko" ? HEADER_KO : HEADER_EN) + "\n";
336
445
  out += `version: "${version}"\n`;
337
- out += `version_code: ${versionCode} # app build number\n`;
338
- out += `project_types: ${typesJson} # array of types, first entry is primary, safe to edit by hand\n`;
446
+ out += `version_code: ${versionCode} # ${C.versionCode}\n`; // 공백 1칸 — version_manager.py 가 쓰는 형태와 같아야 업데이트 diff 가 안 생긴다
447
+ out += `project_types: ${typesJson} # ${C.projectTypes}\n`;
339
448
 
340
449
  // project_paths 블록. pathMarkers: Map<type, markerFilename> (있으면 " type: "path" # path/marker" 주석).
341
450
  if (paths.size) {
342
- out += `project_paths: # project folder per type (relative to the repo root)\n`;
451
+ out += `project_paths: # ${C.projectPaths}\n`;
343
452
  for (const [t, p] of paths) {
344
453
  const marker = pathMarkers.get(t) || "";
345
454
  const pf = p === "." ? marker : (marker ? `${p}/${marker}` : p);
@@ -349,7 +458,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
349
458
 
350
459
  out += `metadata:\n`;
351
460
  out += ` last_updated: "${now}"\n`;
352
- out += ` last_updated_by: "template_integrator"\n`;
461
+ out += ` last_updated_by: "${String(updatedBy || "template_integrator").replace(/"/g, "")}"\n`;
353
462
  out += ` default_branch: "${branch}"\n`;
354
463
  // deploy_branch: 릴리스 PR의 head 브랜치(#456). default_branch(레포 기본)와 별개 개념.
355
464
  // 지정된 경우에만 출력 — 미지정이면 스킬이 develop로 폴백(하위호환).
@@ -361,7 +470,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
361
470
  if (templateOptions) {
362
471
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
363
472
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true, aiPrSummary = true, intent = null, mode = null,
364
- semverAuto = true, appRelease = null, labelStyle = null, closeOnRelease = null, projectsSync = null, language = null } = templateOptions;
473
+ semverAuto = true, appRelease = null, labelStyle = null, closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null } = templateOptions;
365
474
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
366
475
  // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
367
476
  const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
@@ -369,27 +478,29 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
369
478
  out += ` source: "projectops"\n`;
370
479
  out += ` version: "${templateVersion}"\n`;
371
480
  // mode(#502) — 이 레포에 통합된 범위. 업데이트 모드가 같은 범위를 재실행하는 기준.
372
- if (mode) out += ` mode: "${mode}" # install scope (full/version/workflows), used when re-running update mode\n`;
481
+ if (mode) out += ` mode: "${mode}" # ${C.mode}\n`;
373
482
  out += ` integrated_date: "${optionsDate}"\n`;
374
483
  out += ` last_update_date: "${optionsDate}"\n`;
375
484
  out += ` options:\n`;
376
- out += ` intent: "${intentVal}" # project kind (app/library/both/none/manual), drives the deploy questions\n`;
485
+ out += ` intent: "${intentVal}" # ${C.intent}\n`;
377
486
  out += ` deploy: "${deployTarget}"\n`;
378
487
  out += ` publish: ${publishJson}\n`;
379
488
  out += ` secret_backup: ${includeSecretBackup}\n`;
380
489
  // semver 자동 승격(#546) — 릴리스 시 커밋 제목으로 major/minor/patch 결정. false면 항상 patch.
381
- out += ` semver_auto: ${semverAuto} # decide the version bump from commit titles (false means always patch)\n`;
490
+ out += ` semver_auto: ${semverAuto} # ${C.semverAuto}\n`;
382
491
  // Projects 보드 동기화(#716) — 미지정이면 키를 쓰지 않는다.
383
- if (projectsSync !== null) out += ` projects_sync: ${projectsSync} # GitHub Projects 상태 동기화 워크플로우 포함 여부\n`;
492
+ if (projectsSync !== null) out += ` projects_sync: ${projectsSync} # ${C.projectsSync}\n`;
384
493
  // 릴리스 시 완료 이슈 닫기(#771) — 미지정이면 키를 쓰지 않는다(기존 레포는 현행 유지).
385
- if (closeOnRelease !== null) out += ` close_on_release: ${closeOnRelease} # close issues labelled done when a release is merged (false or missing means do not close)\n`;
494
+ if (closeOnRelease !== null) out += ` close_on_release: ${closeOnRelease} # ${C.closeOnRelease}\n`;
386
495
  // 상태 라벨 표기(#776) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
387
496
  // 레포 문구 언어(#769) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
388
- if (language) out += ` language: ${language} # issue/PR template language (en | ko)\n`;
389
- if (labelStyle) out += ` label_style: ${labelStyle} # status label style (en: status: todo / ko: Korean names)\n`;
497
+ // 설치 제외 목록(#810) — 전체 재생성이라 다시 쓰지 않으면 사용자가 적은 목록이 사라진다.
498
+ if (excludedWorkflows && excludedWorkflows.length) out += ` excluded_workflows: [${excludedWorkflows.map((f) => `"${f}"`).join(", ")}] # ${C.excluded}\n`;
499
+ if (language) out += ` language: ${language} # ${C.language}\n`;
500
+ if (labelStyle) out += ` label_style: ${labelStyle} # ${C.labelStyle}\n`;
390
501
  // 앱 심사 배포 레포 여부(#553) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
391
502
  if (appRelease !== null) {
392
- out += ` app_release: ${appRelease} # whether releases go through App Store / Play Store review\n`;
503
+ out += ` app_release: ${appRelease} # ${C.appRelease}\n`;
393
504
  }
394
505
  out += ` code_review:\n`;
395
506
  out += ` coderabbit: ${codeReviewCoderabbit}\n`;
@@ -405,7 +516,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
405
516
  const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
406
517
  if (deployTypes.length) {
407
518
  out += `\n`;
408
- out += `deploy: # deploy settings remembered by the wizard (non-sensitive, safe to edit by hand)\n`;
519
+ out += `deploy: # ${C.deploy}\n`;
409
520
  for (const t of deployTypes) {
410
521
  out += ` ${t}:\n`;
411
522
  for (const [k, v] of deployValues.get(t)) out += ` ${k}: "${v}"\n`;
@@ -47,6 +47,13 @@ export function extractEnvValues(installedContent) {
47
47
  return values;
48
48
  }
49
49
 
50
+ // 예시용 기본값 (#810) — 그대로 배포되면 남의 도메인으로 요청이 간다.
51
+ // 사용자가 값을 고르지 않은 경로(--force·기본값 사용)에서는 채우지 않고 __KEY__ 를 남긴다.
52
+ // 그러면 설치 후 검증(verify.js)이 미치환으로 잡아 완료 화면에 "직접 채울 것"으로 보여준다.
53
+ export function isPlaceholderDefault(value) {
54
+ return /(^|\.)example\.(com|org|net)$/i.test(String(value || "").trim());
55
+ }
56
+
50
57
  // resolver — .sh resolve_token 등가. 값 계산은 주입된 resolvers로 위임(순수성 유지).
51
58
  // resolvers: { repo, "spring-app-yml-dir"(type), "spring-app-yml-path"(type), "flutter-root" }
52
59
  export function resolveToken(name, type, resolvers = {}) {
@@ -89,6 +96,7 @@ export function substituteEnv(content, opts = {}) {
89
96
  let def = p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg;
90
97
  const chosen = values.get(p.key);
91
98
  if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
99
+ else if (isPlaceholderDefault(def)) continue; // 예시값은 쓰지 않는다 — 토큰·마커 그대로 (#810)
92
100
  else val = def;
93
101
  // ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
94
102
  // #489 — 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
@@ -40,6 +40,10 @@ export default {
40
40
  "doctor.pd.l1": "These run other workflows but permissions has no 'actions: write'.",
41
41
  "doctor.pd.l2": "Even if the repository setting is Read and write, the API returns 403 without this declaration.",
42
42
  "doctor.pd.l3": "Fix: add 'actions: write' to the permissions block of each file",
43
+ "doctor.legacy.name": "Retired workflows",
44
+ "doctor.legacy.purpose": "Old-generation files that run next to their replacements",
45
+ "doctor.legacy.l1": "They react to the same events as the new workflows, so every run happens twice. Delete them:",
46
+ "doctor.legacy.l2": "Or run `npx projectops --mode workflows --force --remove-legacy` to rename them to .bak.",
43
47
  "doctor.bl.name": "Update baseline",
44
48
  "doctor.bl.purpose": "Basis for telling your edits apart on the next update",
45
49
  "doctor.bl.recorded": "{n} files recorded",
@@ -40,6 +40,10 @@ export default {
40
40
  "doctor.pd.l1": "다른 워크플로우를 실행하는데 permissions에 'actions: write'가 없습니다.",
41
41
  "doctor.pd.l2": "저장소 설정이 Read and write여도 이 선언이 없으면 API가 403을 반환합니다.",
42
42
  "doctor.pd.l3": "조치: 해당 파일의 permissions 블록에 'actions: write' 추가",
43
+ "doctor.legacy.name": "은퇴한 워크플로우",
44
+ "doctor.legacy.purpose": "신형과 함께 실행되는 구세대 파일",
45
+ "doctor.legacy.l1": "신형과 같은 이벤트에 반응해 모든 실행이 두 번 일어납니다. 지우려면:",
46
+ "doctor.legacy.l2": "또는 `npx projectops --mode workflows --force --remove-legacy` 로 .bak 으로 치웁니다.",
43
47
  "doctor.bl.name": "업데이트 기준점",
44
48
  "doctor.bl.purpose": "다음 업데이트가 내 수정을 구분할 근거",
45
49
  "doctor.bl.recorded": "{n}개 파일 기록됨",
@@ -58,6 +58,9 @@ export default {
58
58
  "migrate.confirm.found": "Found {n} old-generation deploy workflow(s). They may be live deployments, so they are left untouched:",
59
59
  "migrate.confirm.newer": " (new: {name})",
60
60
  "migrate.confirm.hint": " Delete the old files yourself once you have switched to the new workflows.",
61
+ "migrate.confirm.hintForce": " Delete the old files yourself once you have switched, or rerun with --remove-legacy to rename them to .bak.",
62
+ "migrate.confirm.ask": "Rename these {n} old workflow(s) to .bak so they stop running? (restore by removing .bak)",
63
+ "migrate.confirm.done": "✅ Renamed {n} old workflow(s) to .bak.",
61
64
  "breaking.title": "BREAKING CHANGES (v{current} -> v{target}) - CRITICAL {critical}, WARNING {warnings}",
62
65
  "breaking.askDetail": "Show the details (action steps) for each item?",
63
66
  "breaking.seeJson": " Details and action steps: see .github/config/breaking-changes.json",
@@ -58,6 +58,9 @@ export default {
58
58
  "migrate.confirm.found": "⚠️ 구세대 배포 워크플로우 {n}개 발견 — 현역 배포일 수 있어 자동으로 건드리지 않습니다:",
59
59
  "migrate.confirm.newer": " (신형: {name})",
60
60
  "migrate.confirm.hint": " 신형 워크플로우로 전환을 마친 뒤 구 파일을 직접 삭제하세요.",
61
+ "migrate.confirm.hintForce": " 전환을 마친 뒤 구 파일을 직접 삭제하거나, --remove-legacy 로 다시 실행하면 .bak 으로 치웁니다.",
62
+ "migrate.confirm.ask": "구세대 워크플로우 {n}개를 .bak 으로 바꿔 실행을 멈출까요? (.bak 을 지우면 복원)",
63
+ "migrate.confirm.done": "✅ 구세대 워크플로우 {n}개를 .bak 으로 바꿨습니다.",
61
64
  "breaking.title": "⚠️ BREAKING CHANGES (v{current} -> v{target}) - CRITICAL {critical}건, WARNING {warnings}건",
62
65
  "breaking.askDetail": "각 항목의 상세 내용(조치 방법)을 볼까요?",
63
66
  "breaking.seeJson": " 상세 내용, 조치 방법: .github/config/breaking-changes.json 참고",
@@ -23,6 +23,16 @@ export default {
23
23
  "done.keptHint1": "Copies of the new templates are in .github/.projectops/incoming/ (not tracked by git).",
24
24
  "done.keptHint2": "Move only the changes you want into your workflows.",
25
25
  "done.replacedTitle": "Could not tell whether {count} workflows were modified, so they were replaced with the new template and the originals saved as .bak",
26
+ "done.semverTitle": "Automatic version bump (semver_auto) is now ON for this repo",
27
+ "done.semverBody": "The next release picks the bump from commit titles: 'feat:' raises the minor version, 'type!:' the major one, anything else the patch.",
28
+ "done.semverOff": "To keep patch-only bumps, set metadata.template.options.semver_auto: false in version.yml.",
29
+ "done.notInstalledTitle": "{count} template workflow(s) were not installed",
30
+ "done.notInstalled.excluded": "listed in excluded_workflows",
31
+ "done.notInstalled.deleted-by-user": "you deleted it earlier",
32
+ "done.notInstalled.no-dev-branch": "only runs on {branch}, which this repo does not have",
33
+ "done.notInstalledHint": "To install one anyway, copy the file shown above. To stop being told, add it to options.excluded_workflows in version.yml.",
34
+ "done.legacyTitle": "{count} retired workflow(s) are still installed and run alongside their replacements",
35
+ "done.legacyHint": "Delete them once the new workflow works, or rerun with --remove-legacy to rename them to .bak.",
26
36
  "done.replacedHint": "If you had edits, copy them over from the .bak files. (The update baseline is recorded by this run.)",
27
37
  "done.utilTitle": "Utility modules:",
28
38
  "done.spring.title": "Extra setup for Spring projects:",
@@ -23,6 +23,16 @@ export default {
23
23
  "done.keptHint1": "새 템플릿 사본은 저장소에 추적되지 않는 .github/.projectops/incoming/ 에 있습니다.",
24
24
  "done.keptHint2": "필요한 변경만 워크플로에 직접 옮겨 반영하세요.",
25
25
  "done.replacedTitle": "수정 여부를 확인할 수 없어 {count}개 워크플로를 새 템플릿으로 교체하고 원본을 .bak 으로 백업했습니다",
26
+ "done.semverTitle": "이 레포의 자동 버전 승격(semver_auto)이 켜졌습니다",
27
+ "done.semverBody": "다음 릴리스부터 커밋 제목으로 올리는 폭을 정합니다: 'feat:'는 minor, 'type!:'는 major, 그 외는 patch.",
28
+ "done.semverOff": "patch만 올리고 싶으면 version.yml 의 metadata.template.options.semver_auto: false 로 두세요.",
29
+ "done.notInstalledTitle": "템플릿 워크플로우 {count}개를 설치하지 않았습니다",
30
+ "done.notInstalled.excluded": "excluded_workflows 에 적혀 있음",
31
+ "done.notInstalled.deleted-by-user": "예전에 직접 지운 파일",
32
+ "done.notInstalled.no-dev-branch": "{branch} 브랜치에서만 실행되는데 이 레포에는 그 브랜치가 없음",
33
+ "done.notInstalledHint": "그래도 쓰려면 위에 적힌 파일을 복사하세요. 안내를 그만 보려면 version.yml 의 options.excluded_workflows 에 추가하세요.",
34
+ "done.legacyTitle": "은퇴한 구세대 워크플로우 {count}개가 남아 신형과 함께 실행됩니다",
35
+ "done.legacyHint": "신형이 동작하는 걸 확인한 뒤 지우거나, --remove-legacy 로 다시 실행하면 .bak 으로 치웁니다.",
26
36
  "done.replacedHint": "직접 수정한 내용이 있었다면 .bak 에서 옮겨 오세요. (업데이트 기준점은 이번 실행으로 새로 기록됩니다)",
27
37
  "done.utilTitle": "유틸리티 모듈:",
28
38
  "done.spring.title": "Spring 프로젝트 추가 설정:",
package/src/index.js CHANGED
@@ -79,6 +79,14 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
79
79
  if (opts.showVersion) { console.log(readPkgVersion()); return 0; }
80
80
  if (opts.help) { console.log(helpText()); return 0; }
81
81
 
82
+ // options 모드 — 옵션 정본 출력. AI agent 가 문서를 뒤지지 않고 한 번에 읽는다. 읽기 전용·네트워크 없음.
83
+ if (opts.mode === "options") {
84
+ const { buildSchema, renderText } = await import("./core/options-schema.js");
85
+ const schema = buildSchema(readPkgVersion());
86
+ console.log(opts.json ? JSON.stringify(schema, null, 2) : renderText(schema));
87
+ return 0;
88
+ }
89
+
82
90
  // doctor 모드 (#558) — 읽기 전용 진단. 템플릿을 내려받지 않으므로 네트워크 없이도 동작한다.
83
91
  if (opts.mode === "doctor") {
84
92
  const { runDoctor } = await import("./commands/doctor.js");
@@ -245,12 +253,13 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
245
253
  changelogProvider: migrateProvider(existing?.options?.changelogProvider) ?? "commit",
246
254
  changelogBaseUrl: existing?.options?.changelogBaseUrl ?? "",
247
255
  codeReviewCoderabbit: existing?.options?.codeReviewCoderabbit ?? false,
248
- // semver 자동 승격(#546): 저장값 → (기존 통합 레포면 false / 신규면 true).
249
- // 이미 통합된 레포의 버전이 업데이트만으로 예고 없이 minor로 튀지 않게 하는 안전장치다.
250
- semverAuto: existing?.options?.semverAuto ?? (existing ? false : true),
256
+ // semver 자동 승격(#546, #814): 저장값(명시적 false 포함)은 존중하고, 키가 없으면 신규·기존 모두 켠다.
257
+ // 기존 레포에서 처음 켜질 때는 버전이 예고 없이 오르지 않게 완료 화면이 알린다 (semverAutoNewlyOn).
258
+ semverAuto: existing?.options?.semverAuto ?? true,
251
259
  // 앱 심사 배포 레포 여부(#553): 저장값만 보존한다. 마법사가 묻지 않으므로 새로 켜지 않는다
252
260
  // (사용자가 version.yml에 직접 쓰거나 스킬이 기록한 값을 그대로 유지).
253
261
  appRelease: existing?.options?.appRelease ?? null,
262
+ excludedWorkflows: existing?.options?.excludedWorkflows ?? null, // #810 저장값 보존
254
263
  // 상태 라벨 표기(#776): 플래그 > 저장값 > (신규 en / 기존 ko). 기존 레포의 라벨 이름은 업데이트만으로 바뀌지 않는다.
255
264
  // 릴리스 시 완료 이슈 닫기(#771): 저장값 보존, 없으면 신규만 true. 기존 레포는 키를 만들지 않아 현행 유지.
256
265
  closeOnRelease: existing?.options?.closeOnRelease ?? (existing ? null : true),
@@ -275,7 +284,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
275
284
  });
276
285
  trace.event("resolve", "semver-auto", String(context.semverAuto), {
277
286
  reason: existing?.options?.semverAuto != null ? "stored value in version.yml kept"
278
- : (existing ? "existing integrated repo, false to avoid unannounced version jumps"
287
+ : (existing ? "existing integrated repo without the key, turned on by default (announced in the summary)"
279
288
  : "new integration, true"),
280
289
  });
281
290
  trace.event("resolve", "app-release", String(context.appRelease), {
@@ -313,7 +322,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
313
322
 
314
323
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
315
324
  if (recordArtifacts) {
316
- migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd }));
325
+ migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd, removeLegacy: opts.removeLegacy === true }));
317
326
  for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
318
327
  for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
319
328
  }
@@ -352,7 +361,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
352
361
  migrationGuidePath = appendGuideEntry(cwd, {
353
362
  now, mode: opts.mode, types, repoName,
354
363
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
355
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease, language: context.language, labelStyle: context.labelStyle, closeOnRelease: context.closeOnRelease, projectsSync: context.projectsSync },
364
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease, language: context.language, labelStyle: context.labelStyle, closeOnRelease: context.closeOnRelease, projectsSync: context.projectsSync, excludedWorkflows: context.excludedWorkflows },
356
365
  branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
357
366
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
358
367
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -366,6 +375,9 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
366
375
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
367
376
  skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
368
377
  replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
378
+ legacyLeftover: migrationsResult?.confirmPending ?? [], // #809 신·구 세대 동시 실행 안내
379
+ notInstalled: result?.workflows?.notInstalled ?? [], // #810 깔지 않은 워크플로우 안내
380
+ semverAutoNewlyOn: !!existing && existing?.options?.semverAuto == null && context.semverAuto === true, // 업데이트가 켠 사실 안내
369
381
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
370
382
  // #569 — 고른 것만 안내하려면 선택값이 필요하다
371
383
  aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,
package/src/ui/summary.js CHANGED
@@ -120,6 +120,38 @@ export function printSummary(ctx, targetRoot = ".", write = null) {
120
120
  err(` ${t("done.replacedHint")}`);
121
121
  }
122
122
 
123
+ // The update turned on automatic semver for a repo that did not have the key: tell them, because the next release can jump.
124
+ if (ctx?.semverAutoNewlyOn) {
125
+ err("");
126
+ err(` ${YELLOW}⚠️ ${t("done.semverTitle")}${NC}`);
127
+ err(` ${t("done.semverBody")}`);
128
+ err(` ${t("done.semverOff")}`);
129
+ }
130
+
131
+ // Workflows left out on purpose (#810): deleted by the user, listed in excluded_workflows, or needing a branch this repo lacks.
132
+ const skippedNew = ctx?.notInstalled ?? [];
133
+ if (skippedNew.length > 0) {
134
+ err("");
135
+ err(` ${CYAN}ℹ️ ${t("done.notInstalledTitle", { count: skippedNew.length })}${NC}`);
136
+ for (const s of skippedNew) {
137
+ err(` ⏭️ ${s.filename} (${t(`done.notInstalled.${s.reason}`, { branch: ctx?.deployBranch || "develop" })})`);
138
+ if (s.incoming) err(` cp ${s.incoming} .github/workflows/${s.filename}`);
139
+ }
140
+ err(` ${t("done.notInstalledHint")}`);
141
+ }
142
+
143
+ // Retired old-generation workflows still installed: they run next to their replacements (#809).
144
+ const legacy = ctx?.legacyLeftover ?? [];
145
+ if (legacy.length > 0) {
146
+ err("");
147
+ err(` ${YELLOW}⚠️ ${t("done.legacyTitle", { count: legacy.length })}${NC}`);
148
+ for (const e of legacy) {
149
+ err(` 🗑️ ${e.file}${e.replacedBy ? ` → ${e.replacedBy}` : ""}`);
150
+ err(` git rm .github/workflows/${e.file}`);
151
+ }
152
+ err(` ${t("done.legacyHint")}`);
153
+ }
154
+
123
155
  err("");
124
156
  err(" 🔧 .github/scripts/");
125
157
  err(" ├─ version_manager.sh");