projectops 4.2.14 → 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.13 (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.14",
3
+ "version": "4.2.16",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -11,7 +11,8 @@ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeRe
11
11
  import { parseExisting } from "../core/version-yml.js";
12
12
  import { runBreakingCheck } from "../core/breaking-check.js";
13
13
  import { runMigrations } from "../core/migrations/index.js";
14
- import { resolveProjectPaths } from "../core/paths-resolve.js";
14
+ import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
15
+ import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
15
16
  import { askAllOptionalWorkflows, OPTION_AXES } from "../core/options-ask.js";
16
17
  import { promptEnvPlan } from "../ui/env-plan.js";
17
18
  import { listWorkflowConflicts } from "../core/copy/workflows.js";
@@ -88,6 +89,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
88
89
  let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
89
90
  let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
90
91
  let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
92
+ let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
91
93
  let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
92
94
  const showOptional = mode === "full" || mode === "workflows";
93
95
  const realTty = process.stdout.isTTY === true;
@@ -122,6 +124,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
122
124
  changelogProvider = r.changelogProvider;
123
125
  changelogBaseUrl = r.changelogBaseUrl;
124
126
  deployBranch = r.deployBranch;
127
+ deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
125
128
  intent = r.intent;
126
129
  }
127
130
 
@@ -185,6 +188,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
185
188
  changelogProvider = r.changelogProvider;
186
189
  changelogBaseUrl = r.changelogBaseUrl;
187
190
  deployBranch = r.deployBranch;
191
+ deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
188
192
  intent = r.intent;
189
193
  }
190
194
  }
@@ -196,6 +200,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
196
200
  root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
197
201
  force: false, tty: realTty, io: io.engineIo ?? {},
198
202
  });
203
+ // 타입 탈출구 (#487) — 경로 단계에서 제외된 타입은 version.yml·복사·env 전 단계에서 뺀다
204
+ types = filterExcludedTypes(types, paths);
199
205
  } else {
200
206
  for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
201
207
  }
@@ -253,6 +259,24 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
253
259
  });
254
260
  }
255
261
 
262
+ // 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
263
+ if (mode === "full" || mode === "workflows") {
264
+ const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
265
+ if (orphans.length > 0) {
266
+ io.note?.(
267
+ orphans.map((o) => `• ${o.filename} (${o.type} 타입 — 현재 미선택)`).join("\n"),
268
+ `🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
269
+ );
270
+ const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
271
+ if (yes === true) {
272
+ const results = applyOrphanCleanup(cwd, orphans);
273
+ const ok = results.filter((r) => r.action === "bak");
274
+ const failed = results.filter((r) => r.action === "error");
275
+ io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
276
+ }
277
+ }
278
+ }
279
+
256
280
  let result = null;
257
281
  if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
258
282
  else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
@@ -264,7 +288,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
264
288
 
265
289
  // 완료 요약 (.sh print_summary L5438)
266
290
  io.summary?.({
267
- mode, types, version, deployBranch,
291
+ mode, types, version, deployBranch, deployBranchReady,
268
292
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
269
293
  }, cwd);
270
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
  };
@@ -0,0 +1,64 @@
1
+ // 고아 타입 워크플로우 감지·정리 (#487) — 타입 변경으로 선택에서 빠진 타입의
2
+ // 템플릿 워크플로우를 감지해 .bak 무해화한다 (레거시 마이그레이션과 동일 방식).
3
+ // 안전 원칙: 템플릿 인벤토리와 "정확한 파일명 일치"만 대상 — prefix 매칭 금지
4
+ // (사용자 커스텀 워크플로우 오살 방지). common/은 타입이 아니므로 순회에서 제외.
5
+ import { existsSync, renameSync, rmSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { PATHS } from "./paths.js";
8
+ import { exists, listYamlFiles } from "./fsutil.js";
9
+
10
+ // 템플릿의 project-types/<type>/ 인벤토리 — 직하위 + server-deploy/ + publish/*/ (copy 엔진과 동일 범위)
11
+ function typeInventory(projectTypesDir, type) {
12
+ const typeDir = join(projectTypesDir, type);
13
+ const dirs = [typeDir, join(typeDir, "server-deploy")];
14
+ const pubRoot = join(typeDir, "publish");
15
+ if (exists(pubRoot)) {
16
+ for (const e of readdirSync(pubRoot, { withFileTypes: true })) {
17
+ if (e.isDirectory()) dirs.push(join(pubRoot, e.name));
18
+ }
19
+ }
20
+ const files = new Set();
21
+ for (const d of dirs) {
22
+ if (!exists(d)) continue;
23
+ for (const f of listYamlFiles(d)) files.add(f);
24
+ }
25
+ return files;
26
+ }
27
+
28
+ // 선택 안 된 타입의 템플릿 워크플로우가 대상 레포에 실재하면 고아로 반환.
29
+ export function detectOrphanWorkflows({ tempDir, targetRoot = ".", selectedTypes = [] }) {
30
+ const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
31
+ if (!exists(projectTypesDir)) return [];
32
+ const selected = new Set(selectedTypes);
33
+ // 선택된 타입이 쓰는 파일명 집합 — 교차 방어 (파일명은 타입 prefix로 유일하지만 안전망)
34
+ const keep = new Set();
35
+ for (const t of selected) for (const f of typeInventory(projectTypesDir, t)) keep.add(f);
36
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
37
+ const orphans = [];
38
+ for (const e of readdirSync(projectTypesDir, { withFileTypes: true })) {
39
+ if (!e.isDirectory() || e.name === "common" || selected.has(e.name)) continue;
40
+ for (const f of typeInventory(projectTypesDir, e.name)) {
41
+ if (keep.has(f)) continue;
42
+ if (existsSync(join(workflowsDir, f))) orphans.push({ filename: f, type: e.name });
43
+ }
44
+ }
45
+ return orphans.sort((a, b) => a.filename.localeCompare(b.filename));
46
+ }
47
+
48
+ // .bak 무해화 실행기 — 부분 실패 허용 (migrations applySafeMigrations와 동일 원칙).
49
+ export function applyOrphanCleanup(targetRoot, orphans) {
50
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
51
+ const results = [];
52
+ for (const { filename } of orphans) {
53
+ try {
54
+ const src = join(workflowsDir, filename);
55
+ const bak = `${src}.bak`;
56
+ if (existsSync(bak)) rmSync(bak, { force: true }); // Windows rename은 대상 존재 시 실패
57
+ renameSync(src, bak);
58
+ results.push({ filename, action: "bak" });
59
+ } catch (err) {
60
+ results.push({ filename, action: "error", error: err.message });
61
+ }
62
+ }
63
+ return results;
64
+ }
@@ -181,7 +181,9 @@ export async function resolveProjectPaths({
181
181
  continue;
182
182
  }
183
183
 
184
- // ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) ──
184
+ // ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) + 타입 탈출구 (#487) ──
185
+ const EXCLUDE = "이 타입 제외"; // 센티넬 — 한국어 value로 노출 (기존 "직접 입력" 패턴)
186
+ let excluded = false;
185
187
  if (candidates.length === 1) {
186
188
  const cand = candidates[0];
187
189
  const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
@@ -189,31 +191,38 @@ export async function resolveProjectPaths({
189
191
  say("");
190
192
  say(` ${prog} 🔍 ${t} — ${candMarker} 발견`);
191
193
  say(` 위치: <레포루트>/${candFull}`);
192
- // '아니오'/취소 chosen 미설정 → 아래 직접입력 루프로
193
- const ok = await io.confirm({
194
- message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요? (${candFull} 기준 — 아니오 선택 시 직접 입력)`,
195
- initialValue: true,
194
+ const sel = await io.select({
195
+ message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요?`,
196
+ options: [
197
+ { value: cand, label: `예 — '${cand}' 사용 (${candFull} 기준)` },
198
+ { value: "직접 입력", label: "아니오 — 경로 직접 입력" },
199
+ { value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` },
200
+ ],
196
201
  });
197
- if (ok === true) chosen = cand;
202
+ if (sel === EXCLUDE) excluded = true;
203
+ else if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
204
+ // ESC/직접 입력 → 아래 직접입력 루프로
198
205
  } else if (candidates.length > 1) {
199
206
  say("");
200
207
  say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
201
- // 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
208
+ // 후보들 + '직접 입력'/'이 타입 제외' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
202
209
  const options = candidates.map((c) => ({
203
210
  value: c,
204
211
  label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
205
212
  }));
206
213
  options.push({ value: "직접 입력", label: "직접 입력" });
214
+ options.push({ value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` });
207
215
  const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
208
216
  // ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
209
- if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
217
+ if (sel === EXCLUDE) excluded = true;
218
+ else if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
210
219
  } else {
211
220
  say("");
212
221
  say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
213
222
  }
214
223
 
215
- // ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) ──
216
- while (!chosen) {
224
+ // ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) — 제외 탈출구 포함 (#487) ──
225
+ while (!chosen && !excluded) {
217
226
  const hintMarker = existingMarkerInDir(t, root);
218
227
  let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
219
228
  if (existing) prompt += `, 현재값: ${existing}`;
@@ -229,11 +238,25 @@ export async function resolveProjectPaths({
229
238
  chosen = input;
230
239
  } else {
231
240
  say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
232
- const forceOk = await io.confirm({ message: " 그래도 이 경로를 사용할까요?", initialValue: false });
233
- if (forceOk === true) chosen = input;
241
+ const act = await io.select({
242
+ message: " 어떻게 할까요?",
243
+ options: [
244
+ { value: "retry", label: "다시 입력" },
245
+ { value: "force", label: `그래도 '${input}' 경로 사용` },
246
+ { value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` },
247
+ ],
248
+ });
249
+ if (act === "force") chosen = input;
250
+ else if (act === EXCLUDE) excluded = true;
251
+ // retry/ESC → 루프 계속
234
252
  }
235
253
  }
236
254
 
255
+ if (excluded) {
256
+ say(` ➖ ${t} 제외 — 이 타입은 버전 동기화·워크플로우 설치 대상에서 빠집니다`);
257
+ continue;
258
+ }
259
+
237
260
  result.set(t, chosen);
238
261
  say(` ✅ ${t} → ${chosen}`);
239
262
  }
@@ -259,3 +282,10 @@ export async function resolveProjectPaths({
259
282
  say("");
260
283
  return result;
261
284
  }
285
+
286
+ // 경로 단계에서 제외된 타입 반영 (#487) — basic이 아니면서 paths에 없는 타입 제거.
287
+ // 전부 제외되면 basic 폴백 (타입 0개 상태 금지). 비대화형은 제외가 불가능하므로 no-op.
288
+ export function filterExcludedTypes(types, paths) {
289
+ const kept = types.filter((t) => t === "basic" || paths.has(t));
290
+ return kept.length ? kept : ["basic"];
291
+ }
@@ -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 !== ".") {
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeRe
13
13
  import { parseExisting } from "./core/version-yml.js";
14
14
  import { runBreakingCheck } from "./core/breaking-check.js";
15
15
  import { runMigrations } from "./core/migrations/index.js";
16
+ import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
16
17
  import { resolveProjectPaths } from "./core/paths-resolve.js";
17
18
  import { printBannerCompact } from "./ui/banner.js";
18
19
  import { printSummary } from "./ui/summary.js";
@@ -150,6 +151,14 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
150
151
  await runMigrations({ targetRoot: cwd });
151
152
  }
152
153
 
154
+ // 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
155
+ if (opts.mode === "full" || opts.mode === "workflows") {
156
+ const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
157
+ for (const o of orphans) {
158
+ console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
159
+ }
160
+ }
161
+
153
162
  switch (opts.mode) {
154
163
  case "full": result = runFull(context, tempDir, cwd); break;
155
164
  case "version": result = runVersion(context, tempDir, cwd); break;
@@ -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) 부여");