projectops 4.78.0 → 4.79.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  **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)).
12
12
 
13
13
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
14
- ## Latest version : v4.77.4 (2026-10-29)
14
+ ## Latest version : v4.78.1 (2026-10-29)
15
15
 
16
16
  [View full version history](CHANGELOG.md)
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.78.0",
3
+ "version": "4.79.0",
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",
@@ -3,6 +3,7 @@
3
3
  // 새 시각 층(banner/detectionLog/analysisCard/ideStatus/installKind/summary)과 저수준 엔진(engineIo)은
4
4
  // io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
5
5
  import { resolveOptions } from "../core/resolve-options.js";
6
+ import { detectStoreLocalesHint } from "../core/store-locales-hint.js";
6
7
  import { join } from "node:path";
7
8
  import { existsSync, readFileSync } from "node:fs";
8
9
  import { PATHS } from "../core/paths.js";
@@ -290,6 +291,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
290
291
  // @wizard env 계획 질문 (full/workflows만).
291
292
  // 업데이트 모드는 생략 — 신규 파일은 기본값, 교체 파일은 아래 carryover가 기존 실값을 이월한다.
292
293
  const resolvers = makeResolvers(cwd, repoName, paths);
294
+ // 다국어 스토어인데 store_locales 미설정이면 안내만 한다 (#843) — 복사 전 상태로 판정
295
+ const storeLocalesHint = detectStoreLocalesHint({ root: cwd, existing, paths, trace });
293
296
  let envValues = new Map(), envUseDefaults = true;
294
297
  if (showOptional && !updateRun) {
295
298
  const plan = await promptEnvPlan({
@@ -425,6 +428,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
425
428
  now, mode, types, repoName,
426
429
  templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
427
430
  options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows, storeLocales, storeLocalesIos, storeLocalesPlay },
431
+ storeLocalesHint, // #843 체크리스트 한 항목
428
432
  branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
429
433
  breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
430
434
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -441,6 +445,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
441
445
  legacyLeftover: migrationsResult?.confirmPending ?? [], // #809 신·구 세대 동시 실행 안내
442
446
  notInstalled: result?.workflows?.notInstalled ?? [], // #810 깔지 않은 워크플로우 안내
443
447
  semverAutoNewlyOn,
448
+ storeLocalesHint, // #843 다국어 스토어 안내
444
449
  verification: result?.verification, // #549 설치 검증 결과
445
450
  aiPrSummary, codeReviewCoderabbit, // #569 — 고른 것만 안내
446
451
  logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
@@ -2,8 +2,6 @@
2
2
  //
3
3
  // 충돌(기존 파일과 다름)의 처리 결정은 hooks.decisions(Map<파일명, 'skip'|'backup'|'template'>)로 받는다.
4
4
  // - 실제 마법사(commands/interactive.js)는 listWorkflowConflicts 로 충돌 목록을 얻어 직접 Map 을 만든다.
5
- // - copyWorkflowsInteractive 는 onConflict 콜백으로 같은 Map 을 만들어 주는 보조 API 다. 프로덕션 경로는 쓰지 않고
6
- // 테스트(copy-workflows-conflict.test.js)만 쓴다.
7
5
  import { join, basename } from "node:path";
8
6
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
9
7
  import { PATHS } from "../paths.js";
@@ -346,7 +344,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace
346
344
  });
347
345
  }
348
346
 
349
- // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — 마법사(commands/interactive.js)와 copyWorkflowsInteractive 가 쓴다.
347
+ // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — 마법사(commands/interactive.js)가 쓴다.
350
348
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
351
349
  export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
352
350
  const { types = [], paths = new Map(), deployTarget = "docker-ssh", repoName = "", resolvers = {}, branch = "", deployBranch = "" } = context;
@@ -383,23 +381,6 @@ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
383
381
  }
384
382
  return conflicts;
385
383
  }
386
-
387
- // 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
388
- // 동기 엔진에 위임한다. WHY 분리: copyWorkflows를 async로 바꾸면 await 없이 호출하는
389
- // 기존 호출부(runFull/runWorkflows)가 깨진다 — 시그니처 무변경 원칙.
390
- // onConflict 반환값: 'template' | 'skip' | 'backup' (그 외/미지정 → 'skip').
391
- export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".", { onConflict } = {}) {
392
- const decisions = new Map();
393
- if (typeof onConflict === "function") {
394
- for (const { filename, type } of listWorkflowConflicts(context, tempDir, targetRoot)) {
395
- if (decisions.has(filename)) continue; // 파일명은 PROJECT-{TYPE}- prefix로 타입 간 유일
396
- decisions.set(filename, await onConflict(filename, type));
397
- }
398
- }
399
- return copyWorkflows(context, tempDir, targetRoot, { decisions });
400
- }
401
-
402
-
403
384
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
404
385
  const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null, baseline = null } = ctx;
405
386
  const typeDir = join(projectTypesDir, type);
@@ -130,6 +130,12 @@ export function renderGuideEntry(report) {
130
130
  for (const [k, v] of kv) checklist.push(` - ${t} \`${k}\` = \`${v}\``);
131
131
  }
132
132
  }
133
+ // #843 — 다국어 스토어인데 store_locales 미설정. 안내만 (키는 쓰지 않는다)
134
+ const storeHint = r.storeLocalesHint ?? null;
135
+ if (storeHint) {
136
+ checklist.push(`- [ ] **Decide whether to turn on per-language store release notes**: ${storeHint.locales.length} store languages found (${storeHint.locales.map((l) => `\`${l}\``).join(", ")}) but \`store_locales\` is not set, so every language gets the same text. To use per-language text from \`store_notes\` in CHANGELOG.json, add \`metadata.template.options.store_locales: ["ko-KR", "en-US", ...]\` to version.yml (first = default language). Found in:`);
137
+ for (const s of storeHint.signals) checklist.push(` - \`${s.path}\` (${s.source}: ${s.locales.join(", ")})`);
138
+ }
133
139
  const devBranch = r.branches?.deployBranch ?? "develop";
134
140
  const devBranchResult = r.branches?.result ?? null; // #715 --create-development-branch 결과
135
141
  if (devBranchResult?.status === "created") {
@@ -283,7 +283,7 @@ export const KEY_DETAILS = {
283
283
  "metadata.template.options.projects_sync": { when: "Edit via --projects-sync.", effect: "Whether the Projects status sync workflow is installed.", omitted: "false for new installs; kept if installed." },
284
284
  "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." },
285
285
  "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"]' },
286
- "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"]' },
286
+ "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. Likely needed when the repo already has DELIVER_LOCALES with 2+ languages in a workflow or Fastfile, 2+ android/fastlane/metadata/android/<lang> folders, or 2+ release-notes/<lang> folders; an update (npx projectops) then prints a notice on the completion screen and a checklist item in the migration guide, without writing this key.", 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 (also when DELIVER_LOCALES lists several languages: they all get that one text).", example: 'store_locales: ["ko-KR", "en-US", "ja-JP", "zh-CN"]' },
287
287
  "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"]' },
288
288
  "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"]' },
289
289
  "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" },
@@ -0,0 +1,89 @@
1
+ // 스토어 다국어 설정 감지 (#843): store_locales 없이 여러 언어에 한 문구를 올리는 레포를 찾아 알리기만 한다.
2
+ // 동작·version.yml 은 바꾸지 않는다. 결과는 완료 화면 한 줄 + 마이그레이션 가이드 체크리스트 한 항목.
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+
6
+ // 언어 폴더 이름 모양 (ko, ko-KR, en-US, zh-Hans)
7
+ const LOCALE_NAME = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
8
+ // DELIVER_LOCALES 대입 (yaml `KEY: "a,b"`, 셸 `KEY=a,b`, Ruby `ENV["KEY"] = "a,b"`). `||` 기본값은 대입이 아니라 제외
9
+ const DELIVER_LOCALES_ASSIGN = /DELIVER_LOCALES["']?\]?\s*[:=]\s*["']?([A-Za-z0-9_, -]+)/g;
10
+ const MINIMUM_LOCALE_COUNT = 2;
11
+
12
+ /**
13
+ * 다국어 스토어 신호 3종 수집: DELIVER_LOCALES 2개 이상, Play metadata 언어 폴더, release-notes 언어 폴더
14
+ * storeLocales 가 이미 있거나(null 아님) 신호가 없으면 null
15
+ */
16
+ export function detectMultiLocaleStore({ root, flutterPath = ".", storeLocales = null }) {
17
+ if (storeLocales != null) return null;
18
+ const appRoot = join(root, flutterPath || ".");
19
+ const signals = [
20
+ ...findDeliverLocales(root, appRoot),
21
+ ...findLocaleDirectories(root, join(appRoot, "android/fastlane/metadata/android"), "play-metadata"),
22
+ ...findLocaleDirectories(root, join(appRoot, "release-notes"), "release-notes"),
23
+ ];
24
+ // 루트와 앱 폴더가 다를 때만 루트 release-notes 도 본다 (같으면 중복)
25
+ if (appRoot !== join(root, ".")) signals.push(...findLocaleDirectories(root, join(root, "release-notes"), "release-notes"));
26
+ if (signals.length === 0) return null;
27
+ const locales = [...new Set(signals.flatMap((s) => s.locales))];
28
+ return { signals, locales };
29
+ }
30
+
31
+ // 워크플로우(.yml/.yaml) + Fastfile(ios·android·루트) 의 DELIVER_LOCALES
32
+ function findDeliverLocales(root, appRoot) {
33
+ const candidates = [];
34
+ const workflowsDir = join(root, ".github/workflows");
35
+ if (existsSync(workflowsDir)) {
36
+ for (const name of readdirSync(workflowsDir)) {
37
+ if (/\.ya?ml$/.test(name)) candidates.push(join(workflowsDir, name));
38
+ }
39
+ }
40
+ for (const sub of ["ios/fastlane/Fastfile", "android/fastlane/Fastfile", "fastlane/Fastfile"]) {
41
+ candidates.push(join(appRoot, sub));
42
+ }
43
+ const found = [];
44
+ for (const file of new Set(candidates)) {
45
+ if (!isFile(file)) continue;
46
+ const text = readFileSync(file, "utf8");
47
+ for (const match of text.matchAll(DELIVER_LOCALES_ASSIGN)) {
48
+ const locales = match[1].split(",").map((s) => s.trim()).filter((s) => LOCALE_NAME.test(s));
49
+ if (locales.length >= MINIMUM_LOCALE_COUNT) {
50
+ found.push({ source: "deliver-locales", path: toRelative(root, file), locales });
51
+ break; // 파일당 1건이면 충분
52
+ }
53
+ }
54
+ }
55
+ return found;
56
+ }
57
+
58
+ // <dir>/<lang>/ 폴더가 2개 이상이면 신호 1건
59
+ function findLocaleDirectories(root, dir, source) {
60
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
61
+ const locales = readdirSync(dir)
62
+ .filter((name) => LOCALE_NAME.test(name) && statSync(join(dir, name)).isDirectory())
63
+ .sort();
64
+ return locales.length >= MINIMUM_LOCALE_COUNT ? [{ source, path: toRelative(root, dir), locales }] : [];
65
+ }
66
+
67
+ function isFile(path) {
68
+ return existsSync(path) && statSync(path).isFile();
69
+ }
70
+
71
+ // 화면·가이드 표기는 OS 와 무관하게 / 구분자
72
+ function toRelative(root, path) {
73
+ return relative(root, path).split("\\").join("/");
74
+ }
75
+
76
+ /**
77
+ * 진입점(비대화형·대화형) 공통: 기존 레포 + storeLocales 미설정일 때만 감지 > trace 기록 > 결과 반환
78
+ * 신규 설치는 null (알릴 대상은 이미 다국어로 운영 중인 레포)
79
+ */
80
+ export function detectStoreLocalesHint({ root, existing, paths, trace = null }) {
81
+ if (!existing) return null;
82
+ const hint = detectMultiLocaleStore({
83
+ root, flutterPath: paths?.get?.("flutter") || ".", storeLocales: existing.options?.storeLocales ?? null,
84
+ });
85
+ trace?.event?.("detect", "store-locales-hint", hint ? "multi-locale" : "none", hint
86
+ ? { locales: hint.locales, signals: hint.signals.map((s) => `${s.source}:${s.path}`) }
87
+ : { storeLocalesSet: existing.options?.storeLocales != null });
88
+ return hint;
89
+ }
@@ -26,6 +26,8 @@ export default {
26
26
  "done.semverTitle": "Automatic version bump (semver_auto) is now ON for this repo",
27
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
28
  "done.semverOff": "To keep patch-only bumps, set metadata.template.options.semver_auto: false in version.yml.",
29
+ "done.storeLocalesTitle": "Several store languages found ({locales}) but store_locales is not set: every language gets the same release note",
30
+ "done.storeLocalesHint": "For per-language notes add metadata.template.options.store_locales: [\"ko-KR\", \"en-US\", ...] to version.yml (first = default). Nothing was changed.",
29
31
  "done.notInstalledTitle": "{count} template workflow(s) were not installed",
30
32
  "done.notInstalled.excluded": "listed in excluded_workflows",
31
33
  "done.notInstalled.deleted-by-user": "you deleted it earlier",
@@ -26,6 +26,8 @@ export default {
26
26
  "done.semverTitle": "이 레포의 자동 버전 승격(semver_auto)이 켜졌습니다",
27
27
  "done.semverBody": "다음 릴리스부터 커밋 제목으로 올리는 폭을 정합니다: 'feat:'는 minor, 'type!:'는 major, 그 외는 patch.",
28
28
  "done.semverOff": "patch만 올리고 싶으면 version.yml 의 metadata.template.options.semver_auto: false 로 두세요.",
29
+ "done.storeLocalesTitle": "스토어 언어가 여러 개({locales})인데 store_locales 가 없어 모든 언어에 같은 릴리스 노트가 올라갑니다",
30
+ "done.storeLocalesHint": "언어별 문구를 쓰려면 version.yml 에 metadata.template.options.store_locales: [\"ko-KR\", \"en-US\", ...] 를 넣으세요 (첫 항목이 기본 언어). 아무것도 바꾸지 않았습니다.",
29
31
  "done.notInstalledTitle": "템플릿 워크플로우 {count}개를 설치하지 않았습니다",
30
32
  "done.notInstalled.excluded": "excluded_workflows 에 적혀 있음",
31
33
  "done.notInstalled.deleted-by-user": "예전에 직접 지운 파일",
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
21
21
  import { appendGuideEntry } from "./core/migration-guide.js";
22
22
  import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
23
23
  import { resolveOptions } from "./core/resolve-options.js";
24
+ import { detectStoreLocalesHint } from "./core/store-locales-hint.js";
24
25
  import { runDevelopmentBranchStep } from "./core/development-branch.js";
25
26
  import { printBannerCompact } from "./ui/banner.js";
26
27
  import { printSummary } from "./ui/summary.js";
@@ -203,6 +204,8 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
203
204
  });
204
205
  const { intent, deployTarget, publishTargets } = resolved;
205
206
  const { before: beforeCleanup, applicable } = sources;
207
+ // 다국어 스토어인데 store_locales 미설정이면 안내만 한다 (#843) — 복사 전 상태로 판정
208
+ const storeLocalesHint = detectStoreLocalesHint({ root: cwd, existing, paths, trace });
206
209
 
207
210
  // 축 확정 근거 (#561) — "왜 이 값인가"가 가장 헷갈리는 자리다.
208
211
  // CLI 플래그 / 저장값 / intent 유도 / 타입 적용성 정리 중 무엇이 이겼는지 남긴다.
@@ -327,6 +330,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
327
330
  now, mode: opts.mode, types, repoName,
328
331
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
329
332
  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 },
333
+ storeLocalesHint, // #843 체크리스트 한 항목
330
334
  branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: isDevelopmentBranchReady,
331
335
  created: developmentBranchResult ? developmentBranchResult.status === "created" : null, result: developmentBranchResult },
332
336
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
@@ -346,6 +350,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
346
350
  legacyLeftover: migrationsResult?.confirmPending ?? [], // #809 신·구 세대 동시 실행 안내
347
351
  notInstalled: result?.workflows?.notInstalled ?? [], // #810 깔지 않은 워크플로우 안내
348
352
  semverAutoNewlyOn: sources.semverAutoNewlyOn && context.semverAuto === true, // 업데이트가 켠 사실 안내
353
+ storeLocalesHint, // #843 다국어 스토어 안내
349
354
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
350
355
  // #569 — 고른 것만 안내하려면 선택값이 필요하다
351
356
  aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,
package/src/ui/summary.js CHANGED
@@ -128,6 +128,13 @@ export function printSummary(ctx, targetRoot = ".", write = null) {
128
128
  err(` ${t("done.semverOff")}`);
129
129
  }
130
130
 
131
+ // Several store languages but no store_locales (#843): every language still gets the same text. Notice only.
132
+ if (ctx?.storeLocalesHint) {
133
+ err("");
134
+ err(` ${CYAN}ℹ️ ${t("done.storeLocalesTitle", { locales: ctx.storeLocalesHint.locales.join(", ") })}${NC}`);
135
+ err(` ${t("done.storeLocalesHint")}`);
136
+ }
137
+
131
138
  // Workflows left out on purpose (#810): deleted by the user, listed in excluded_workflows, or needing a branch this repo lacks.
132
139
  const skippedNew = ctx?.notInstalled ?? [];
133
140
  if (skippedNew.length > 0) {