projectops 4.2.15 → 4.2.16

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
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.2.14 (2026-07-12)
10
+ ## 최신 버전 : v4.2.15 (2026-07-13)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.2.15",
3
+ "version": "4.2.16",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -89,6 +89,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
89
89
  let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
90
90
  let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
91
91
  let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
92
+ let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
92
93
  let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
93
94
  const showOptional = mode === "full" || mode === "workflows";
94
95
  const realTty = process.stdout.isTTY === true;
@@ -123,6 +124,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
123
124
  changelogProvider = r.changelogProvider;
124
125
  changelogBaseUrl = r.changelogBaseUrl;
125
126
  deployBranch = r.deployBranch;
127
+ deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
126
128
  intent = r.intent;
127
129
  }
128
130
 
@@ -186,6 +188,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
186
188
  changelogProvider = r.changelogProvider;
187
189
  changelogBaseUrl = r.changelogBaseUrl;
188
190
  deployBranch = r.deployBranch;
191
+ deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
189
192
  intent = r.intent;
190
193
  }
191
194
  }
@@ -285,7 +288,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
285
288
 
286
289
  // 완료 요약 (.sh print_summary L5438)
287
290
  io.summary?.({
288
- mode, types, version, deployBranch,
291
+ mode, types, version, deployBranch, deployBranchReady,
289
292
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
290
293
  }, cwd);
291
294
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
@@ -18,6 +18,17 @@ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", reso
18
18
  writeFileSync(targetPath, out);
19
19
  }
20
20
 
21
+ // util 버전 동기화 워크플로우 (#491) — .github/util/ 모듈(version.json)이 있는 레포에서만 의미가 있다.
22
+ // util이 없는 레포(예: spring 단독)에 복사하면 트리거가 영원히 안 걸리는 no-op 오염이 된다.
23
+ const UTIL_VERSION_SYNC = "PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC.yml";
24
+
25
+ // 이 통합에서 util 모듈이 존재하게 되는가 — 대상에 이미 있거나(업데이트),
26
+ // 선택 타입의 util이 템플릿에 있어 곧 복사될 예정(runFull 6단계)이면 true.
27
+ function utilSyncApplies(tempDir, targetRoot, types) {
28
+ if (exists(join(targetRoot, ".github", "util"))) return true;
29
+ return types.some((t) => exists(join(tempDir, ".github", "util", t)));
30
+ }
31
+
21
32
  // 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
22
33
  function classify(srcDir, workflowsDir, envOpts) {
23
34
  const result = { newFiles: [], unchanged: [], changed: [] };
@@ -62,6 +73,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
62
73
  const commonDir = join(projectTypesDir, "common");
63
74
  if (exists(commonDir)) {
64
75
  for (const filename of listYamlFiles(commonDir)) {
76
+ // #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
77
+ if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) continue;
65
78
  const src = join(commonDir, filename);
66
79
  const dst = join(workflowsDir, filename);
67
80
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
@@ -16,34 +16,39 @@ import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
16
16
  // 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
17
17
  // 로컬에 없고 원격에도 없(거나 불명이)면 기본 브랜치에서 생성을 제안하고, 생성 후 push 여부도 묻는다.
18
18
  // git 미설치·비레포·질문 불가(io.confirm 없음)면 조용히 통과 — 마법사 진행을 막지 않는다.
19
+ // 반환 ready (#490 — 완료 요약이 "브랜치 만들어라" 재지시를 접을지 판단):
20
+ // true = 브랜치가 이미 있거나 이번 실행에서 생성함 → 요약에서 생성 안내 불필요
21
+ // false = 없는데 사용자가 거절/생성 실패 → 안내 유지
22
+ // null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
19
23
  export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
20
- if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false };
24
+ if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null };
21
25
  const st = branchStatus(targetRoot, deployBranch);
22
- if (!st.isRepo || st.local) return { created: false, pushed: false };
26
+ if (!st.isRepo) return { created: false, pushed: false, ready: null };
27
+ if (st.local) return { created: false, pushed: false, ready: true };
23
28
  if (st.remote === true) {
24
29
  say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
25
- return { created: false, pushed: false };
30
+ return { created: false, pushed: false, ready: true };
26
31
  }
27
32
  say(`⚠️ 개발(릴리스 소스) 브랜치 '${deployBranch}'가 없습니다 — 릴리스(${deployBranch}→${defaultBranch || "기본 브랜치"} PR)가 동작하려면 필요합니다.`);
28
33
  const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
29
34
  if (mk !== true) {
30
35
  say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
31
- return { created: false, pushed: false };
36
+ return { created: false, pushed: false, ready: false };
32
37
  }
33
38
  if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
34
39
  say(`⚠️ 브랜치 생성 실패 — 직접 실행해주세요: git branch ${deployBranch}${defaultBranch ? ` ${defaultBranch}` : ""}`);
35
- return { created: false, pushed: false };
40
+ return { created: false, pushed: false, ready: false };
36
41
  }
37
42
  say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
38
43
  // #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
39
44
  const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
40
- if (up !== true) return { created: true, pushed: false };
45
+ if (up !== true) return { created: true, pushed: false, ready: true };
41
46
  if (pushBranch(targetRoot, deployBranch)) {
42
47
  say(`✅ origin/${deployBranch} push 완료`);
43
- return { created: true, pushed: true };
48
+ return { created: true, pushed: true, ready: true };
44
49
  }
45
50
  say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
46
- return { created: true, pushed: false };
51
+ return { created: true, pushed: false, ready: true };
47
52
  }
48
53
 
49
54
  // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
@@ -115,6 +120,7 @@ export async function askAllOptionalWorkflows({
115
120
  let changelogProvider = current.changelogProvider ?? null;
116
121
  let changelogBaseUrl = current.changelogBaseUrl ?? null;
117
122
  let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
123
+ let deployBranchReady = null; // #490 — 이번 실행에서 브랜치 존재/생성이 확인됐는지 (null=확인 안 함)
118
124
  let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
119
125
 
120
126
  // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
@@ -328,7 +334,9 @@ export async function askAllOptionalWorkflows({
328
334
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
329
335
  say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
330
336
  // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
331
- await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
337
+ // #490 — 결과(ready)를 완료 요약에 전달해 이미 생성/확인한 브랜치를 재지시하지 않는다
338
+ const br = await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
339
+ deployBranchReady = br.ready;
332
340
  }
333
341
  }
334
342
 
@@ -349,6 +357,8 @@ export async function askAllOptionalWorkflows({
349
357
  changelogProvider: changelogProvider ?? "github-ai",
350
358
  changelogBaseUrl: changelogBaseUrl ?? "",
351
359
  deployBranch: deployBranch ?? "develop",
360
+ deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
361
+
352
362
  // #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
353
363
  intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
354
364
  };
@@ -41,6 +41,13 @@ export function resolveToken(name, type, resolvers = {}) {
41
41
  return typeof fn === "function" ? (fn(type) ?? "") : "";
42
42
  }
43
43
 
44
+ // 잔여 전역 토큰 치환 (.sh 3347~3351) — 파일 본문·수집값(#489)·카드 표시가 전부 같은 규칙을 쓴다.
45
+ // 파일에 실제 써지는 값과 version.yml deploy 블록 기억값이 어긋나지 않게 하는 단일 지점.
46
+ export function resolveGlobalTokens(s, repoName = "") {
47
+ if (typeof s !== "string" || (!s.includes("__PROJECT_NAME__") && !s.includes("__APP_ARTIFACT_NAME__"))) return s;
48
+ return s.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
49
+ }
50
+
44
51
  // 파일 전체 치환 (configure_workflow_env 등가).
45
52
  // content: 원본 워크플로우 텍스트. 반환: 치환된 텍스트.
46
53
  // opts:
@@ -70,16 +77,16 @@ export function substituteEnv(content, opts = {}) {
70
77
  if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
71
78
  else val = def;
72
79
  // ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
73
- if (collectAsks) collectAsks.set(p.key, val);
80
+ // #489 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
81
+ // version.yml deploy 블록이 설치본과 항상 바이트 일치하게 한다.
82
+ if (collectAsks) collectAsks.set(p.key, resolveGlobalTokens(val, repoName));
74
83
  }
75
84
  lines[i] = setEnvLine(lines[i], p.key, val);
76
85
  }
77
86
  let out = lines.join(usesCRLF ? "\r\n" : "\n");
78
87
 
79
88
  // 잔여 전역 토큰 (.sh 3347~3351)
80
- if (out.includes("__PROJECT_NAME__") || out.includes("__APP_ARTIFACT_NAME__")) {
81
- out = out.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
82
- }
89
+ out = resolveGlobalTokens(out, repoName);
83
90
 
84
91
  // paths-anchor (.sh 3353~3360): 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
85
92
  if (PATHS_ANCHOR_RE.test(out) && projectPath && projectPath !== ".") {
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { stdin, stderr } from "node:process";
9
9
  import { PATHS } from "../core/paths.js";
10
10
  import { exists, listYamlFiles } from "../core/fsutil.js";
11
- import { parseWizardLine, resolveToken } from "../core/wizard-env.js";
11
+ import { parseWizardLine, resolveToken, resolveGlobalTokens } from "../core/wizard-env.js";
12
12
  import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
13
13
  import * as engine from "./readline-engine.js";
14
14
 
@@ -39,7 +39,7 @@ export function scopeString(usages = []) {
39
39
  // 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
40
40
  // usages:Map<key,[{type,workflowName}]> }
41
41
  export function collectAsks(tempDir, types = [], opts = {}) {
42
- const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null } = opts;
42
+ const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null, repoName = "" } = opts;
43
43
  const baseDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
44
44
  const keys = [];
45
45
  const defaults = new Map();
@@ -64,9 +64,13 @@ export function collectAsks(tempDir, types = [], opts = {}) {
64
64
  const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
65
65
  if (!p || p.action !== "ask") continue;
66
66
  // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
67
- const typeDefault = p.arg.startsWith("@")
68
- ? resolveToken(p.arg.slice(1), type, resolvers)
69
- : p.arg;
67
+ // #489 __PROJECT_NAME__ 등 전역 토큰은 카드 표시 전에 미리 해석한다.
68
+ // 설치본(파일 복사 시 전역 치환) 동일한 값을 보여주지 않으면
69
+ // 사용자가 "리터럴이 그대로 박히나?" 오해하게 된다.
70
+ const typeDefault = resolveGlobalTokens(
71
+ p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg,
72
+ repoName,
73
+ );
70
74
  typeDefaults.set(`${type}|${p.key}`, typeDefault);
71
75
  if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
72
76
  const list = usages.get(p.key) || [];
@@ -140,7 +144,7 @@ export async function promptEnvPlan({
140
144
  deployTarget = "docker-ssh", publishTargets = [], targetRoot = ".", repoName = "", log = defaultLog,
141
145
  } = {}) {
142
146
  const prompts = loadWizardPrompts(targetRoot, tempDir);
143
- const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts });
147
+ const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts, repoName });
144
148
  const defaults = asks.defaults;
145
149
 
146
150
  // 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
package/src/ui/summary.js CHANGED
@@ -9,6 +9,7 @@ const SEPARATOR = "────────────────────
9
9
  export function printSummary(ctx, targetRoot = ".") {
10
10
  const { mode, types = [], version = "", counters = {} } = ctx || {};
11
11
  const deployBranchName = ctx?.deployBranch || "develop"; // #477 — 설정된 배포 브랜치명으로 안내
12
+ const deployBranchReady = ctx?.deployBranchReady === true; // #490 — 마법사가 이번 실행에서 존재/생성 확인함
12
13
  const err = (s = "") => process.stderr.write(`${s}\n`);
13
14
  // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
14
15
  const isTty = !!process.stderr.isTTY;
@@ -139,8 +140,13 @@ export function printSummary(ctx, targetRoot = ".") {
139
140
  err(" → Secret Name: _GITHUB_PAT_TOKEN");
140
141
  err(" → Scopes: repo, workflow");
141
142
  err("");
142
- err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
143
- err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
143
+ // #490 — 마법사가 브랜치를 직접 생성(또는 존재 확인)했으면 같은 작업을 재지시하지 않는다
144
+ if (deployBranchReady) {
145
+ err(` 2️⃣ ✅ ${deployBranchName} 브랜치 준비 완료 — 마법사가 확인·생성했습니다 (추가 작업 불필요)`);
146
+ } else {
147
+ err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
148
+ err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
149
+ }
144
150
  err("");
145
151
  err(" 3️⃣ CodeRabbit 활성화 (코드 리뷰를 켰다면)");
146
152
  err(" → https://coderabbit.ai 로그인 → GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");