projectops 4.44.2 → 4.45.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.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  **Status: actively maintained.** npm `latest` is exactly what is on the `main` branch; work in progress on `develop` is never published. Versions can still ship several times a day. To stay on a version you have tested, run `npx projectops@<version>` ([releases](https://github.com/Cassiiopeia/projectops/releases)).
10
10
 
11
11
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
12
- ## Latest version : v4.44.1 (2026-10-10)
12
+ ## Latest version : v4.45.0 (2026-10-10)
13
13
 
14
14
  [View full version history](CHANGELOG.md)
15
15
 
@@ -49,7 +49,7 @@ This project automates your development workflow on two axes.
49
49
  | Format every issue by hand | `/pro-github` writes and files an issue from the standard template in one step |
50
50
  | Copy the issue URL into commit messages | `/pro-commit` completes the message from the issue context |
51
51
  | Write PR descriptions and reports by hand | `/pro-report` analyzes the git diff and generates one |
52
- | Re-type prompts for every code review or analysis | 20 Skills give consistent results without re-typing |
52
+ | Re-type prompts for every code review or analysis | 17 Skills give consistent results without re-typing |
53
53
 
54
54
  ---
55
55
 
@@ -137,8 +137,7 @@ Open a PR from the development branch to `main` and it writes the release notes,
137
137
  | `/pro-design-brief` | A design request for a screen you built before the mockup exists: state captures, alternatives, copy candidates, and must-keep rules as a board for the designer |
138
138
  | `/pro-oss-consult` | Diagnose a GitHub repo as an open-source project: type detection, 6-axis scoring, maturity stage, and edits to About, topics, labels, and files only after approval. One repo or a whole account |
139
139
 
140
- > Design, planning, and implementation flow is handled by `superpowers` (brainstorming → writing-plans → executing-plans).
141
- > `/pro-plan`, `/pro-analyze`, and `/pro-implement` are the previous generation of the same roles and only run when invoked explicitly. Together with the 17 in the tables above, that makes 20 in total.
140
+ > Design, planning, and implementation are handled by `superpowers` (brainstorming → writing-plans → executing-plans), not by this plugin.
142
141
 
143
142
  ### 📝 Documents and artifacts
144
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.44.2",
3
+ "version": "4.45.1",
4
4
  "description": "ProjectOps: installer CLI for a fully automated GitHub project management template (versioning, CI/CD, release notes, agent skills)",
5
5
  "keywords": [
6
6
  "devops",
@@ -30,7 +30,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
30
30
  force = true, now, today, templateVersion = "unknown",
31
31
  deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true,
32
32
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
33
- deployBranch = "", intent = null, semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = "en", excludedWorkflows = null, storeLocales = null } = context;
33
+ deployBranch = "", intent = null, semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = "en", excludedWorkflows = null, storeLocales = null, storeLocalesIos = null, storeLocalesPlay = null } = context;
34
34
 
35
35
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
36
36
  const pathMarkers = new Map();
@@ -55,7 +55,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
55
55
  ...customYml(vyFile),
56
56
  updatedBy: resolveUpdatedBy(existsSync(vyFile) ? readFileSync(vyFile, "utf8") : "", targetRoot), // #811
57
57
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, aiPrSummary, optionsDate: today,
58
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales },
58
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales, storeLocalesIos, storeLocalesPlay },
59
59
  })), { version, versionCode });
60
60
 
61
61
  // 2. README 버전 섹션
@@ -2,7 +2,7 @@
2
2
  // io 주입으로 테스트 가능. 실제 실행은 src/ui/prompts.js 함수를 io로 넘긴다.
3
3
  // 새 시각 층(banner/detectionLog/analysisCard/ideStatus/installKind/summary)과 저수준 엔진(engineIo)은
4
4
  // io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
5
- import { DEFAULT_CODE_REVIEW_CODERABBIT } from "../core/constants.js";
5
+ import { resolveOptions } from "../core/resolve-options.js";
6
6
  import { join } from "node:path";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { PATHS } from "../core/paths.js";
@@ -14,7 +14,7 @@ import { runBreakingCheck } from "../core/breaking-check.js";
14
14
  import { runMigrations } from "../core/migrations/index.js";
15
15
  import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
16
16
  import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
17
- import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets, migrateProvider } from "../core/options-ask.js";
17
+ import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets } from "../core/options-ask.js";
18
18
  import { createRunTrace, MIGRATION_DIR } from "../core/run-trace.js";
19
19
  import { appendGuideEntry } from "../core/migration-guide.js";
20
20
  import { promptEnvPlan } from "../ui/env-plan.js";
@@ -125,33 +125,26 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
125
125
  let branch = detectDefaultBranch(cwd);
126
126
  const repoName = detectRepoName(cwd);
127
127
  const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존
128
- // 배포/publish 축 초기값(#439): version.yml 저장 옵션 (구 키 자동 마이그레이션 포함)
129
- let deployTarget = existing?.options?.deploy ?? "docker-ssh";
130
- let publishTargets = existing?.options?.publish ?? [];
131
- let includeSecretBackup = existing?.options?.secretBackup ?? false;
132
- let aiPrSummary = existing?.options?.aiPrSummary ?? null; // null = 아직 안 물음 (#566)
133
- let codeReviewCoderabbit = existing?.options?.codeReviewCoderabbit ?? DEFAULT_CODE_REVIEW_CODERABBIT;
134
- let changelogProvider = migrateProvider(existing?.options?.changelogProvider) ?? "commit";
135
- let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
136
- let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
128
+ // 옵션 초기값은 비대화형과 같은 resolve-options.js 에서 받는다 (#851).
129
+ // 질문을 건너뛰는 모드(version 등)도 이 값이 그대로 기록되므로 타입 적용성 정리까지 거친 값이어야 한다.
130
+ const { values: init, sources: initSources } = resolveOptions({ existing, types, cwd });
131
+ let deployTarget = init.deployTarget;
132
+ let publishTargets = init.publishTargets;
133
+ let includeSecretBackup = init.includeSecretBackup;
134
+ let aiPrSummary = init.aiPrSummary;
135
+ let codeReviewCoderabbit = init.codeReviewCoderabbit;
136
+ let changelogProvider = init.changelogProvider;
137
+ let changelogBaseUrl = init.changelogBaseUrl;
138
+ let deployBranch = init.deployBranch; // #456 — 비어 있으면 기록하지 않는다. 질문에서 고른 값만 남긴다
137
139
  let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
138
140
  let deployBranchCreated = null; // #493 — 마법사가 직접 생성했는지 (가이드 기록용)
139
- let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
140
- // semver 자동 승격(#546) — 질문하지 않는다(#485 질문 부담 축소 방향 유지).
141
- // 저장값(명시적 false 포함)은 보존하고, 키가 없으면 기존 레포도 ON. 켜진 사실은 완료 화면이 알린다.
142
- const semverAuto = existing?.options?.semverAuto ?? true;
143
- const semverAutoNewlyOn = !!existing && existing?.options?.semverAuto == null;
144
- const appRelease = existing?.options?.appRelease ?? null; // #553 저장값 보존 (묻지 않음)
145
- // 상태 라벨 표기(#776): 저장값 > (신규 en / 기존 ko). 기존 한글 레포에는 영문 전환을 한 번 제안한다(아래).
146
- const closeOnRelease = existing?.options?.closeOnRelease ?? (existing ? null : true); // #771 묻지 않는다
147
- const excludedWorkflows = existing?.options?.excludedWorkflows ?? null; // #810 저장값 보존
148
- const storeLocales = existing?.options?.storeLocales ?? null; // #829 저장값 보존
149
- // Projects 보드 동기화(#716): 묻지 않는다. 저장값 > 이미 설치돼 있으면 유지 > 신규는 제외 (index.js 와 같은 규칙).
150
- const projectsSync = existing?.options?.projectsSync
151
- ?? existsSync(join(cwd, ".github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml"));
152
- // 레포 문구 언어(#769): 저장값 > (신규 en / 기존 ko). 기존 레포에는 영문 전환을 한 번만 제안한다.
153
- let language = resolveRepoLanguage({ flag: null, stored: existing?.options?.language, existing: !!existing });
154
- let labelStyle = resolveLabelStyle({ flag: null, stored: existing?.options?.labelStyle, existing: !!existing });
141
+ let intent = init.intent; // #485 프로젝트 성격
142
+ // 묻지 않는 값 — 저장값 보존 규칙은 resolve-options.js 에 있다
143
+ const { semverAuto, appRelease, closeOnRelease, excludedWorkflows, storeLocales, storeLocalesIos, storeLocalesPlay, projectsSync } = init;
144
+ const semverAutoNewlyOn = initSources.semverAutoNewlyOn;
145
+ // 레포 문구 언어(#769)·라벨 표기(#776): 기존 레포에는 영문 전환을 한 번만 제안한다(아래).
146
+ let language = init.language;
147
+ let labelStyle = init.labelStyle;
155
148
  const showOptional = mode === "full" || mode === "workflows";
156
149
  const realTty = process.stdout.isTTY === true;
157
150
  // 기존 한글 라벨 레포에는 영문 표준 전환을 한 번만 제안한다(#776). 거절하면 한글을 저장해 다시 묻지 않는다.
@@ -192,6 +185,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
192
185
  publishTargets = r.publish;
193
186
  includeSecretBackup = r.secretBackup;
194
187
  codeReviewCoderabbit = r.codeReviewCoderabbit;
188
+ aiPrSummary = r.aiPrSummary; // #851 — 빠져 있어 PR 요약 선택이 버려졌다
195
189
  changelogProvider = r.changelogProvider;
196
190
  changelogBaseUrl = r.changelogBaseUrl;
197
191
  deployBranch = r.deployBranch;
@@ -254,7 +248,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
254
248
  tempDir, types, targetRoot: cwd, defaultBranch: branch,
255
249
  current: {
256
250
  deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup,
257
- codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent,
251
+ codeReviewCoderabbit, aiPrSummary, changelogProvider, changelogBaseUrl, deployBranch, intent,
258
252
  },
259
253
  force: false, tty: realTty, forceAsk: true, scope: [what],
260
254
  io: {
@@ -268,6 +262,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
268
262
  publishTargets = r.publish;
269
263
  includeSecretBackup = r.secretBackup;
270
264
  codeReviewCoderabbit = r.codeReviewCoderabbit;
265
+ aiPrSummary = r.aiPrSummary; // #851
271
266
  changelogProvider = r.changelogProvider;
272
267
  changelogBaseUrl = r.changelogBaseUrl;
273
268
  deployBranch = r.deployBranch;
@@ -332,10 +327,10 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
332
327
  const { now, today } = clock || utcNow();
333
328
  const ctx = createContext({
334
329
  mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
335
- codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales,
330
+ aiPrSummary, codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales, storeLocalesIos, storeLocalesPlay,
336
331
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
337
332
  // #502 — version 모드가 기존 full 기록을 강등하지 않도록 (full이 우세)
338
- recordMode: existing?.templateMode === "full" ? "full" : "version",
333
+ recordMode: init.recordMode,
339
334
  });
340
335
  ctx.templateVersion = templateVersion;
341
336
 
@@ -427,7 +422,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
427
422
  migrationGuidePath = appendGuideEntry(cwd, {
428
423
  now, mode, types, repoName,
429
424
  templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
430
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales },
425
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales, storeLocalesIos, storeLocalesPlay },
431
426
  branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
432
427
  breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
433
428
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -17,7 +17,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
17
17
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
18
18
  now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
19
19
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
20
- deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null, storeLocales = null } = context;
20
+ deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null, storeLocales = null, storeLocalesIos = null, storeLocalesPlay = null } = context;
21
21
 
22
22
  const pathMarkers = new Map();
23
23
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -36,7 +36,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
36
36
  // mode(#502): version 모드가 기존 full 통합 기록을 "version"으로 강등하지 않도록
37
37
  // 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
38
38
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
39
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales },
39
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales, storeLocalesIos, storeLocalesPlay },
40
40
  }));
41
41
  addVersionSectionToReadme(version, targetRoot);
42
42
  copyScripts(tempDir, targetRoot);
package/src/context.js CHANGED
@@ -33,6 +33,8 @@ export function createContext(overrides = {}) {
33
33
  labelStyle: null, // 상태 라벨 표기 (#776). null=미설정(신규 en / 기존 레포 ko)
34
34
  excludedWorkflows: null, // 설치하지 않을 워크플로우 파일명 (#810). null=없음
35
35
  storeLocales: null, // 스토어 릴리스 노트 언어 (#829). null=미설정 → 현행 동작 유지
36
+ storeLocalesIos: null, // App Store 만 언어 구성이 다를 때 (#829). null=공통 store_locales 를 쓴다
37
+ storeLocalesPlay: null, // Play 만 언어 구성이 다를 때 (#829)
36
38
  appRelease: null, // 앱 심사 배포 레포인가 (#553). null=미설정(키 기록 안 함)
37
39
  templateVersion: "",
38
40
  tempDir: "",
@@ -15,6 +15,7 @@ import { substituteBranches } from "../branch-sub.js";
15
15
  import { saveIncoming, lineDiffCounts } from "../incoming.js";
16
16
  import { branchStatus } from "../git-branch.js";
17
17
  import { PUBLISH_TARGETS } from "../constants.js";
18
+ import { commonOptionalGroups, serverDeployEnabled, SERVER_DEPLOY_DIR } from "../workflow-groups.js";
18
19
 
19
20
  // on: 블록의 브랜치 필터가 전부 develop 뿐인가 (#810).
20
21
  // 개발 브랜치가 없는 레포에 깔면 영영 실행되지 않는 워크플로우를 가린다.
@@ -229,78 +230,46 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
229
230
  if (asks.size) deployValues.set(type, asks);
230
231
  }
231
232
 
232
- // (4.5) common/deploy/<target> — 타입 비종속 배포 타겟 (vercel 등, #439)
233
- const commonDeployDir = join(commonDir, "deploy", deployTarget || "docker-ssh");
234
- if (exists(commonDeployDir)) {
235
- for (const filename of listYamlFiles(commonDeployDir)) {
236
- const src = join(commonDeployDir, filename);
233
+ // (4.5~5) common/ 아래 선택형 폴더 — deploy/<target>, pr-summary, projects-sync, secret-backup.
234
+ // 어느 폴더를 켜는지는 workflow-groups.js 한 곳이 정한다 (#851). 고아 감지도 같은 표를 쓴다.
235
+ // bak-replace: 템플릿과 같으면 건너뛰고, 다르면 .bak 으로 남기고 교체한다.
236
+ // new-only : 이미 있으면 건드리지 않는다. 새로 깔 때만 레포 이름을 채운다 (#729 — 고정값이면
237
+ // 모든 프로젝트가 서버의 같은 폴더에 Secret 을 덮어쓴다).
238
+ for (const group of commonOptionalGroups(commonDir, { includeSecretBackup, aiPrSummary, projectsSync, deployTarget })) {
239
+ const groupDir = join(commonDir, group.dir);
240
+ if (!group.enabled || !exists(groupDir)) continue;
241
+ for (const filename of listYamlFiles(groupDir)) {
242
+ const src = join(groupDir, filename);
237
243
  const dst = join(workflowsDir, filename);
238
- if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
239
- counters.skipped++;
240
- trace?.event("copy", "skipped-unchanged", filename, { group: "common-deploy" });
244
+ if (group.policy === "new-only") {
245
+ if (existsSync(dst)) continue;
246
+ if (skipNew(groupDir, filename, group.copyLabel)) continue;
247
+ copyFileSync(src, dst);
248
+ const raw = readFileSync(dst, "utf8");
249
+ const filled = resolveGlobalTokens(raw, repoName || "my-project");
250
+ if (filled !== raw) writeFileSync(dst, filled);
251
+ counters.optionalCopied++;
252
+ counters.copied++;
253
+ counters.copiedFiles.push(filename);
254
+ trace?.event("copy", "copied", filename, { group: group.copyLabel });
241
255
  continue;
242
256
  }
243
- if (skipNew(commonDeployDir, filename, "common-deploy")) continue;
244
- const backedUp = existsSync(dst);
245
- if (backedUp) renameSync(dst, dst + ".bak");
246
- copyFileSync(src, dst);
247
- counters.optionalCopied++;
248
- counters.copied++;
249
- counters.copiedFiles.push(filename);
250
- trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "common-deploy" });
251
- if (backedUp) counters.replacedBak.push(filename); // #673
252
- }
253
- }
254
-
255
- // (4.7) common/pr-summary — AI 변경 요약 (#566). 선택했을 때만 복사한다.
256
- // 종전에는 common 본체에 있어 무조건 복사된 뒤 런타임에 스스로 빠졌고, 그 판단이
257
- // ".coderabbit.yaml 존재"라 파일만 있고 앱이 없는 저장소에서는 아무도 요약하지 않았다.
258
- // (4.8) common/projects-sync — Projects 보드 동기화 (#716). 이것도 선택했을 때만 복사한다.
259
- // Secret(_GITHUB_PAT_TOKEN)과 PROJECT_URL 변수를 등록해야 동작하는데, Projects 를 안 쓰는 레포가
260
- // 많아 설치만 되고 설정되지 않은 채 남았다. 같은 폴더 방식이라 게이트와 고아 감지(orphan-workflows.js)를 함께 둔다.
261
- for (const [groupName, groupEnabled] of [["pr-summary", aiPrSummary], ["projects-sync", projectsSync]]) {
262
- const prSummaryDir = join(commonDir, groupName);
263
- if (exists(prSummaryDir) && groupEnabled) {
264
- for (const filename of listYamlFiles(prSummaryDir)) {
265
- const src = join(prSummaryDir, filename);
266
- const dst = join(workflowsDir, filename);
267
257
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
268
258
  counters.skipped++;
269
- trace?.event("copy", "skipped-unchanged", filename, { group: groupName });
259
+ trace?.event("copy", "skipped-unchanged", filename, { group: group.copyLabel });
270
260
  continue;
271
261
  }
272
- if (skipNew(prSummaryDir, filename, groupName)) continue;
262
+ if (skipNew(groupDir, filename, group.copyLabel)) continue;
273
263
  const backedUp = existsSync(dst);
274
264
  if (backedUp) renameSync(dst, dst + ".bak");
275
265
  copyFileSync(src, dst);
276
266
  counters.optionalCopied++;
277
267
  counters.copied++;
278
268
  counters.copiedFiles.push(filename);
279
- trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: groupName });
269
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: group.copyLabel });
280
270
  if (backedUp) counters.replacedBak.push(filename); // #673
281
271
  }
282
272
  }
283
- }
284
-
285
- // (5) common/secret-backup — 있으면 무조건 스킵/신규만 복사
286
- const secretDir = join(commonDir, "secret-backup");
287
- if (exists(secretDir) && includeSecretBackup) {
288
- for (const filename of listYamlFiles(secretDir)) {
289
- const dst = join(workflowsDir, filename);
290
- if (existsSync(dst)) continue; // 이미 존재하면 스킵
291
- if (skipNew(secretDir, filename, "secret-backup")) continue;
292
- copyFileSync(join(secretDir, filename), dst);
293
- // PROJECT_NAME 이 고정값이면 모든 프로젝트가 서버의 같은 폴더에 Secret 을 덮어쓴다 (#729) —
294
- // 다른 배포 워크플로처럼 레포 이름으로 채운다. 이름을 알 수 없으면 기존 기본값을 쓴다.
295
- const raw = readFileSync(dst, "utf8");
296
- const filled = resolveGlobalTokens(raw, repoName || "my-project");
297
- if (filled !== raw) writeFileSync(dst, filled);
298
- counters.optionalCopied++;
299
- counters.copied++;
300
- counters.copiedFiles.push(filename);
301
- trace?.event("copy", "copied", filename, { group: "secret-backup" });
302
- }
303
- }
304
273
 
305
274
  // (6) 브랜치 치환 post-pass (#477) — 표준과 다른 브랜치 전략일 때만 복사된 파일에 적용.
306
275
  // isUnchanged 가상 비교에도 같은 branches가 들어가므로 다음 업데이트에서 재복사 churn이 없다.
@@ -411,8 +380,8 @@ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
411
380
  if (exists(typeDir)) {
412
381
  for (const f of classify(typeDir, workflowsDir, envOpts, baseline).changed) conflicts.push({ filename: f, type });
413
382
  }
414
- const serverDeployDir = join(typeDir, "server-deploy");
415
- if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
383
+ const serverDeployDir = join(typeDir, SERVER_DEPLOY_DIR);
384
+ if (exists(serverDeployDir) && serverDeployEnabled(deployTarget)) {
416
385
  for (const f of classify(serverDeployDir, workflowsDir, envOpts, baseline).changed) conflicts.push({ filename: f, type });
417
386
  }
418
387
  }
@@ -464,8 +433,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
464
433
  }
465
434
 
466
435
  // server-deploy — deploy=docker-ssh일 때만 포함 (#439)
467
- const serverDeployDir = join(typeDir, "server-deploy");
468
- if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
436
+ const serverDeployDir = join(typeDir, SERVER_DEPLOY_DIR);
437
+ if (exists(serverDeployDir) && serverDeployEnabled(deployTarget)) {
469
438
  const { newFiles, unchanged, changed, upstream } = classify(serverDeployDir, workflowsDir, envOpts, baseline);
470
439
  for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
471
440
  for (const f of newFiles) { if (counters.skipNew(serverDeployDir, f, `${type}/server-deploy`)) continue; copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
@@ -234,7 +234,10 @@ export async function askAllOptionalWorkflows({
234
234
  if (askIntent) {
235
235
  if (force || !tty || typeof io.select !== "function") {
236
236
  // 비대화형: CLI intent 미지정이면 deploy/publish에서 역추론, 그래도 없으면 manual(기존 동작)
237
- intent = intent ?? inferIntent(deploy, publish) ?? "manual";
237
+ // 축이 아직 안 정해졌으면 비대화형(resolve-options.js)과 같은 기본 축으로 추론한다 (#851).
238
+ // 예전에는 null 로 추론해 "manual" 이 기록되어 같은 레포에서 두 경로의 결과가 갈렸다.
239
+ const defaultDeploy = applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none";
240
+ intent = intent ?? inferIntent(deploy ?? defaultDeploy, publish ?? []) ?? "manual";
238
241
  } else {
239
242
  say("");
240
243
  say(msg("options.intent.intro"));
@@ -394,7 +397,8 @@ export async function askAllOptionalWorkflows({
394
397
  // "배포 브랜치"가 아니다 — 배포가 도는 곳은 기본 브랜치(default) 쪽 개념. #482 참조.
395
398
  if (ask("release-branch") || deployBranch === null) {
396
399
  if (force || !tty || typeof io.text !== "function") {
397
- deployBranch = deployBranch ?? "develop";
400
+ // 묻지 못했으면 값을 지어내지 않는다 (#851) — 비어 있으면 기록하지 않고 스킬이 develop 으로 폴백한다.
401
+ deployBranch = deployBranch ?? "";
398
402
  } else {
399
403
  const base = defaultBranch || "main"; // git으로 감지된 기본 브랜치 (#481 동적 안내)
400
404
  say("");
@@ -431,7 +435,7 @@ export async function askAllOptionalWorkflows({
431
435
  aiPrSummary: aiPrSummary !== false,
432
436
  changelogProvider: changelogProvider ?? "commit",
433
437
  changelogBaseUrl: changelogBaseUrl ?? "",
434
- deployBranch: deployBranch ?? "develop",
438
+ deployBranch: deployBranch ?? "",
435
439
  deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
436
440
  deployBranchCreated, // #493 — true=이번 실행에서 마법사가 생성, null=확인 안 함
437
441
 
@@ -111,6 +111,12 @@ export const VERSION_YML = [
111
111
  { path: "metadata.template.options.store_locales", parserKey: "storeLocales", type: "string[]",
112
112
  description: "Store release-note languages, first is the default (for example [\"ko-KR\", \"en-US\", \"ja-JP\", \"zh-CN\"]). Missing = one text for every store language, as before. Per-language text comes from store_notes in CHANGELOG.json; a language without text gets the default-language text (the stores reject an empty What's New).",
113
113
  edit: "by hand" },
114
+ { path: "metadata.template.options.store_locales_ios", parserKey: "storeLocalesIos", type: "string[]",
115
+ description: "Only when the App Store languages differ from store_locales: the App Store uses this list instead. fastlane deliver ACTIVATES every language folder the app version does not have yet, so a language the App Store listing lacks must not be in this list.",
116
+ edit: "by hand" },
117
+ { path: "metadata.template.options.store_locales_play", parserKey: "storeLocalesPlay", type: "string[]",
118
+ description: "Only when the Play languages differ from store_locales: Google Play uses this list instead.",
119
+ edit: "by hand" },
114
120
  { path: "metadata.template.options.code_review.coderabbit", parserKey: "codeReviewCoderabbit", type: "boolean", description: "CodeRabbit config installed.", edit: "wizard" },
115
121
  { path: "metadata.template.options.code_review.ai_summary", parserKey: "aiPrSummary", type: "boolean", default: true, description: "AI PR summary workflow installed.", edit: "wizard (--ai-summary)" },
116
122
  { path: "metadata.template.options.changelog.provider", parserKey: "changelogProvider", type: "enum",
@@ -269,6 +275,8 @@ export const KEY_DETAILS = {
269
275
  "metadata.template.options.app_release": { when: "Set true by hand for repos whose releases go through App Store / Play Store review.", effect: "The deploy skill (pro-changelog-deploy) treats releases as going through store review: release notes get a review warning banner and are checked more strictly, and the release PR always waits for manual approval instead of automerging.", omitted: "Not set. Never enabled automatically.", caution: "Do not set it on repos that do not ship to a store." },
270
276
  "metadata.template.options.excluded_workflows": { when: "Add a file name to stop the update from ever installing that template workflow.", effect: "Listed files are skipped on install and update. Files you deleted yourself are also not restored; a copy of the template is kept in .github/.projectops/incoming/.", omitted: "[]", example: 'excluded_workflows: ["PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC.yml"]' },
271
277
  "metadata.template.options.store_locales": { when: "Set it when the store listing has several languages and release notes must not be one Korean text copied into all of them.", effect: "Turns on per-language store release notes (Play Console 'Release notes', App Store 'What's New'). The first entry is the default language. Each other language uses the text in store_notes[<language>] of that version in CHANGELOG.json; a language without text gets the default-language text, because the stores reject an empty What's New.", omitted: "Off: the same text is used for every store language, as before.", example: 'store_locales: ["ko-KR", "en-US", "ja-JP", "zh-CN"]' },
278
+ "metadata.template.options.store_locales_ios": { when: "Set it only when the App Store listing has a different set of languages than Google Play.", effect: "The App Store uses this list instead of store_locales (same rules: first entry is the default language, a language without text gets the default-language text). Needed because fastlane deliver ACTIVATES every language folder the app version does not have, so a language the App Store listing lacks would be added to the app.", omitted: "The App Store uses store_locales.", example: 'store_locales_ios: ["ko-KR", "en-US"]' },
279
+ "metadata.template.options.store_locales_play": { when: "Set it only when Google Play has a different set of languages than the App Store.", effect: "Google Play uses this list instead of store_locales (same rules).", omitted: "Google Play uses store_locales.", example: 'store_locales_play: ["ko-KR", "en-US", "ja-JP"]' },
272
280
  "metadata.template.options.code_review.coderabbit": { when: "Edit via --coderabbit / --no-coderabbit.", effect: "Whether .coderabbit.yaml is installed by --mode full. PR review bot only; unrelated to release notes.", omitted: "false" },
273
281
  "metadata.template.options.code_review.ai_summary": { when: "Edit via --ai-summary.", effect: "Whether the AI PR summary workflow is installed.", omitted: "true" },
274
282
  "metadata.template.options.changelog.provider": { when: "Leave the default. Set only to force one generator.", effect: "Release notes: the PR body is used as is when it already has notes; otherwise a registered AI key (GEMINI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) is used; otherwise the commit analysis, which always works and costs nothing. 'copilot' consumes Copilot premium requests, so it is only used when set here.", omitted: "commit (an AI key is still picked up automatically)." },
@@ -6,11 +6,12 @@ import { existsSync, renameSync, rmSync, readdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { PATHS } from "./paths.js";
8
8
  import { exists, listYamlFiles } from "./fsutil.js";
9
+ import { commonOptionalGroups, SERVER_DEPLOY_DIR } from "./workflow-groups.js";
9
10
 
10
11
  // 템플릿의 project-types/<type>/ 인벤토리 — 직하위 + server-deploy/ + publish/*/ (copy 엔진과 동일 범위)
11
12
  function typeInventory(projectTypesDir, type) {
12
13
  const typeDir = join(projectTypesDir, type);
13
- const dirs = [typeDir, join(typeDir, "server-deploy")];
14
+ const dirs = [typeDir, join(typeDir, SERVER_DEPLOY_DIR)];
14
15
  const pubRoot = join(typeDir, "publish");
15
16
  if (exists(pubRoot)) {
16
17
  for (const e of readdirSync(pubRoot, { withFileTypes: true })) {
@@ -28,30 +29,14 @@ function typeInventory(projectTypesDir, type) {
28
29
  // common/ 아래 "선택했을 때만 복사되는" 폴더들 (#566).
29
30
  // 켜면 복사 엔진이 넣어주지만, 끄면 이미 있던 파일이 그대로 남아 계속 실행됐다.
30
31
  // 축을 끈 사용자에게는 그게 곧 "껐는데 왜 도나"가 된다 — 여기서 고아로 잡는다.
31
- // deploy는 타겟별 폴더라 선택된 타겟만 살리고 나머지를 대상으로 삼는다.
32
+ // 켜짐/꺼짐은 복사 엔진과 같은 표(workflow-groups.js)에서 읽는다 (#851).
32
33
  function commonOptionalOrphans(commonDir, opts) {
33
- const { includeSecretBackup = false, aiPrSummary = true, projectsSync = true, deployTarget = "docker-ssh" } = opts;
34
34
  const out = [];
35
-
36
- const gated = [
37
- ["secret-backup", includeSecretBackup, "secret-backup"],
38
- ["pr-summary", aiPrSummary, "pr-summary"],
39
- ["projects-sync", projectsSync, "projects-sync"],
40
- ];
41
- for (const [dir, enabled, label] of gated) {
42
- if (enabled) continue;
43
- const d = join(commonDir, dir);
35
+ for (const group of commonOptionalGroups(commonDir, opts)) {
36
+ if (group.enabled) continue;
37
+ const d = join(commonDir, group.dir);
44
38
  if (!exists(d)) continue;
45
- for (const f of listYamlFiles(d)) out.push({ filename: f, type: label });
46
- }
47
-
48
- // deploy/<target> — 선택된 타겟 외 폴더는 전부 고아 후보
49
- const deployRoot = join(commonDir, "deploy");
50
- if (exists(deployRoot)) {
51
- for (const e of readdirSync(deployRoot, { withFileTypes: true })) {
52
- if (!e.isDirectory() || e.name === deployTarget) continue;
53
- for (const f of listYamlFiles(join(deployRoot, e.name))) out.push({ filename: f, type: `deploy/${e.name}` });
54
- }
39
+ for (const f of listYamlFiles(d)) out.push({ filename: f, type: group.orphanLabel });
55
40
  }
56
41
  return out;
57
42
  }
@@ -0,0 +1,91 @@
1
+ // 설치 옵션 값을 정하는 단 한 곳 (#851).
2
+ //
3
+ // 예전에는 비대화형(src/index.js)과 대화형(src/commands/interactive.js)이 같은 옵션을
4
+ // 각자 정했다. 그 사이 규칙이 갈라져 대화형에서 PR 요약 선택이 버려지고(aiPrSummary),
5
+ // 질문을 건너뛰는 version 모드가 모바일 단독 레포에 deploy: docker-ssh 를 남겼다.
6
+ // 규칙은 모든 옵션에 같다: CLI 플래그 > version.yml 저장값 > 기본값.
7
+ // 대화형은 이 결과를 초기값으로 쓰고, 사용자가 답한 것만 그 위에 덮어쓴다.
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { applicableTargets, migrateProvider } from "./options-ask.js";
11
+ import { DEFAULT_CODE_REVIEW_CODERABBIT } from "./constants.js";
12
+ import { resolveLabelStyle } from "./label-style.js";
13
+ import { resolveRepoLanguage } from "./repo-language.js";
14
+
15
+ // 이미 설치돼 있으면 Projects 동기화를 켜진 것으로 보는 기준 파일 (#716)
16
+ export const PROJECTS_SYNC_WORKFLOW = ".github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml";
17
+
18
+ // 타입에 성립하지 않는 축 값을 정리한다 (#498). 모바일 앱·basic 단독은 두 축이 모두 비어
19
+ // intent 도 none 으로 확정된다. 질문을 거친 값에도 같은 규칙을 다시 적용할 수 있게 따로 둔다.
20
+ export function normalizeAxes({ deploy, publish, intent, types }) {
21
+ const applicable = applicableTargets(types);
22
+ const outDeploy = deploy !== "none" && !applicable.deploy.includes(deploy) ? "none" : deploy;
23
+ const outPublish = publish.filter((t) => applicable.publish.includes(t));
24
+ const noAxes = types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0;
25
+ return { deploy: outDeploy, publish: outPublish, intent: noAxes ? "none" : intent, applicable };
26
+ }
27
+
28
+ // flags: parseArgs 결과 중 옵션 관련 값 (없으면 {} — 대화형 초기값)
29
+ // existing: parseExisting 결과 또는 null
30
+ // 반환: { values, sources } — sources 는 trace 가 "왜 이 값인가"를 남길 근거다 (#561).
31
+ export function resolveOptions({ flags = {}, existing = null, types = [], cwd = "." }) {
32
+ const stored = existing?.options ?? {};
33
+ const applicable = applicableTargets(types);
34
+
35
+ // 프로젝트 성격(#485): CLI --intent → 저장값. deploy/publish 유도의 기준.
36
+ let intent = flags.intent ?? stored.intent ?? null;
37
+ // 배포/publish 축(#439): 플래그 → 저장값 → 기본값(적용 가능할 때만 docker-ssh — #498).
38
+ let deploy = flags.deployTarget ?? stored.deploy
39
+ ?? (applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none");
40
+ let publish = flags.publishTargets ?? stored.publish ?? [];
41
+ // --intent 를 줬고 해당 축 플래그가 없으면 intent 가 축을 유도한다 (#485 비대화형)
42
+ if (flags.intent != null) {
43
+ if ((intent === "library" || intent === "none") && flags.deployTarget == null) deploy = "none";
44
+ if ((intent === "app" || intent === "none") && flags.publishTargets == null) publish = [];
45
+ }
46
+ const before = { deploy, publish: [...publish] };
47
+ ({ deploy, publish, intent } = normalizeAxes({ deploy, publish, intent, types }));
48
+
49
+ const values = {
50
+ intent, deployTarget: deploy, publishTargets: publish,
51
+ includeSecretBackup: flags.includeSecretBackup ?? stored.secretBackup ?? false,
52
+ // #566 — 설정 없이 바로 동작하므로 기본 켜짐
53
+ aiPrSummary: flags.aiPrSummary ?? stored.aiPrSummary ?? true,
54
+ // #716 — 설정(Secret, PROJECT_URL) 없는 신규 레포에 깔면 알림만 쌓인다. 이미 깔린 레포는 유지.
55
+ projectsSync: flags.projectsSync ?? stored.projectsSync ?? existsSync(join(cwd, PROJECTS_SYNC_WORKFLOW)),
56
+ // #502 — version 모드가 기존 full 통합 기록을 강등하지 않게 (full 이 우세)
57
+ recordMode: existing?.templateMode === "full" ? "full" : "version",
58
+ // #456 — 비어 있으면 version.yml 에 쓰지 않는다 (스킬이 develop 으로 폴백). 사용자가 고르거나 플래그로 준 것만 기록.
59
+ deployBranch: flags.deployBranch || stored.deployBranch || "",
60
+ // #455 — null 이 흘러 provider:"null" 로 기록되던 버그 방지
61
+ changelogProvider: migrateProvider(stored.changelogProvider) ?? "commit",
62
+ changelogBaseUrl: stored.changelogBaseUrl ?? "",
63
+ codeReviewCoderabbit: flags.codeReviewCoderabbit ?? stored.codeReviewCoderabbit ?? DEFAULT_CODE_REVIEW_CODERABBIT,
64
+ // #546, #814 — 명시적 false 는 존중, 키가 없으면 신규·기존 모두 켠다
65
+ semverAuto: stored.semverAuto ?? true,
66
+ // 마법사가 묻지 않는 값은 저장값만 보존한다 (전체 재생성이라 안 넘기면 사라진다)
67
+ appRelease: stored.appRelease ?? null, // #553
68
+ excludedWorkflows: stored.excludedWorkflows ?? null, // #810
69
+ storeLocales: stored.storeLocales ?? null, // #829
70
+ storeLocalesIos: stored.storeLocalesIos ?? null,
71
+ storeLocalesPlay: stored.storeLocalesPlay ?? null,
72
+ // #771 — 신규만 켠다. 기존 레포는 키를 만들지 않아 현행 유지
73
+ closeOnRelease: stored.closeOnRelease ?? (existing ? null : true),
74
+ // #769, #776 — 신규 en / 기존 ko. 업데이트만으로 기존 레포 문구·라벨이 바뀌지 않는다
75
+ language: resolveRepoLanguage({ flag: flags.language ?? null, stored: stored.language, existing: !!existing }),
76
+ labelStyle: resolveLabelStyle({ flag: flags.labelStyle ?? null, stored: stored.labelStyle, existing: !!existing }),
77
+ };
78
+
79
+ const from = (flagVal, storedVal, flagName) =>
80
+ flagVal != null ? `cli-flag(${flagName})` : (storedVal ? "version.yml(stored value)" : "default");
81
+ const sources = {
82
+ intent: flags.intent != null ? "cli-flag(--intent)"
83
+ : (stored.intent ? "version.yml(stored value)" : "not given, inferred from deploy/publish"),
84
+ deploy: from(flags.deployTarget, stored.deploy, "--deploy"),
85
+ publish: from(flags.publishTargets, stored.publish, "--publish"),
86
+ before, applicable,
87
+ // 기존 레포에서 semver_auto 가 이번에 처음 켜졌는지 — 완료 화면이 알린다
88
+ semverAutoNewlyOn: !!existing && stored.semverAuto == null,
89
+ };
90
+ return { values, sources };
91
+ }
@@ -117,6 +117,7 @@ const COMMENTS = {
117
117
  deploy: "deploy settings remembered by the wizard (non-sensitive, safe to edit by hand)",
118
118
  excluded: "workflow files the update must never install (safe to edit by hand)",
119
119
  storeLocales: "store release-note languages, first is the default (missing = one text for every store language)",
120
+ storeLocalesStore: "same, only for this store when its languages differ from store_locales (optional)",
120
121
  },
121
122
  ko: {
122
123
  versionCode: "app build number",
@@ -133,6 +134,7 @@ const COMMENTS = {
133
134
  deploy: "마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)",
134
135
  excluded: "업데이트가 절대 설치하지 않을 워크플로우 파일 (직접 수정 가능)",
135
136
  storeLocales: "스토어 릴리스 노트 언어 목록, 첫 항목이 기본 언어 (미기재면 모든 스토어 언어에 같은 문구)",
137
+ storeLocalesStore: "이 스토어만 언어 구성이 store_locales 와 다를 때 쓰는 선택 키",
136
138
  },
137
139
  };
138
140
  const commentsFor = (lang) => COMMENTS[lang === "ko" ? "ko" : "en"];
@@ -161,7 +163,7 @@ export function resolveUpdatedBy(existingContent = "", cwd = ".") {
161
163
  export function parseTemplateOptions(content) {
162
164
  const out = { deploy: null, publish: null, secretBackup: null,
163
165
  changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null, aiPrSummary: null, projectsSync: null,
164
- deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null, excludedWorkflows: null, storeLocales: null };
166
+ deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null, excludedWorkflows: null, storeLocales: null, storeLocalesIos: null, storeLocalesPlay: null };
165
167
  // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
166
168
  for (const line of String(content || "").split("\n")) {
167
169
  if (line.startsWith("#")) continue;
@@ -275,6 +277,12 @@ export function parseTemplateOptions(content) {
275
277
  out.storeLocales = m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
276
278
  continue;
277
279
  }
280
+ // 스토어마다 지원 언어가 다를 때만 쓰는 선택 키 — 있으면 그 스토어는 공통 store_locales 대신 이것을 쓴다.
281
+ m = line.match(/^\s+store_locales_(ios|play):\s*\[([^\]]*)\]/);
282
+ if (m) {
283
+ out[m[1] === "ios" ? "storeLocalesIos" : "storeLocalesPlay"] = m[2].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
284
+ continue;
285
+ }
278
286
  // 프로젝트 성격(#553) — 앱 심사로 이어지는 레포인가. 워크플로우와 스킬이 같은 값을 본다.
279
287
  m = line.match(/^\s+app_release:\s*["']?(true|false)["']?/);
280
288
  if (m) {
@@ -559,7 +567,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
559
567
  if (templateOptions) {
560
568
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
561
569
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true, aiPrSummary = true, intent = null, mode = null,
562
- semverAuto = true, appRelease = null, labelStyle = null, closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null, storeLocales = null } = templateOptions;
570
+ semverAuto = true, appRelease = null, labelStyle = null, closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null, storeLocales = null, storeLocalesIos = null, storeLocalesPlay = null } = templateOptions;
563
571
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
564
572
  // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
565
573
  const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
@@ -587,6 +595,8 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
587
595
  if (excludedWorkflows && excludedWorkflows.length) out += ` excluded_workflows: [${excludedWorkflows.map((f) => `"${f}"`).join(", ")}] # ${C.excluded}\n`;
588
596
  // 스토어 릴리스 노트 언어(#829) — 미지정이면 키를 쓰지 않는다. 전체 재생성이라 다시 쓰지 않으면 사용자가 적은 값이 사라진다.
589
597
  if (storeLocales && storeLocales.length) out += ` store_locales: [${storeLocales.map((l) => `"${l}"`).join(", ")}] # ${C.storeLocales}\n`;
598
+ if (storeLocalesIos && storeLocalesIos.length) out += ` store_locales_ios: [${storeLocalesIos.map((l) => `"${l}"`).join(", ")}] # App Store: ${C.storeLocalesStore}\n`;
599
+ if (storeLocalesPlay && storeLocalesPlay.length) out += ` store_locales_play: [${storeLocalesPlay.map((l) => `"${l}"`).join(", ")}] # Play: ${C.storeLocalesStore}\n`;
590
600
  if (language) out += ` language: ${language} # ${C.language}\n`;
591
601
  if (labelStyle) out += ` label_style: ${labelStyle} # ${C.labelStyle}\n`;
592
602
  // 앱 심사 배포 레포 여부(#553) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
@@ -0,0 +1,44 @@
1
+ // 선택형 워크플로 그룹의 켜짐/꺼짐을 정하는 단 한 곳 (#851).
2
+ //
3
+ // 예전에는 복사(copy/workflows.js), 충돌 목록(listWorkflowConflicts), 고아 감지(orphan-workflows.js)가
4
+ // 같은 조건을 각자 들고 있었다. 한쪽만 고치면 "켰는데 안 깔림" 또는 "껐는데 계속 돎"이 된다 (#567).
5
+ // 새 조건부 폴더를 만들면 여기에 한 줄을 넣는다. 세 곳이 함께 따라온다.
6
+ import { readdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { exists } from "./fsutil.js";
9
+
10
+ // 서버 배포(<type>/server-deploy/)는 docker-ssh 일 때만 깐다 (#439)
11
+ export const SERVER_DEPLOY_DIR = "server-deploy";
12
+ export function serverDeployEnabled(deployTarget) {
13
+ return (deployTarget || "docker-ssh") === "docker-ssh";
14
+ }
15
+
16
+ // common/ 아래 선택형 폴더 목록. 순서가 복사 순서다 (기록·요약에 나오는 순서를 바꾸지 않는다).
17
+ // policy: "bak-replace" — 템플릿과 다르면 .bak 으로 남기고 교체
18
+ // "new-only" — 이미 있으면 건드리지 않는다 (secret-backup: 서버 경로를 사용자가 고쳐 쓴다)
19
+ // copyLabel / orphanLabel — trace 그룹 이름과 고아 안내 이름. 기존 기록과 같게 둔다.
20
+ export function commonOptionalGroups(commonDir, opts = {}) {
21
+ const { includeSecretBackup = false, aiPrSummary = true, projectsSync = true, deployTarget = "docker-ssh" } = opts;
22
+ const selectedDeploy = deployTarget || "docker-ssh";
23
+ const groups = [];
24
+
25
+ // deploy/<target> — 타입 비종속 배포 타겟(vercel 등). 고른 타겟 폴더만 켠다.
26
+ const deployRoot = join(commonDir, "deploy");
27
+ const deployNames = exists(deployRoot)
28
+ ? readdirSync(deployRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
29
+ : [];
30
+ if (!deployNames.includes(selectedDeploy)) deployNames.push(selectedDeploy); // 폴더가 없으면 아래에서 exists 로 걸러진다
31
+ for (const name of deployNames) {
32
+ groups.push({ dir: join("deploy", name), enabled: name === selectedDeploy, policy: "bak-replace",
33
+ copyLabel: "common-deploy", orphanLabel: `deploy/${name}` });
34
+ }
35
+ groups.push(
36
+ // #566 — AI 변경 요약. 설정 없이 바로 동작
37
+ { dir: "pr-summary", enabled: aiPrSummary === true, policy: "bak-replace", copyLabel: "pr-summary", orphanLabel: "pr-summary" },
38
+ // #716 — Projects 보드 동기화. Secret·변수를 등록해야 동작해 신규는 기본 꺼짐
39
+ { dir: "projects-sync", enabled: projectsSync === true, policy: "bak-replace", copyLabel: "projects-sync", orphanLabel: "projects-sync" },
40
+ // Secret 서버 백업
41
+ { dir: "secret-backup", enabled: includeSecretBackup === true, policy: "new-only", copyLabel: "secret-backup", orphanLabel: "secret-backup" },
42
+ );
43
+ return groups;
44
+ }
package/src/index.js CHANGED
@@ -19,16 +19,13 @@ import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
19
19
  import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
20
20
  import { appendGuideEntry } from "./core/migration-guide.js";
21
21
  import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
22
- import { applicableTargets, migrateProvider } from "./core/options-ask.js";
23
- import { DEFAULT_CODE_REVIEW_CODERABBIT } from "./core/constants.js";
22
+ import { resolveOptions } from "./core/resolve-options.js";
24
23
  import { printBannerCompact } from "./ui/banner.js";
25
24
  import { printSummary } from "./ui/summary.js";
26
25
  import { runFull } from "./commands/full.js";
27
26
  import { runVersion } from "./commands/version.js";
28
27
  import { runWorkflows } from "./commands/workflows.js";
29
28
  import { runIssues } from "./commands/issues.js";
30
- import { resolveLabelStyle } from "./core/label-style.js";
31
- import { resolveRepoLanguage } from "./core/repo-language.js";
32
29
  import { runInteractive } from "./commands/interactive.js";
33
30
  import { runSkills } from "./commands/skills.js";
34
31
 
@@ -191,84 +188,38 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
191
188
  const { now, today } = clock || utcNow();
192
189
  const tempDir = join(cwd, PATHS.tempDir);
193
190
 
194
- // 프로젝트 성격(#485): CLI --intent → version.yml 저장값. deploy/publish 유도의 기준.
195
- let intent = opts.intent ?? existing?.options?.intent ?? null;
196
- // 배포/publish 축(#439): CLI 플래그 최우선 → 저장값 → 기본값(적용 가능할 때만 docker-ssh — #498).
197
- // 단 --intent가 명시됐고 해당 축 플래그가 없으면 intent가 유도한다 (#485 비대화형):
198
- // library/none이면 deploy=none, app/none이면 publish=[].
199
- const applicable = applicableTargets(types);
200
- let deployTarget = opts.deployTarget ?? existing?.options?.deploy
201
- ?? (applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none");
202
- let publishTargets = opts.publishTargets ?? existing?.options?.publish ?? [];
203
- if (opts.intent != null) {
204
- if ((intent === "library" || intent === "none") && opts.deployTarget == null) deployTarget = "none";
205
- if ((intent === "app" || intent === "none") && opts.publishTargets == null) publishTargets = [];
206
- }
207
- // 적용 불가 타겟 조용한 정리 (#498) — 대화형과 동일 규칙. 타입에 성립하지 않는 축 값은
208
- // 복사 결과가 동일하므로 경고 없이 none/교집합으로 정리한다 (모바일 앱/basic 단독 등).
209
- const beforeCleanup = { deploy: deployTarget, publish: [...publishTargets] };
210
- if (deployTarget !== "none" && !applicable.deploy.includes(deployTarget)) deployTarget = "none";
211
- publishTargets = publishTargets.filter((t) => applicable.publish.includes(t));
212
- if (types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0) intent = "none";
191
+ // 옵션 값은 resolve-options.js 한 곳에서 정한다 (#851) — 대화형과 같은 규칙.
192
+ const { values: resolved, sources } = resolveOptions({
193
+ flags: {
194
+ intent: opts.intent, deployTarget: opts.deployTarget, publishTargets: opts.publishTargets,
195
+ includeSecretBackup: opts.includeSecretBackup, aiPrSummary: opts.aiPrSummary, projectsSync: opts.projectsSync,
196
+ deployBranch: opts.deployBranch, codeReviewCoderabbit: opts.codeReviewCoderabbit,
197
+ language: opts.language, labelStyle: opts.labelStyle,
198
+ },
199
+ existing, types, cwd,
200
+ });
201
+ const { intent, deployTarget, publishTargets } = resolved;
202
+ const { before: beforeCleanup, applicable } = sources;
213
203
 
214
204
  // 축 확정 근거 (#561) — "왜 이 값인가"가 가장 헷갈리는 자리다.
215
205
  // CLI 플래그 / 저장값 / intent 유도 / 타입 적용성 정리 중 무엇이 이겼는지 남긴다.
216
- trace.event("resolve", "intent", String(intent ?? "(unset)"), {
217
- source: opts.intent != null ? "cli-flag(--intent)"
218
- : (existing?.options?.intent ? "version.yml(stored value)" : "not given, inferred from deploy/publish"),
219
- });
206
+ trace.event("resolve", "intent", String(intent ?? "(unset)"), { source: sources.intent });
220
207
  trace.event("resolve", "deploy", deployTarget, {
221
- source: opts.deployTarget != null ? "cli-flag(--deploy)"
222
- : (existing?.options?.deploy ? "version.yml(stored value)" : "default"),
208
+ source: sources.deploy,
223
209
  applicableForTypes: applicable.deploy,
224
210
  adjusted: beforeCleanup.deploy !== deployTarget
225
211
  ? `${beforeCleanup.deploy} → ${deployTarget} (not applicable to the selected types)` : null,
226
212
  });
227
213
  trace.event("resolve", "publish", publishTargets.join(",") || "(none)", {
228
- source: opts.publishTargets != null ? "cli-flag(--publish)"
229
- : (existing?.options?.publish ? "version.yml(stored value)" : "default"),
214
+ source: sources.publish,
230
215
  applicableForTypes: applicable.publish,
231
216
  adjusted: beforeCleanup.publish.join(",") !== publishTargets.join(",")
232
217
  ? `${beforeCleanup.publish.join(",") || "(none)"} → ${publishTargets.join(",") || "(none)"} (cleaned up, not applicable)` : null,
233
218
  });
234
219
 
235
220
  const context = createContext({
236
- mode: opts.mode, force: true, types, version, versionCode, branch,
237
- paths,
238
- deployTarget,
239
- publishTargets,
240
- includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
241
- // #566 — CLI 값 우선, 없으면 저장값 보존, 그것도 없으면 true(설정 없이 바로 동작).
242
- aiPrSummary: opts.aiPrSummary ?? existing?.options?.aiPrSummary ?? true,
243
- // Projects 보드 동기화(#716): CLI > 저장값 > 이미 설치돼 있으면 유지 > 신규는 제외.
244
- // 설정(Secret, PROJECT_URL)이 없는 레포에 설치되면 라벨이 바뀔 때마다 알림만 쌓이기 때문이다.
245
- // 이미 설치된 레포가 갱신만으로 연동을 잃지 않도록 파일이 있으면 켜진 것으로 본다.
246
- projectsSync: opts.projectsSync ?? existing?.options?.projectsSync
247
- ?? existsSync(join(cwd, ".github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml")),
248
- // #502 — version 모드가 기존 full 통합 기록(mode)을 강등하지 않도록 (full이 우세)
249
- recordMode: existing?.templateMode === "full" ? "full" : "version",
250
- // 릴리스 배포 브랜치(#456): CLI 플래그 → version.yml 저장값 → 빈 값(미출력, 스킬이 develop 폴백)
251
- deployBranch: opts.deployBranch || existing?.options?.deployBranch || "",
252
- intent,
253
- // changelog/code_review 축(#455): 비대화형은 저장값 → 기본값. null이 흘러 provider:"null"로 기록되던 버그 수정.
254
- changelogProvider: migrateProvider(existing?.options?.changelogProvider) ?? "commit",
255
- changelogBaseUrl: existing?.options?.changelogBaseUrl ?? "",
256
- // 플래그 > 저장값 > 기본(꺼짐). 쓰겠다고 명시하면 켜지고, 아무 말이 없을 때만 기본값이 적용된다.
257
- codeReviewCoderabbit: opts.codeReviewCoderabbit ?? existing?.options?.codeReviewCoderabbit ?? DEFAULT_CODE_REVIEW_CODERABBIT,
258
- // semver 자동 승격(#546, #814): 저장값(명시적 false 포함)은 존중하고, 키가 없으면 신규·기존 모두 켠다.
259
- // 기존 레포에서 처음 켜질 때는 버전이 예고 없이 오르지 않게 완료 화면이 알린다 (semverAutoNewlyOn).
260
- semverAuto: existing?.options?.semverAuto ?? true,
261
- // 앱 심사 배포 레포 여부(#553): 저장값만 보존한다. 마법사가 묻지 않으므로 새로 켜지 않는다
262
- // (사용자가 version.yml에 직접 쓰거나 스킬이 기록한 값을 그대로 유지).
263
- appRelease: existing?.options?.appRelease ?? null,
264
- excludedWorkflows: existing?.options?.excludedWorkflows ?? null, // #810 저장값 보존
265
- storeLocales: existing?.options?.storeLocales ?? null, // #829 저장값 보존 (전체 재생성이라 안 넘기면 사라진다)
266
- // 상태 라벨 표기(#776): 플래그 > 저장값 > (신규 en / 기존 ko). 기존 레포의 라벨 이름은 업데이트만으로 바뀌지 않는다.
267
- // 릴리스 시 완료 이슈 닫기(#771): 저장값 보존, 없으면 신규만 true. 기존 레포는 키를 만들지 않아 현행 유지.
268
- closeOnRelease: existing?.options?.closeOnRelease ?? (existing ? null : true),
269
- // 레포 문구 언어(#769): 플래그 > 저장값 > (신규 en / 기존 ko). 기존 레포의 템플릿 언어는 업데이트만으로 바뀌지 않는다.
270
- language: resolveRepoLanguage({ flag: opts.language, stored: existing?.options?.language, existing: !!existing }),
271
- labelStyle: resolveLabelStyle({ flag: opts.labelStyle, stored: existing?.options?.labelStyle, existing: !!existing }),
221
+ mode: opts.mode, force: true, types, version, versionCode, branch, paths,
222
+ ...resolved,
272
223
  repoName,
273
224
  // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
274
225
  resolvers: makeResolvers(cwd, repoName, paths),
@@ -364,7 +315,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
364
315
  migrationGuidePath = appendGuideEntry(cwd, {
365
316
  now, mode: opts.mode, types, repoName,
366
317
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
367
- 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, storeLocales: context.storeLocales },
318
+ 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, storeLocales: context.storeLocales, storeLocalesIos: context.storeLocalesIos, storeLocalesPlay: context.storeLocalesPlay },
368
319
  branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
369
320
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
370
321
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -380,7 +331,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
380
331
  replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
381
332
  legacyLeftover: migrationsResult?.confirmPending ?? [], // #809 신·구 세대 동시 실행 안내
382
333
  notInstalled: result?.workflows?.notInstalled ?? [], // #810 깔지 않은 워크플로우 안내
383
- semverAutoNewlyOn: !!existing && existing?.options?.semverAuto == null && context.semverAuto === true, // 업데이트가 켠 사실 안내
334
+ semverAutoNewlyOn: sources.semverAutoNewlyOn && context.semverAuto === true, // 업데이트가 켠 사실 안내
384
335
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
385
336
  // #569 — 고른 것만 안내하려면 선택값이 필요하다
386
337
  aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,