projectops 4.59.1 → 4.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/cli/args.js +2 -2
  4. package/src/commands/doctor.js +3 -1
  5. package/src/commands/full.js +9 -11
  6. package/src/commands/interactive.js +13 -13
  7. package/src/commands/issues.js +1 -1
  8. package/src/commands/skills.js +1 -1
  9. package/src/commands/version.js +5 -6
  10. package/src/commands/workflows.js +3 -3
  11. package/src/context.js +2 -2
  12. package/src/core/agent-guide.js +2 -1
  13. package/src/core/assets.js +3 -3
  14. package/src/core/breaking-check.js +6 -6
  15. package/src/core/breaking.js +2 -2
  16. package/src/core/copy/coderabbit.js +1 -1
  17. package/src/core/copy/gitignore.js +3 -3
  18. package/src/core/copy/readme.js +2 -3
  19. package/src/core/copy/simple.js +4 -2
  20. package/src/core/copy/util.js +2 -2
  21. package/src/core/copy/workflows.js +9 -13
  22. package/src/core/detect-fs.js +6 -6
  23. package/src/core/detect.js +3 -3
  24. package/src/core/exclusions.js +1 -1
  25. package/src/core/fsutil.js +2 -4
  26. package/src/core/ide/adapters/claude.js +3 -3
  27. package/src/core/ide/adapters/codex.js +3 -3
  28. package/src/core/ide/adapters/cursor.js +3 -3
  29. package/src/core/ide/adapters/gemini.js +4 -3
  30. package/src/core/ide/adapters/pi-common.js +8 -7
  31. package/src/core/ide/adapters/pi-harness.js +4 -4
  32. package/src/core/ide/adapters/pi.js +1 -1
  33. package/src/core/ide/runner.js +2 -2
  34. package/src/core/options-ask.js +7 -9
  35. package/src/core/options-schema.js +5 -5
  36. package/src/core/paths-resolve.js +27 -28
  37. package/src/core/paths.js +7 -3
  38. package/src/core/resolve-options.js +14 -0
  39. package/src/core/run-trace.js +1 -1
  40. package/src/core/version-yml.js +44 -25
  41. package/src/core/wizard-env.js +11 -12
  42. package/src/core/wizard-labels.js +15 -17
  43. package/src/i18n/ide.en.js +1 -0
  44. package/src/i18n/ide.ko.js +1 -0
  45. package/src/index.js +8 -8
  46. package/src/ui/banner.js +2 -1
  47. package/src/ui/env-plan.js +22 -25
  48. package/src/ui/prompts.js +1 -1
  49. package/src/ui/readline-engine.js +1 -1
  50. package/src/ui/skills-prompts.js +1 -1
  51. package/src/ui/summary.js +3 -3
@@ -1,5 +1,5 @@
1
- // package.json 내용 분류 (.sh classify_package_json 등가) — 원본 파일 텍스트에 대한
2
- // grep 부분문자열 매칭. dependencies 파싱이 아니라 raw 텍스트 검사여야 등가.
1
+ // package.json 내용 분류 — 원본 파일 텍스트에 대한 부분문자열 매칭.
2
+ // dependencies 파싱이 아니라 raw 텍스트 검사 (깨진 JSON에도 동작).
3
3
  // 입력은 package.json의 원문 문자열(raw). 순서 중요.
4
4
  export function classifyPackageText(raw) {
5
5
  const s = String(raw || "");
@@ -61,7 +61,7 @@ export function extraMarkers(type) {
61
61
  return { python: ["setup.py", "requirements.txt"], spring: ["build.gradle.kts", "pom.xml"] }[type] || [];
62
62
  }
63
63
 
64
- // 확장자 빈도 스캔 추천 (.sh suggest_types_by_scan 2단계 등가, #458 npx 이식).
64
+ // 확장자 빈도 스캔 추천 (마커 없을 때 2단계, #458).
65
65
  // 마커가 전혀 없을 때(=basic 폴백)만 쓰는 2차 추천. files = 프루닝된 상대경로 목록.
66
66
  // 임계: dart≥1→flutter, java+kt+gradle≥3→spring, tsx+jsx≥3→react, py≥3→python,
67
67
  // 아무것도 없고 ts+js≥3→node. 반환은 메뉴 정의 순서로 정렬.
@@ -1,7 +1,7 @@
1
1
  // 템플릿 다운로드 후 제거하는 항목 (복사 제외 목록).
2
2
  // ⚠️ CLAUDE.md "템플릿 전용 파일 추가" 규칙의 동기화 지점:
3
3
  // template_initializer.py(삭제) / 이 파일(복사 제외)
4
- // (구 template_integrator.sh/.ps1의 배열은 #458 EOF로 소멸 — 여기가 유일한 복사 제외 지점)
4
+ // (여기가 유일한 복사 제외 지점)
5
5
  export const DOCS_TO_REMOVE = [
6
6
  "CONTRIBUTING.md",
7
7
  "SECURITY.md",
@@ -31,10 +31,8 @@ export function remove(p) {
31
31
  }
32
32
 
33
33
  // 디렉토리 직하위 .yaml/.yml 파일명 목록. 하위 폴더 제외.
34
- // 정렬 순서는 .sh의 glob `"$_dir"/*.yaml "$_dir"/*.yml` 와 일치시킨다:
35
- // 확장자로 1차 그룹(.yaml 먼저 → .yml 나중), 각 그룹 안에서 알파벳순.
36
- // (단순 .sort()는 확장자를 섞어 정렬해 .sh와 파일 순회 순서가 갈리고,
37
- // 그 결과 version.yml deploy 블록의 키 순서까지 달라진다. 확장자 그룹핑으로 바이트 등가 확보.)
34
+ // 정렬: 확장자로 1차 그룹(.yaml 먼저 → .yml 나중), 각 그룹 안에서 알파벳순.
35
+ // (순회 순서가 version.yml deploy 블록의 키 순서를 정함 — 기존 설치본과 바이트 동일 유지)
38
36
  export function listYamlFiles(dir) {
39
37
  if (!existsSync(dir)) return [];
40
38
  const names = readdirSync(dir, { withFileTypes: true })
@@ -1,13 +1,13 @@
1
- // Claude Code 어댑터 (.sh _manage_claude_section / _do_claude_plugin_install /
2
- // _remove_claude_section / _remove_claude_plugin_data 등가).
1
+ // Claude Code 어댑터 (플러그인 설치·제거, 플러그인 데이터 정리).
3
2
  // 마켓플레이스 Cassiiopeia/projectops, 플러그인 projectops@projectops-marketplace.
4
3
  import { join } from "node:path";
5
4
  import { existsSync, readdirSync, mkdirSync, cpSync, rmSync } from "node:fs";
6
5
  import { compareCacheName } from "../util.js";
7
6
  import { migrateConfigRoot } from "../legacy.js";
8
7
  import { t } from "../../../i18n/index.js";
8
+ import { TEMPLATE_REPO_SLUG } from "../../paths.js";
9
9
 
10
- const MARKETPLACE = "Cassiiopeia/projectops";
10
+ const MARKETPLACE = TEMPLATE_REPO_SLUG;
11
11
  const PLUGIN = "projectops@projectops-marketplace";
12
12
  const LEGACY_PLUGINS = ["cassiiopeia@cassiiopeia-marketplace", "cassiiopeia"];
13
13
  const LEGACY_MARKETPLACES = ["cassiiopeia-marketplace"];
@@ -1,12 +1,12 @@
1
- // Codex CLI 어댑터 (.sh _manage_codex_skills / _do_codex_marketplace_register /
2
- // _remove_codex_section 등가).
1
+ // Codex CLI 어댑터 (skills 관리·마켓플레이스 등록·제거).
3
2
  // marketplace 등록/업그레이드가 주 경로. native ~/.agents/skills/projectops 심링크는 감지·제거에 사용.
4
3
  import { join } from "node:path";
5
4
  import { existsSync, lstatSync, rmSync } from "node:fs";
6
5
  import { migrateConfigRoot } from "../legacy.js";
7
6
  import { t } from "../../../i18n/index.js";
7
+ import { TEMPLATE_REPO_SLUG } from "../../paths.js";
8
8
 
9
- const MARKETPLACE = "Cassiiopeia/projectops";
9
+ const MARKETPLACE = TEMPLATE_REPO_SLUG;
10
10
  const PLUGIN = "projectops";
11
11
  const LEGACY_NATIVES = ["SUH-DEVOPS-TEMPLATE"];
12
12
  const LEGACY_MARKETPLACES = ["SUH-DEVOPS-TEMPLATE"];
@@ -1,10 +1,10 @@
1
- // Cursor 어댑터 (.sh _manage_cursor_section / _do_cursor_skills_copy /
2
- // _write_cursor_skills_meta / _remove_cursor_section 등가).
1
+ // Cursor 어댑터 (skills 복사·메타 기록·제거).
3
2
  // 마켓플레이스 없음 → ~/.cursor/skills/ 에 skills/ 폴더를 복사하고 meta.json으로 버전 추적.
4
3
  import { join } from "node:path";
5
4
  import { existsSync, readFileSync, mkdirSync, cpSync, rmSync, writeFileSync, readdirSync } from "node:fs";
6
5
  import { migrateConfigRoot, isLegacyVersion } from "../legacy.js";
7
6
  import { t } from "../../../i18n/index.js";
7
+ import { TEMPLATE_REPO_WEB_URL } from "../../paths.js";
8
8
 
9
9
  const LEGACY_NAMES = ["cassiiopeia", "suh-devops-template"];
10
10
  const LEGACY_MAX_VERSION = "4.2.4";
@@ -127,7 +127,7 @@ function writeMeta(io, destDir, templateVersion) {
127
127
  if (existsSync(file)) { try { installedAt = JSON.parse(readFileSync(file, "utf8")).installedAt || now; } catch { /* 무해 */ } }
128
128
  const meta = {
129
129
  name: "projectops", version, scope: "user",
130
- source: "https://github.com/Cassiiopeia/projectops",
130
+ source: TEMPLATE_REPO_WEB_URL,
131
131
  installPath: destDir, installedAt, lastUpdated: now,
132
132
  };
133
133
  mkdirSync(destDir, { recursive: true });
@@ -1,15 +1,16 @@
1
- // Gemini CLI 어댑터 (.sh _manage_gemini_extension / _remove_gemini_section 등가).
1
+ // Gemini CLI 어댑터 (확장 설치·제거).
2
2
  // extension install/update/uninstall. 마켓플레이스 대신 git URL 확장.
3
3
  import { migrateConfigRoot } from "../legacy.js";
4
4
  import { t } from "../../../i18n/index.js";
5
+ import { TEMPLATE_REPO_WEB_URL } from "../../paths.js";
5
6
 
6
7
  const EXT = "projectops";
7
- const URL = "https://github.com/Cassiiopeia/projectops";
8
+ const URL = TEMPLATE_REPO_WEB_URL;
8
9
  const LEGACY_EXTS = ["SUH-DEVOPS-TEMPLATE"];
9
10
 
10
11
  function detect(io) {
11
12
  if (!io.which("gemini")) return { installed: false, version: null, cliMissing: true, note: t("ide.cliMissing") };
12
- // .sh는 gemini 설치 상태를 정밀 조회하지 않고 "설치 가능"으로만 표기 → installed 미상.
13
+ // gemini 설치 상태는 정밀 조회하지 않고 "설치 가능"으로만 표기 → installed 미상.
13
14
  return { installed: false, version: null, cliMissing: false, note: t("ide.installable") };
14
15
  }
15
16
 
@@ -1,11 +1,10 @@
1
1
  // PI 공용 경로·상태 로직 (pi 어댑터와 pi-harness 어댑터가 공유).
2
- // .sh: PI_PACKAGE_URL / _pi_is_installed / _pi_clone_dir / _pi_harness_loader_path /
3
- // _pi_settings_path / _pi_harness_enabled 등가.
4
2
  import { join } from "node:path";
5
3
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
6
4
  import { t } from "../../../i18n/index.js";
5
+ import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_WEB_URL } from "../../paths.js";
7
6
 
8
- export const PI_PACKAGE_URL = "https://github.com/Cassiiopeia/projectops";
7
+ export const PI_PACKAGE_URL = TEMPLATE_REPO_WEB_URL;
9
8
 
10
9
  // 'pi list' 출력에 우리 레포가 잡히면 설치됨. (구 레포명·프로젝트명 모두 허용)
11
10
  export function piInstalled(io) {
@@ -17,7 +16,7 @@ export function piInstalled(io) {
17
16
 
18
17
  // pi 클론 경로(harness loader가 사는 곳). 레포명 변경 전 구 경로 하위호환.
19
18
  export function piCloneDir(io) {
20
- const base = join(io.home(), ".pi/agent/git/github.com/Cassiiopeia");
19
+ const base = join(io.home(), ".pi/agent/git/github.com", TEMPLATE_REPO_OWNER);
21
20
  const newDir = join(base, "projectops");
22
21
  const oldDir = join(base, "SUH-DEVOPS-TEMPLATE");
23
22
  return (!existsSync(newDir) && existsSync(oldDir)) ? oldDir : newDir;
@@ -41,8 +40,10 @@ export function harnessAdd(io) {
41
40
  const settings = piSettingsPath(io), loader = harnessLoaderPath(io);
42
41
  if (!existsSync(settings)) return { ok: false, reason: "no-settings" };
43
42
  if (!existsSync(loader)) return { ok: false, reason: "no-loader" };
44
- let s = {};
45
- try { s = JSON.parse(readFileSync(settings, "utf8")); } catch { s = {}; }
43
+ let s;
44
+ // 해석 실패 시 덮어쓰지 않음 ({} 로 쓰면 사용자 설정 키가 전부 지워진다)
45
+ try { s = JSON.parse(readFileSync(settings, "utf8")); } catch { return { ok: false, reason: "invalid-settings" }; }
46
+ if (s === null || typeof s !== "object" || Array.isArray(s)) return { ok: false, reason: "invalid-settings" };
46
47
  const exts = (Array.isArray(s.extensions) ? s.extensions : []).filter(Boolean);
47
48
  if (!exts.includes(loader)) exts.push(loader);
48
49
  s.extensions = exts;
@@ -66,7 +67,7 @@ export function harnessRemove(io) {
66
67
  // 신·구 clone 공존 시 옛 SUH-DEVOPS-TEMPLATE clone 제거 + settings.json에서 그 loader 경로 제거.
67
68
  // 공존일 때만 정리(옛것만 있으면 pi install이 신규를 만들 때까지 그대로 둔다). 실패 무해.
68
69
  export function migratePiLegacy(io) {
69
- const base = join(io.home(), ".pi/agent/git/github.com/Cassiiopeia");
70
+ const base = join(io.home(), ".pi/agent/git/github.com", TEMPLATE_REPO_OWNER);
70
71
  const oldDir = join(base, "SUH-DEVOPS-TEMPLATE");
71
72
  const newDir = join(base, "projectops");
72
73
  if (!(existsSync(oldDir) && existsSync(newDir))) return;
@@ -1,8 +1,7 @@
1
- // PI Persona Harness 어댑터 (.sh _pi_harness_toggle / _pi_harness_remove_only /
2
- // _pi_harness_add / _pi_harness_remove / _pi_harness_enabled 등가).
1
+ // PI Persona Harness 어댑터 (켜기·끄기·상태 조회).
3
2
  // PI skill과 독립 — skill은 그대로 두고 harness 등록(settings.json extensions)만 켜고/끈다.
4
3
  import { existsSync } from "node:fs";
5
- import { harnessEnabled, harnessAdd, harnessRemove, harnessLoaderPath } from "./pi-common.js";
4
+ import { harnessEnabled, harnessAdd, harnessRemove, harnessLoaderPath, piSettingsPath } from "./pi-common.js";
6
5
  import { t } from "../../../i18n/index.js";
7
6
 
8
7
  // 이 어댑터는 "설치 가능" 여부가 pi 존재 + loader 파일 존재에 달림.
@@ -20,7 +19,8 @@ function apply(io) {
20
19
  if (harnessEnabled(io)) { io.log(t("ide.harness.already")); return true; }
21
20
  const r = harnessAdd(io);
22
21
  if (r.ok) { io.log(t("ide.harness.enabledDone")); return true; }
23
- io.log(t(r.reason === "no-settings" ? "ide.harness.noSettings" : "ide.harness.needPackage"));
22
+ const messageKeyByReason = { "no-settings": "ide.harness.noSettings", "invalid-settings": "ide.harness.invalidSettings" };
23
+ io.log(t(messageKeyByReason[r.reason] ?? "ide.harness.needPackage", { file: piSettingsPath(io) }));
24
24
  return false;
25
25
  }
26
26
 
@@ -1,4 +1,4 @@
1
- // PI 패키지 어댑터 (.sh _manage_pi_section / _remove_pi_section 등가).
1
+ // PI 패키지 어댑터 (설치·제거).
2
2
  // pi install/update/remove. skill은 복사되지 않고 pi가 startup마다 스캔 → 설치 검증은 'pi list'.
3
3
  import { PI_PACKAGE_URL, piInstalled, harnessEnabled, harnessRemove, migratePiLegacy } from "./pi-common.js";
4
4
  import { migrateConfigRoot } from "../legacy.js";
@@ -1,4 +1,4 @@
1
- // 외부 CLI 실행·감지 래퍼 (.sh command -v / CLI 호출 등가).
1
+ // 외부 CLI 실행·감지 래퍼.
2
2
  // 전부 주입 가능 — 테스트는 stub runner/which/home 를 넘겨 실제 CLI 없이 검증한다.
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { delimiter, join } from "node:path";
@@ -54,6 +54,6 @@ export function defaultIo() {
54
54
  which,
55
55
  run,
56
56
  home: () => homedir(),
57
- log: (msg) => console.error(msg), // .sh는 안내를 stderr로 출력
57
+ log: (msg) => console.error(msg), // 안내는 stderr로 (stdout 파이프 오염 방지)
58
58
  };
59
59
  }
@@ -1,5 +1,4 @@
1
- // 배포/publish 축(#439) + Secret 백업 opt-in 질문 (.sh ask_deploy_publish /
2
- // ask_optional_workflow / ask_all_optional_workflows 등가).
1
+ // 배포/publish 축(#439) + Secret 백업 opt-in 질문.
3
2
  //
4
3
  // io 주입 계약(readline-engine 시그니처):
5
4
  // io.confirm({message, initialValue}) → bool | CANCEL(symbol)
@@ -110,7 +109,7 @@ export function migrateProvider(saved) {
110
109
 
111
110
  const isCancel = (v) => typeof v === "symbol";
112
111
 
113
- // Secret 백업 등 폴더 기반 opt-in 1종 질문 (.sh ask_optional_workflow 등가).
112
+ // Secret 백업 등 폴더 기반 opt-in 1종 질문.
114
113
  // 반환: true/false/null(폴더 없음·파일 0개로 질문 자체 생략 → 현재값 유지).
115
114
  async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty, io, forceAsk, say }) {
116
115
  // 폴더가 없거나 yaml이 0개면 조용히 건너뜀 — 질문 자체가 성립 안 함
@@ -148,7 +147,7 @@ export const OPTION_AXES = ["intent", "deploy", "publish", "code-review", "chang
148
147
  const INTENT_ASKS_DEPLOY = { app: true, both: true, manual: true, library: false, none: false };
149
148
  const INTENT_ASKS_PUBLISH = { library: true, both: true, manual: true, app: false, none: false };
150
149
 
151
- // 배포/publish 축 + Secret 백업을 순서대로 질문 (.sh ask_all_optional_workflows 등가).
150
+ // 배포/publish 축 + Secret 백업을 순서대로 질문.
152
151
  // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
153
152
  // current: { deploy: string|null, publish: string[]|null, secretBackup: bool|null } — CLI 명시값
154
153
  // scope: null이면 전 축(초기 통합·전체 재질문). Set/배열이면 그 축만 forceAsk 대상 (#483 수정 메뉴 격리).
@@ -204,7 +203,7 @@ export async function askAllOptionalWorkflows({
204
203
  if (aiPrSummary === null && saved.aiPrSummary != null) aiPrSummary = saved.aiPrSummary;
205
204
  if (changelogProvider === null && saved.changelogProvider !== null) changelogProvider = saved.changelogProvider;
206
205
  if (changelogBaseUrl === null && saved.changelogBaseUrl !== null) changelogBaseUrl = saved.changelogBaseUrl;
207
- // #456 deploy_branch 저장값 재사용
206
+ // #456 개발 브랜치 저장값 재사용
208
207
  if (deployBranch === null && saved.deployBranch != null) deployBranch = saved.deployBranch;
209
208
  // #485 intent 저장값 재사용 — 없으면 저장된 deploy/publish에서 역추론(구 version.yml 하위호환)
210
209
  if (intent === null) intent = saved.intent ?? inferIntent(saved.deploy, saved.publish);
@@ -332,9 +331,8 @@ export async function askAllOptionalWorkflows({
332
331
  }
333
332
 
334
333
  // ── pr_comment: PR에 달 리뷰·요약 (#566에서 통합) ──
335
- // 종전에는 CodeRabbit 여부만 예/아니오로 물었고, AI 변경 요약은 물어보지도 않은 채
336
- // 항상 복사된 뒤 런타임에 스스로 빠졌다. 그 결과 "CodeRabbit도 안 달고 요약도 안 다는"
337
- // 저장소가 생겼다. 둘은 같은 자리(PR 댓글)를 놓고 경쟁하므로 한 번에 고르게 한다.
334
+ // CodeRabbit 과 AI 변경 요약은 같은 자리(PR 댓글)를 놓고 경쟁하므로 한 번에 고르게 한다
335
+ // (따로 물으면 둘 다 안 다는 저장소가 생긴다).
338
336
  if (ask("code-review") || codeReviewCoderabbit === null || aiPrSummary === null) {
339
337
  if (force || !tty || typeof io.select !== "function") {
340
338
  // 비대화형 기본값: CodeRabbit은 별도 앱 설치가 있어야 실제로 동작하므로
@@ -392,7 +390,7 @@ export async function askAllOptionalWorkflows({
392
390
  changelogBaseUrl = "";
393
391
  }
394
392
 
395
- // ── 릴리스 소스(개발) 브랜치 (#456 필드 deploy_branch — 이름과 달리 "개발 브랜치"다, #482) ──
393
+ // ── 릴리스 소스(개발) 브랜치 (#456, version.yml development_branch — #915) ──
396
394
  // 이 값은 릴리스 PR(개발→기본)의 head, 즉 개발한 걸 모아 기본 브랜치로 올리는 브랜치다.
397
395
  // "배포 브랜치"가 아니다 — 배포가 도는 곳은 기본 브랜치(default) 쪽 개념. #482 참조.
398
396
  if (ask("release-branch") || deployBranch === null) {
@@ -47,7 +47,7 @@ export const FLAGS = [
47
47
  { flag: "--publish", value: "CSV", values: [...PUBLISH_TARGETS], default: "none",
48
48
  description: "Registries a library is published to. Several allowed.", versionYml: "metadata.template.options.publish" },
49
49
  { flag: "--deploy-branch", value: "NAME", default: "develop",
50
- description: "Development branch: the head of the release PR. Not the repository default branch.", versionYml: "metadata.deploy_branch" },
50
+ description: "Development branch: the head of the release PR. Not the repository default branch.", versionYml: "metadata.development_branch" },
51
51
  { flag: "--language", value: "LANG", values: [...REPO_LANGUAGES], default: "en for new installs, ko for existing installs",
52
52
  description: "Language of issue/PR templates and bot messages written into the repo.", versionYml: "metadata.template.options.language" },
53
53
  { flag: "--label-style", value: "STYLE", values: [...LABEL_STYLES], default: "en for new installs, ko for existing installs",
@@ -84,8 +84,8 @@ export const VERSION_YML = [
84
84
  { path: "project_types", type: "string[]", description: "Project types; the first is primary. Do not change after setup.", edit: "wizard" },
85
85
  { path: "project_paths", type: "map", description: "Per-type project folder relative to the repo root (monorepo).", edit: "wizard (--paths)" },
86
86
  { path: "metadata.default_branch", type: "string", default: "main", description: "Repository default (production) branch. Pushes here deploy.", edit: "wizard" },
87
- { path: "metadata.deploy_branch", parserKey: "deployBranch", type: "string", default: "develop",
88
- description: "Development branch: head of the release PR. Despite the name it is NOT where deploys run.", edit: "wizard (--deploy-branch)" },
87
+ { path: "metadata.development_branch", parserKey: "deployBranch", type: "string", default: "develop",
88
+ description: "Development branch: head of the release PR. Old name deploy_branch is still read and written with the same value.", edit: "wizard (--deploy-branch)" },
89
89
  { path: "metadata.template.options.intent", parserKey: "intent", type: "enum", values: [...INTENT_VALUES],
90
90
  description: "Project kind; derives the deploy and publish questions.", edit: "wizard (--intent)" },
91
91
  { path: "metadata.template.options.deploy", parserKey: "deploy", type: "enum", values: [...DEPLOY_TARGETS], default: "docker-ssh",
@@ -183,7 +183,7 @@ export const FLAG_DETAILS = {
183
183
  },
184
184
  "--deploy-branch": {
185
185
  when: "Only if your development branch is not named develop.",
186
- effect: "Writes metadata.deploy_branch and rewrites the branch name inside the installed workflows (release PR head, CI triggers, PR summary base). The branch is NOT created by this flag.",
186
+ effect: "Writes metadata.development_branch (and the old deploy_branch with the same value) and rewrites the branch name inside the installed workflows (release PR head, CI triggers, PR summary base). The branch is NOT created by this flag.",
187
187
  omitted: "develop.",
188
188
  example: "npx projectops --mode full --force --deploy-branch dev",
189
189
  caution: "Despite the name this is the development branch (head of the release PR), not the branch that deploys. The deploying branch is the repository default branch.",
@@ -262,7 +262,7 @@ export const KEY_DETAILS = {
262
262
  "project_types": { when: "Set by the wizard. Do not change after setup.", effect: "Decides which type workflows run and which build files are synced. The first entry is primary.", omitted: "Detected at install." },
263
263
  "project_paths": { when: "Monorepos only.", effect: "Maps a type to its subfolder so version sync and workflow path filters look there.", omitted: "Every type at the repo root." },
264
264
  "metadata.default_branch": { when: "Do not edit unless the repository default branch was renamed.", effect: "The branch whose pushes deploy and publish. Workflows trigger on it.", omitted: "main" },
265
- "metadata.deploy_branch": { when: "Only if the development branch is not develop.", effect: "Head of the release PR; workflows rewrite their develop references to this name.", omitted: "develop", caution: "Not the deploying branch, despite the name." },
265
+ "metadata.development_branch": { when: "Only if the development branch is not develop.", effect: "Head of the release PR; workflows rewrite their develop references to this name.", omitted: "develop", caution: "Old name deploy_branch is read when this key is missing. The wizard writes both with the same value; keep them equal if you edit by hand." },
266
266
  "metadata.template.options.intent": { when: "Leave as is.", effect: "Remembers the project kind so a re-run does not ask the deploy questions again.", omitted: "Inferred from deploy and publish." },
267
267
  "metadata.template.options.deploy": { when: "Edit via --deploy, not by hand.", effect: "Which deploy workflow family is installed on the next update (docker-ssh, vercel, none).", omitted: "docker-ssh for server stacks." },
268
268
  "metadata.template.options.publish": { when: "Edit via --publish.", effect: "Which registry publish workflows are installed on the next update.", omitted: "[]" },
@@ -1,5 +1,4 @@
1
- // 타입별 프로젝트 경로 감지·확정 (.sh find_type_path_candidates L1249~1311 /
2
- // resolve_project_paths L1362~1589 등가). 모노레포에서 각 타입의 버전 파일이
1
+ // 타입별 프로젝트 경로 감지·확정. 모노레포에서 각 타입의 버전 파일이
3
2
  // 어느 폴더에 있는지 5단계 우선순위로 확정한다.
4
3
  //
5
4
  // io 주입 계약(readline-engine 시그니처 그대로):
@@ -16,8 +15,8 @@ import { normalizePath } from "../cli/args.js";
16
15
  // 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
17
16
  const isCancel = (v) => typeof v === "symbol";
18
17
 
19
- // 타입의 대표 마커 파일명 (.sh marker_for_type L1220~1229 등가).
20
- // detect.js는 미지 타입에 package.json을 기본 반환하지만 .sh는 빈 문자열 — 등가를 위해 래핑.
18
+ // 타입의 대표 마커 파일명.
19
+ // detect.js는 미지 타입에 package.json을 기본 반환 — 여기서는 빈 문자열이 되도록 래핑.
21
20
  const KNOWN_MARKER_TYPES = new Set([
22
21
  "flutter", "react", "node", "react-native", "react-native-expo", "python", "spring",
23
22
  ]);
@@ -26,7 +25,7 @@ export function markerForType(type) {
26
25
  }
27
26
 
28
27
  // 디렉토리에 실재하는 마커 파일명 반환 — 보조 마커 포함, 없으면 대표 마커 (표시용).
29
- // (.sh existing_marker_in_dir L1232~1245: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
28
+ // (spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
30
29
  export function existingMarkerInDir(type, dir) {
31
30
  const primary = markerForType(type);
32
31
  const names = primary ? [primary, ...extraMarkers(type)] : [];
@@ -47,7 +46,7 @@ function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
47
46
  const childDepth = depth + 1;
48
47
  const childRel = rel ? `${rel}/${e.name}` : e.name;
49
48
  if (e.isDirectory()) {
50
- // prune 폴더는 하위 전체 제외 (.sh find -prune 등가)
49
+ // prune 폴더는 하위 전체 제외
51
50
  if (prune.has(e.name)) continue;
52
51
  // 자식 파일이 depth ≤ maxDepth 안에 들어올 때만 하강
53
52
  if (childDepth < maxDepth) walk(childRel, childDepth);
@@ -59,13 +58,13 @@ function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
59
58
  }
60
59
  };
61
60
  walk("", 0);
62
- return [...new Set(hits)].sort(); // sort -u 등가
61
+ return [...new Set(hits)].sort(); // 중복 제거 + 정렬
63
62
  }
64
63
 
65
- // 타입별 마커 파일 후보 검색 (.sh find_type_path_candidates L1249~1311 등가).
64
+ // 타입별 마커 파일 후보 검색.
66
65
  // 반환: 후보 디렉토리 상대경로 배열 (루트는 ".").
67
66
  export function findTypePathCandidates(root, type) {
68
- // ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 (.sh L1255~1268) ──
67
+ // ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 ──
69
68
  // version_manager가 그 폴더 아래 build.gradle 전부를 갱신하므로 하위 모듈을 펼치지 않는다.
70
69
  // android/ 의 settings.gradle(Flutter/RN)은 spring이 아니므로 prune.
71
70
  if (type === "spring") {
@@ -92,7 +91,7 @@ export function findTypePathCandidates(root, type) {
92
91
  "node_modules", ".git", "build", "dist", ".dart_tool", "android", "ios",
93
92
  ".gradle", "venv", ".venv", "__pycache__",
94
93
  ]);
95
- // 우선순위 높은 마커에서 발견되면 그것만 사용 (.sh L1281~1288)
94
+ // 우선순위 높은 마커에서 발견되면 그것만 사용
96
95
  let found = [];
97
96
  for (const n of names) {
98
97
  found = walkFindDirs(root, { prune, match: (name) => name === n });
@@ -101,13 +100,13 @@ export function findTypePathCandidates(root, type) {
101
100
 
102
101
  return found.filter((d) => {
103
102
  if (type === "flutter") {
104
- // example/ 제외 + lib/ 동반 확인 — 오탐 방지 (.sh L1298~1303)
103
+ // example/ 제외 + lib/ 동반 확인 — 오탐 방지
105
104
  if (d.includes("example")) return false;
106
105
  const libDir = d === "." ? join(root, "lib") : join(root, d, "lib");
107
106
  if (!existsSync(libDir)) return false;
108
107
  }
109
108
  if (type === "spring") {
110
- // Flutter/RN의 android/build.gradle 오탐 제외 (.sh L1304~1307)
109
+ // Flutter/RN의 android/build.gradle 오탐 제외
111
110
  if (d.includes("android")) return false;
112
111
  }
113
112
  return true;
@@ -115,7 +114,7 @@ export function findTypePathCandidates(root, type) {
115
114
  }
116
115
 
117
116
  // 선택된 모든 타입의 경로를 감지·확인하여 Map<type,path> 확정
118
- // (.sh resolve_project_paths L1362~1589 등가 — 5단계 우선순위).
117
+ // (5단계 우선순위).
119
118
  // ① paths에 이미 있음(--paths) → 유지
120
119
  // ② 루트에 마커 존재 → "." 자동
121
120
  // ③ existingPaths(version.yml 저장값)
@@ -127,11 +126,11 @@ export async function resolveProjectPaths({
127
126
  }) {
128
127
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
129
128
  const result = new Map(paths); // --paths 사전값 유지 (호출부 Map은 불변)
130
- const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요 (.sh L1400)
129
+ const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요
131
130
  if (targets.length === 0) return result;
132
131
 
133
132
  const total = targets.length;
134
- // ── 도입부 안내 (.sh L1407~1434 — 감지 결과 + 무엇을 할지 설명) ──
133
+ // ── 도입부 안내 (감지 결과 + 무엇을 할지 설명) ──
135
134
  say("");
136
135
  if (total > 1) say(msg("paths.multi", { total }));
137
136
  else say(msg("paths.single", { type: targets[0] }));
@@ -145,13 +144,13 @@ export async function resolveProjectPaths({
145
144
  idx += 1;
146
145
  const prog = `[${idx}/${total}]`;
147
146
 
148
- // ① --paths 등으로 이미 지정됨 → 최우선 (.sh L1441~1446)
147
+ // ① --paths 등으로 이미 지정됨 → 최우선
149
148
  if (result.get(t)) {
150
149
  say(msg("paths.byFlag", { type: t, path: result.get(t) }));
151
150
  continue;
152
151
  }
153
152
 
154
- // ② 루트에 마커 존재 → "." 자동 확정 (.sh L1449~1455, 보조 마커 포함)
153
+ // ② 루트에 마커 존재 → "." 자동 확정 (보조 마커 포함)
155
154
  const rootMarker = existingMarkerInDir(t, root);
156
155
  if (rootMarker && existsSync(join(root, rootMarker))) {
157
156
  result.set(t, ".");
@@ -159,14 +158,14 @@ export async function resolveProjectPaths({
159
158
  continue;
160
159
  }
161
160
 
162
- // ③ 기존 version.yml 저장값 → 기본 제안값 (.sh L1458~1466)
161
+ // ③ 기존 version.yml 저장값 → 기본 제안값
163
162
  const existing = existingPaths.get(t) || "";
164
163
 
165
- // ④ 후보 검색 (.sh L1469~1471)
164
+ // ④ 후보 검색
166
165
  const candidates = findTypePathCandidates(root, t);
167
166
  let chosen = "";
168
167
 
169
- // ── ⑤-a 비대화형 (--force 또는 TTY 없음, .sh L1476~1489) ──
168
+ // ── ⑤-a 비대화형 (--force 또는 TTY 없음) ──
170
169
  if (force || !tty) {
171
170
  if (existing) {
172
171
  chosen = existing;
@@ -182,7 +181,7 @@ export async function resolveProjectPaths({
182
181
  continue;
183
182
  }
184
183
 
185
- // ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) + 타입 탈출구 (#487) ──
184
+ // ── ⑤-b 대화형: 후보 개수별 분기 + 타입 탈출구 (#487) ──
186
185
  const EXCLUDE = "이 타입 제외"; // 센티넬 — 한국어 value로 노출 (기존 "직접 입력" 패턴)
187
186
  let excluded = false;
188
187
  if (candidates.length === 1) {
@@ -206,7 +205,7 @@ export async function resolveProjectPaths({
206
205
  } else if (candidates.length > 1) {
207
206
  say("");
208
207
  say(msg("paths.multiFound", { prog, type: t, count: candidates.length }));
209
- // 후보들 + '직접 입력'/'이 타입 제외' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
208
+ // 후보들 + '직접 입력'/'이 타입 제외' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지)
210
209
  const options = candidates.map((c) => ({
211
210
  value: c,
212
211
  label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
@@ -214,7 +213,7 @@ export async function resolveProjectPaths({
214
213
  options.push({ value: "직접 입력", label: msg("paths.manual") });
215
214
  options.push({ value: EXCLUDE, label: msg("paths.exclude", { type: t }) });
216
215
  const sel = await io.select({ message: msg("paths.selectPrompt", { type: t }), options });
217
- // ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
216
+ // ESC(취소)도 직접 입력으로 폴백
218
217
  if (sel === EXCLUDE) excluded = true;
219
218
  else if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
220
219
  } else {
@@ -222,7 +221,7 @@ export async function resolveProjectPaths({
222
221
  say(msg("paths.notFound", { prog, type: t }));
223
222
  }
224
223
 
225
- // ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) — 제외 탈출구 포함 (#487) ──
224
+ // ── 직접 입력 루프 (위에서 미확정 시) — 제외 탈출구 포함 (#487) ──
226
225
  while (!chosen && !excluded) {
227
226
  const hintMarker = existingMarkerInDir(t, root);
228
227
  let prompt = msg("paths.inputPrompt", { type: t, marker: hintMarker });
@@ -231,9 +230,9 @@ export async function resolveProjectPaths({
231
230
  let input = await io.text({ message: prompt, defaultValue: "" });
232
231
  if (isCancel(input) || input == null) input = ""; // ESC → 빈값 (아래 폴백)
233
232
  input = String(input).trim();
234
- // 빈값 → 기존값 또는 루트 (.sh L1541~1543) — normalizePath 전에 판정
233
+ // 빈값 → 기존값 또는 루트 — normalizePath 전에 판정
235
234
  input = input === "" ? (existing || ".") : normalizePath(input);
236
- // 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함, .sh L1544~1552)
235
+ // 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함)
237
236
  const m = existingMarkerInDir(t, input === "." ? root : join(root, input));
238
237
  if (m && existsSync(join(root, input === "." ? "" : input, m))) {
239
238
  chosen = input;
@@ -262,7 +261,7 @@ export async function resolveProjectPaths({
262
261
  say(` ✅ ${t} → ${chosen}`);
263
262
  }
264
263
 
265
- // ── 요약 + 같은 마커 파일 중복 경고 (.sh L1559~1587) ──
264
+ // ── 요약 + 같은 마커 파일 중복 경고 ──
266
265
  say("");
267
266
  say(msg("paths.summary"));
268
267
  const fileToTypes = new Map(); // 마커 파일 상대경로 → 그 파일을 쓰는 타입들
@@ -275,7 +274,7 @@ export async function resolveProjectPaths({
275
274
  }
276
275
  for (const [file, ts] of fileToTypes) {
277
276
  if (ts.length > 1) {
278
- // 멱등 동작이라 막지는 않고 경고만 (.sh L1577~1586)
277
+ // 멱등 동작이라 막지는 않고 경고만
279
278
  say(msg("paths.duplicate", { types: ts.join(" "), file }));
280
279
  say(msg("paths.duplicateNote"));
281
280
  }
package/src/core/paths.js CHANGED
@@ -1,4 +1,4 @@
1
- // 경로 상수 (.sh readonly 상수군 등가) — template_integrator.sh 113~137
1
+ // 경로 상수
2
2
  export const PATHS = {
3
3
  tempDir: ".template_download_temp",
4
4
  versionFile: "version.yml",
@@ -7,8 +7,12 @@ export const PATHS = {
7
7
  projectTypesDir: "project-types",
8
8
  };
9
9
 
10
- export const TEMPLATE_RAW_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main";
11
- export const TEMPLATE_REPO = "https://github.com/Cassiiopeia/projectops.git";
10
+ // 템플릿 레포 주소 정본: 소유자/이름 > 웹·git·raw 주소 (src/ 직접 기재는 no-repo-url-literal 규칙이 막는다)
11
+ export const TEMPLATE_REPO_OWNER = "Cassiiopeia";
12
+ export const TEMPLATE_REPO_SLUG = `${TEMPLATE_REPO_OWNER}/projectops`;
13
+ export const TEMPLATE_REPO_WEB_URL = `https://github.com/${TEMPLATE_REPO_SLUG}`;
14
+ export const TEMPLATE_RAW_URL = `https://raw.githubusercontent.com/${TEMPLATE_REPO_SLUG}/main`;
15
+ export const TEMPLATE_REPO = `${TEMPLATE_REPO_WEB_URL}.git`;
12
16
  export const WORKFLOW_PREFIX = "PROJECT";
13
17
  export const WORKFLOW_COMMON_PREFIX = "PROJECT-COMMON";
14
18
  export const WORKFLOW_TEMPLATE_INIT = "PROJECT-TEMPLATE-INITIALIZER.yaml";
@@ -89,3 +89,17 @@ export function resolveOptions({ flags = {}, existing = null, types = [], cwd =
89
89
  };
90
90
  return { values, sources };
91
91
  }
92
+
93
+ // version.yml options 로 기록되는 값 (resolveOptions 결과 이름 그대로)
94
+ // 진입점(full·version)이 각자 고르면 빠진 옵션이 생성기 기본값으로 되돌아간다 (#909: version 모드 ai_summary false > true)
95
+ const TEMPLATE_OPTION_FIELDS = [
96
+ "intent", "deployTarget", "publishTargets", "includeSecretBackup", "aiPrSummary", "projectsSync",
97
+ "changelogProvider", "changelogBaseUrl", "codeReviewCoderabbit", "semverAuto", "appRelease",
98
+ "excludedWorkflows", "storeLocales", "storeLocalesIos", "storeLocalesPlay", "closeOnRelease", "language", "labelStyle",
99
+ ];
100
+
101
+ /** 실행 context(resolveOptions 값이 펼쳐진 것) > buildVersionYml templateOptions */
102
+ export function toTemplateOptions(context, { mode, optionsDate }) {
103
+ const options = Object.fromEntries(TEMPLATE_OPTION_FIELDS.map((field) => [field, context[field]]));
104
+ return { ...options, templateVersion: context.templateVersion, mode, optionsDate };
105
+ }
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { writeText } from "./fsutil.js";
7
7
  import { getLang } from "../i18n/index.js";
8
8
 
9
- // 진단 로그 위치 (#561). docs/ 아래(추적 대상)에서 옮겼다 — 이 기록은 사람이 읽는 문서가
9
+ // 진단 로그 위치 (#561). 추적되는 docs/ 가 아닌 곳에 둔다 — 이 기록은 사람이 읽는 문서가
10
10
  // 아니라 Agent가 "지난 실행에서 무슨 일이 있었나"를 확인하는 자료다.
11
11
  // 폴더 안에 .gitignore를 함께 써서 폴더가 자기 규칙을 들고 다닌다(루트 .gitignore 무수정).
12
12
  // 형제인 .github/.projectops/baseline.json은 팀원 공유 자산이라 계속 추적된다.