projectops 4.27.0 → 4.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.26.0 (2026-09-30)
10
+ ## 최신 버전 : v4.28.0 (2026-09-30)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
@@ -168,6 +168,7 @@ npx projectops --mode skills
168
168
  | `/pro-build` | 프로젝트 빌드 실행, 에러 분석, 최적화 제안 |
169
169
  | `/pro-launch` | 앱·웹·서버를 띄우고 조작하고 찍는다 — 상태바 고정 캡처, 폭 바꾸기, 응답 바꿔치기(빈 목록·실패 연출), 코드 렌더 |
170
170
  | `/pro-design-brief` | 시안보다 먼저 만든 화면의 디자인 요청서 — 상태별 캡처·대안·문구 후보·꼭 지킬 것을 보드로 만들어 디자이너에게 |
171
+ | `/pro-oss-consult` | GitHub 레포를 오픈소스로서 진단 — 성격 판별, 6축 채점, 성숙도 단계, 승인받은 것만 About·topics·라벨·파일 수정. 단일 레포·계정 전체 일괄 |
171
172
 
172
173
  > 설계·계획·구현 흐름은 `superpowers`(brainstorming → writing-plans → executing-plans)가 담당합니다.
173
174
  > `/pro-plan` · `/pro-analyze` · `/pro-implement`는 같은 역할의 이전 세대 경로로, 명시적으로 호출할 때만 동작합니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.27.0",
3
+ "version": "4.28.1",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
package/src/cli/args.js CHANGED
@@ -1,9 +1,15 @@
1
1
  // CLI 인자 파싱 (.sh top-level while-case 등가) — template_integrator.sh 842~920.
2
+ import { existsSync, statSync, realpathSync } from "node:fs";
3
+ import { join, relative, isAbsolute } from "node:path";
2
4
  import { VALID_TYPES } from "../context.js";
3
5
 
4
6
  export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
5
7
  export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
6
8
  export const INTENT_VALUES = ["app", "library", "both", "none", "manual"];
9
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
10
+ const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
11
+ // 허용 실행 모드 (#665) — 오타가 조용히 "복사 0건 성공"으로 끝나지 않게 한다.
12
+ export const MODE_VALUES = ["full", "version", "workflows", "issues", "skills", "doctor", "interactive"];
7
13
 
8
14
  // argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
9
15
  export function parseArgs(argv) {
@@ -28,12 +34,28 @@ export function parseArgs(argv) {
28
34
  const a = args.shift();
29
35
  switch (a) {
30
36
  case "-m": case "--mode":
31
- result.mode = args.shift() ?? ""; break;
37
+ {
38
+ const v = (args.shift() ?? "").trim();
39
+ if (!MODE_VALUES.includes(v)) {
40
+ throw new CliError(`--mode 값은 ${MODE_VALUES.join(" | ")} 중 하나여야 합니다: '${v}'`);
41
+ }
42
+ result.mode = v;
43
+ break;
44
+ }
32
45
  case "-v": case "--version":
33
46
  // npm 관례: -v/--version 은 패키지 버전 출력. (초기 버전 지정은 --project-version)
34
47
  result.showVersion = true; break;
35
48
  case "--project-version":
36
- result.version = args.shift() ?? ""; break;
49
+ {
50
+ // version.yml·셸 스크립트가 그대로 읽는 값이라 x.y.z 외에는 받지 않는다 (#676)
51
+ // 'v1.0.0'은 사용자가 흔히 쓰는 표기라 거부하지 않고 접두사만 떼어 받는다
52
+ const v = (args.shift() ?? "").trim().replace(/^[vV](?=\d)/, "");
53
+ if (!SEMVER_RE.test(v)) {
54
+ throw new CliError(`--project-version 값은 x.y.z (예: 1.0.0) 형식이어야 합니다: '${v}'`);
55
+ }
56
+ result.version = v;
57
+ break;
58
+ }
37
59
  case "-t": case "--type": {
38
60
  const csv = args.shift() ?? "";
39
61
  const seen = new Set();
@@ -79,6 +101,10 @@ export function parseArgs(argv) {
79
101
  case "--deploy-branch": {
80
102
  // 릴리스 PR head 브랜치 (#456). default_branch와 별개.
81
103
  const v = (args.shift() ?? "").trim();
104
+ // 워크플로우 yaml·셸에 그대로 삽입되므로 안전한 브랜치명만 허용 (#676)
105
+ if (v && (!BRANCH_RE.test(v) || v.includes("..") || v.endsWith("/") || v.endsWith(".lock"))) {
106
+ throw new CliError(`--deploy-branch 값은 영문·숫자·. _ - / 로 이루어진 브랜치명이어야 합니다: '${v}'`);
107
+ }
82
108
  if (v) result.deployBranch = v;
83
109
  break;
84
110
  }
@@ -132,6 +158,29 @@ export function normalizePath(p) {
132
158
  return s === "" ? "." : s;
133
159
  }
134
160
 
161
+ // --paths 맵 검증 (#674) — 저장소 밖 경로는 version_manager가 그 파일을 실제로 수정하므로 가장 위험하다.
162
+ // · 절대경로·`..` 세그먼트 거부 (저장소 루트 기준 상대경로만)
163
+ // · --type 으로 고르지 않은 타입 키 거부
164
+ // · 존재하지 않는 폴더 거부 (심볼릭 링크로 밖을 가리키는 경우도 realpath 로 막는다)
165
+ export function validatePathsMap(map, { root, types }) {
166
+ for (const [type, p] of map) {
167
+ if (!types.includes(type)) {
168
+ throw new CliError(`--paths 에 선택하지 않은 타입이 있습니다: '${type}' (선택한 타입: ${types.join(",") || "없음"})`);
169
+ }
170
+ if (p.startsWith("/") || /^[A-Za-z]:/.test(p) || p.split("/").includes("..")) {
171
+ throw new CliError(`--paths ${type}=${p} : 저장소 루트 기준 상대 경로여야 합니다 (절대경로, '..' 불가)`);
172
+ }
173
+ const abs = join(root, p);
174
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
175
+ throw new CliError(`--paths ${type}=${p} : 존재하지 않는 폴더입니다`);
176
+ }
177
+ const rel = relative(realpathSync(root), realpathSync(abs));
178
+ if (rel.startsWith("..") || isAbsolute(rel)) {
179
+ throw new CliError(`--paths ${type}=${p} : 저장소 밖을 가리킵니다`);
180
+ }
181
+ }
182
+ }
183
+
135
184
  // "flutter=app,react=client" → Map<type, normalizedPath>. 타입 검증(무효 → throw).
136
185
  export function parsePathsCsv(csv) {
137
186
  const map = new Map();
@@ -0,0 +1,40 @@
1
+ // 예기치 못한 예외를 사용자가 읽을 수 있는 한 줄 오류 + 조치 안내로 바꾼다 (#672).
2
+ // 스택 트레이스는 사용자가 고칠 수 있는 정보가 아니므로 DEBUG 환경변수가 있을 때만 노출한다.
3
+
4
+ // err → { message, hint }. 분류 근거는 Node 가 던지는 err.code / 메시지 형식이다.
5
+ export function describeError(err) {
6
+ const msg = String(err?.message || err);
7
+ const code = err?.code;
8
+
9
+ // PATH 에 git 이 없음 — spawn 계열이 ENOENT 를 던진다
10
+ if (code === "ENOENT" && /\bgit\b/.test(`${err?.path ?? ""} ${err?.syscall ?? ""} ${msg}`)) {
11
+ return { message: "git 을 실행할 수 없습니다.", hint: "git 이 설치되어 있고 PATH 에 있는지 확인하세요." };
12
+ }
13
+ // 템플릿 내려받기 실패 — execFileSync 가 "Command failed: git clone ..." 를 던진다
14
+ if (/Command failed: git clone/.test(msg)) {
15
+ return {
16
+ message: "템플릿을 내려받지 못했습니다.",
17
+ hint: "네트워크·프록시 설정과 GitHub 접근 가능 여부를 확인한 뒤 다시 실행하세요.",
18
+ };
19
+ }
20
+ // 권한/읽기 전용 파일시스템
21
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
22
+ const where = err?.path ? ` (${err.path})` : "";
23
+ return {
24
+ message: `파일을 쓸 수 없습니다${where}.`,
25
+ hint: "프로젝트 폴더와 .github 폴더의 쓰기 권한(읽기 전용 여부)을 확인하세요.",
26
+ };
27
+ }
28
+ if (code === "ENOSPC") {
29
+ return { message: "디스크 공간이 부족합니다.", hint: "공간을 확보한 뒤 다시 실행하세요." };
30
+ }
31
+ return { message: `예기치 못한 오류: ${msg}`, hint: "자세한 내용은 DEBUG=1 로 다시 실행하면 볼 수 있습니다." };
32
+ }
33
+
34
+ // 오류를 stderr 로 출력한다. 호출부는 비0 종료코드를 돌려주면 된다.
35
+ export function reportFatal(err, { env = process.env, write = (s) => process.stderr.write(s) } = {}) {
36
+ const { message, hint } = describeError(err);
37
+ write(`❌ ${message}\n`);
38
+ if (hint) write(` ${hint}\n`);
39
+ if (env.DEBUG) write(`\n${err?.stack || err}\n`);
40
+ }
@@ -2,9 +2,10 @@
2
2
  // 복사 순서: version.yml → readme → workflows → (deploy블록) → scripts → config →
3
3
  // util(타입별) → issue → discussion → coderabbit → gitignore → setup_guide → (옵션저장)
4
4
  import { join } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
5
6
  import { writeText } from "../core/fsutil.js";
6
7
  import { PATHS } from "../core/paths.js";
7
- import { buildVersionYml } from "../core/version-yml.js";
8
+ import { buildVersionYml, mergeDeployValues } from "../core/version-yml.js";
8
9
  import { markerForType } from "../core/detect.js";
9
10
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
10
11
  import { copyWorkflows } from "../core/copy/workflows.js";
@@ -36,7 +37,10 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
36
37
  const step = (name, fn, d) => (hooks.trace ? hooks.trace.step(name, fn, d) : fn());
37
38
  const wfCounters = step("copy-workflows", () => copyWorkflows(context, tempDir, targetRoot, hooks),
38
39
  { types, deploy: deployTarget, publish: publishTargets });
39
- const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
40
+ // 기존 version.yml 의 deploy 값은 보존한다 (#670) — 전체 재생성이라 읽지 않으면 사용자 수정이 사라진다.
41
+ const vyFile = join(targetRoot, PATHS.versionFile);
42
+ const deployValues = mergeDeployValues(existsSync(vyFile) ? readFileSync(vyFile, "utf8") : "",
43
+ wfCounters.deployValues || new Map()); // Map<type, Map<key,value>
40
44
 
41
45
  // 1. version.yml 생성 (전체 재생성 — metadata → deploy → template 순, .sh 최종형과 동일)
42
46
  step("write-version-yml", () => writeText(join(targetRoot, PATHS.versionFile),
@@ -408,6 +408,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
408
408
  mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
409
409
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
410
410
  skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
411
+ replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
411
412
  verification: result?.verification, // #549 설치 검증 결과
412
413
  aiPrSummary, codeReviewCoderabbit, // #569 — 고른 것만 안내
413
414
  logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
@@ -1,10 +1,11 @@
1
1
  // version 모드 (.sh execute_integration version case 등가) — template_integrator.sh 4517~4530.
2
2
  // 순서: version.yml → readme → scripts → config → gitignore → setup_guide.
3
3
  // (워크플로우 미복사 → deploy 블록 없음. util·issue·coderabbit도 없음.)
4
+ import { existsSync, readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { writeText } from "../core/fsutil.js";
6
7
  import { PATHS } from "../core/paths.js";
7
- import { buildVersionYml } from "../core/version-yml.js";
8
+ import { buildVersionYml, mergeDeployValues } from "../core/version-yml.js";
8
9
  import { markerForType } from "../core/detect.js";
9
10
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
10
11
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
@@ -19,9 +20,14 @@ export function runVersion(context, tempDir, targetRoot = ".") {
19
20
  const pathMarkers = new Map();
20
21
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
21
22
 
22
- writeText(join(targetRoot, PATHS.versionFile),
23
+ // version.yml 은 전체 재생성이라, 읽지 않으면 이전 통합이 남긴 deploy 값(사용자 수정 포함)이 사라진다 (#670)
24
+ const vyFile = join(targetRoot, PATHS.versionFile);
25
+ const deployValues = mergeDeployValues(existsSync(vyFile) ? readFileSync(vyFile, "utf8") : "", new Map());
26
+
27
+ writeText(vyFile,
23
28
  buildVersionYml({
24
29
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
30
+ deployValues,
25
31
  // mode(#502): version 모드가 기존 full 통합 기록을 "version"으로 강등하지 않도록
26
32
  // 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
27
33
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
@@ -7,7 +7,7 @@ import { PATHS } from "../core/paths.js";
7
7
  import { copyWorkflows } from "../core/copy/workflows.js";
8
8
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
9
9
  import { copyUtilModules } from "../core/copy/util.js";
10
- import { convertLegacySingularType } from "../core/version-yml.js";
10
+ import { convertLegacySingularType, mergeDeployValues } from "../core/version-yml.js";
11
11
  import { verifyInstall } from "../core/verify.js";
12
12
 
13
13
  export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
@@ -25,7 +25,8 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
25
25
 
26
26
  // update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
27
27
  if (existsSync(vy) && wf.deployValues && wf.deployValues.size) {
28
- writeFileSync(vy, upsertDeployBlock(readFileSync(vy, "utf8"), wf.deployValues));
28
+ const cur = readFileSync(vy, "utf8");
29
+ writeFileSync(vy, upsertDeployBlock(cur, mergeDeployValues(cur, wf.deployValues))); // 기존 값 우선 (#670)
29
30
  }
30
31
 
31
32
  copyScripts(tempDir, targetRoot);
@@ -62,8 +63,10 @@ export function upsertDeployBlock(content, deployValues) {
62
63
  for (const line of lines) {
63
64
  if (/^deploy:/.test(line)) { inDeploy = true; continue; }
64
65
  if (inDeploy) {
65
- if (/^\s/.test(line) || line === "") continue; // 들여쓰기/빈줄 = deploy 내부
66
- inDeploy = false;
66
+ // 4.28.0 이 만든 깨진 구조: deploy 아래 2칸 `template:` 은 deploy 내용이 아니라 metadata 의 자식이다 (#670)
67
+ if (/^ {2}template:/.test(line)) inDeploy = false;
68
+ else if (/^\s/.test(line) || line === "") continue; // 들여쓰기/빈줄 = deploy 내부
69
+ else inDeploy = false;
67
70
  }
68
71
  out.push(line);
69
72
  }
@@ -81,6 +81,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
81
81
  if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
82
82
 
83
83
  const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, copiedFiles: [],
84
+ replacedBak: [], // #673 — 기준점이 없어 수정 여부를 모른 채 .bak 백업 후 교체한 파일명
84
85
  skippedConflicts: [] }; // #654 — 유지한 사용자 수정본별 { filename, incoming, added, removed }
85
86
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
86
87
  counters.deployValues = deployValues;
@@ -152,6 +153,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
152
153
  counters.copied++;
153
154
  counters.copiedFiles.push(filename);
154
155
  trace?.event("copy", "replaced-bak", filename, { group: "common", reason: "baseline-absent" });
156
+ counters.replacedBak.push(filename); // #673 — 수정 가능성이 있는 파일을 교체했음을 화면에 알린다
155
157
  continue;
156
158
  }
157
159
  // 사용자가 손댄 것이 확인된 파일 — 결정에 따라 처리(미지정이면 유지).
@@ -184,6 +186,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
184
186
  counters.copied++;
185
187
  counters.copiedFiles.push(filename);
186
188
  trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "common-deploy" });
189
+ if (backedUp) counters.replacedBak.push(filename); // #673
187
190
  }
188
191
  }
189
192
 
@@ -207,6 +210,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
207
210
  counters.copied++;
208
211
  counters.copiedFiles.push(filename);
209
212
  trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "pr-summary" });
213
+ if (backedUp) counters.replacedBak.push(filename); // #673
210
214
  }
211
215
  }
212
216
 
@@ -407,6 +411,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
407
411
  counters.copied++;
408
412
  counters.copiedFiles.push(filename);
409
413
  trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: `${type}/publish/${target}` });
414
+ if (backedUp) counters.replacedBak.push(filename); // #673
410
415
  }
411
416
  }
412
417
 
@@ -21,6 +21,8 @@ function gitOut(root, args) {
21
21
  const SCAN_PRUNE = new Set([
22
22
  "node_modules", ".git", "build", "dist", ".dart_tool",
23
23
  "android", "ios", ".gradle", "venv", ".venv", "__pycache__",
24
+ // 템플릿이 스스로 설치하는 .github/scripts/*.py 가 python 으로 오탐되지 않게 한다 (#671)
25
+ ".github",
24
26
  ]);
25
27
  export function listScanFiles(root, maxDepth = 3) {
26
28
  const files = [];
@@ -267,6 +267,37 @@ export function parseExisting(content) {
267
267
  return { version, versionCode, types, paths, templateVersion, templateMode, options, defaultBranch, deployBranch };
268
268
  }
269
269
 
270
+ // 기존 version.yml 의 deploy 블록 → Map<type, Map<key,value>> (#670 — 재실행 시 사용자 수정값 보존용).
271
+ // 4.28.0 이 만든 깨진 구조(deploy 아래에 2칸 들여쓴 template 이 딸려 있음)도 읽을 수 있게,
272
+ // 2칸 키 `template` 은 타입이 아니라 블록 종료로 본다.
273
+ export function parseDeployBlock(content) {
274
+ const out = new Map();
275
+ let inDeploy = false;
276
+ let type = null;
277
+ for (const line of String(content || "").split(/\r?\n/)) {
278
+ if (/^deploy:/.test(line)) { inDeploy = true; type = null; continue; }
279
+ if (!inDeploy) continue;
280
+ if (line === "" || /^\s*#/.test(line)) continue;
281
+ if (/^\S/.test(line) || /^ {2}template:/.test(line)) { inDeploy = false; continue; }
282
+ const t = line.match(/^ {2}([A-Za-z0-9_-]+):\s*(#.*)?$/);
283
+ if (t) { type = t[1]; if (!out.has(type)) out.set(type, new Map()); continue; }
284
+ const kv = line.match(/^ {4}([A-Za-z0-9_.-]+):\s*"([^"]*)"/);
285
+ if (kv && type) out.get(type).set(kv[1], kv[2]);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ // 재실행 시 기존 deploy 값 우선 병합 (#670) — 사용자가 version.yml 에서 직접 고친 값이 템플릿 기본값에
291
+ // 덮이지 않게 한다. 기존 값이 이기고, 이번에 새로 묻는 키만 추가된다.
292
+ export function mergeDeployValues(existingContent, fresh) {
293
+ const merged = parseDeployBlock(existingContent);
294
+ for (const [t, kv] of fresh) {
295
+ if (!merged.has(t)) merged.set(t, new Map());
296
+ for (const [k, v] of kv) if (!merged.get(t).has(k)) merged.get(t).set(k, v);
297
+ }
298
+ return merged;
299
+ }
300
+
270
301
  // version.yml 전체 생성 (.sh create_version_yml + save_template_options 신규 케이스 등가).
271
302
  // opts: { version, types:[], paths:Map, pathMarkers?:Map, branch, versionCode, now, today, templateOptions? }
272
303
  // primary 타입은 별도 키 없이 project_types[0]이다 (v4.1.0 SSOT — 단수 project_type 키 제거).
@@ -302,18 +333,6 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
302
333
  out += ` integrated_from: "projectops"\n`;
303
334
  out += ` integration_date: "${today}"\n`;
304
335
 
305
- // deploy 블록 (.sh update_version_yml_deploy). deployValues: Map<type, Map<key,value>>.
306
- // WF ask 값이 있는 타입만. metadata 뒤, template 앞. (앞에 빈 줄 1개)
307
- const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
308
- if (deployTypes.length) {
309
- out += `\n`;
310
- out += `deploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)\n`;
311
- for (const t of deployTypes) {
312
- out += ` ${t}:\n`;
313
- for (const [k, v] of deployValues.get(t)) out += ` ${k}: "${v}"\n`;
314
- }
315
- }
316
-
317
336
  // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
318
337
  if (templateOptions) {
319
338
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
@@ -347,5 +366,18 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
347
366
  out += ` provider: "${changelogProvider}"\n`;
348
367
  out += ` base_url: "${changelogBaseUrl}"\n`;
349
368
  }
369
+
370
+ // deploy 블록 (.sh update_version_yml_deploy). deployValues: Map<type, Map<key,value>>.
371
+ // WF ask 값이 있는 타입만. 반드시 template 뒤의 별도 최상위 키로 둔다 — metadata 와 template
372
+ // 사이에 끼면 template 이 deploy 의 자식이 되어 metadata.template.options 구조가 깨진다 (#670).
373
+ const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
374
+ if (deployTypes.length) {
375
+ out += `\n`;
376
+ out += `deploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)\n`;
377
+ for (const t of deployTypes) {
378
+ out += ` ${t}:\n`;
379
+ for (const [k, v] of deployValues.get(t)) out += ` ${k}: "${v}"\n`;
380
+ }
381
+ }
350
382
  return out;
351
383
  }
package/src/index.js CHANGED
@@ -3,8 +3,9 @@
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { readFileSync, existsSync } from "node:fs";
6
- import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
6
+ import { parseArgs, parsePathsCsv, validatePathsMap, CliError } from "./cli/args.js";
7
7
  import { HELP_TEXT } from "./cli/help.js";
8
+ import { reportFatal } from "./cli/errors.js";
8
9
  import { createContext } from "./context.js";
9
10
  import { PATHS } from "./core/paths.js";
10
11
  import { remove } from "./core/fsutil.js";
@@ -49,7 +50,18 @@ function utcNow(date = new Date()) {
49
50
  // run(argv, opts) → exitCode. opts: { cwd, source?, clock? }
50
51
  // source: acquireTemplate용 (기본 git clone). 테스트는 {type:'local', path} 주입.
51
52
  // clock: {now, today} 주입 (기본 현재 UTC).
52
- export async function run(argv, { cwd = process.cwd(), source = { type: "git" }, clock } = {}) {
53
+ export async function run(argv, options = {}) {
54
+ try {
55
+ return await runCore(argv, options);
56
+ } catch (err) {
57
+ // 네트워크·git·권한 같은 환경 문제는 스택 트레이스 대신 한 줄 원인 + 조치로 알린다 (#672).
58
+ // 실행 기록(trace)은 runCore 의 finally 에서 이미 닫혔다.
59
+ reportFatal(err);
60
+ return 1;
61
+ }
62
+ }
63
+
64
+ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, clock } = {}) {
53
65
  let opts;
54
66
  try {
55
67
  opts = parseArgs(argv);
@@ -130,8 +142,21 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
130
142
  const repoName = detectRepoName(cwd);
131
143
  trace.event("detect", "repo", repoName || "(미상)", { defaultBranch: branch, versionCode });
132
144
  // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
145
+ let cliPaths;
146
+ try {
147
+ cliPaths = parsePathsCsv(opts.pathsCsv);
148
+ validatePathsMap(cliPaths, { root: cwd, types }); // #674 — 파일을 쓰기 전에 거른다
149
+ } catch (e) {
150
+ if (e instanceof CliError) {
151
+ console.error(e.message);
152
+ trace.mirrorStop();
153
+ disarmSignals();
154
+ return 1;
155
+ }
156
+ throw e;
157
+ }
133
158
  const paths = await resolveProjectPaths({
134
- root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
159
+ root: cwd, types, paths: cliPaths,
135
160
  existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
136
161
  });
137
162
 
@@ -322,6 +347,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
322
347
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
323
348
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
324
349
  skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
350
+ replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
325
351
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
326
352
  // #569 — 고른 것만 안내하려면 선택값이 필요하다
327
353
  aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,
package/src/ui/summary.js CHANGED
@@ -18,6 +18,8 @@ export function printSummary(ctx, targetRoot = ".") {
18
18
  const CYAN = isTty ? "\x1b[0;36m" : "";
19
19
  const NC = isTty ? "\x1b[0m" : "";
20
20
  const utilModulesCopied = counters.utilModules ?? 0;
21
+ // README.md 가 없으면 버전 섹션 추가를 건너뛰므로 화면에도 추가·자동 갱신으로 표시하지 않는다 (#677)
22
+ const hasReadme = existsSync(join(targetRoot, "README.md"));
21
23
 
22
24
  err("");
23
25
  err(SEPARATOR);
@@ -32,7 +34,7 @@ export function printSummary(ctx, targetRoot = ".") {
32
34
  switch (mode) {
33
35
  case "full":
34
36
  err(" ✅ 버전 관리 시스템 (version.yml)");
35
- err(" ✅ README.md 자동 버전 업데이트");
37
+ if (hasReadme) err(" ✅ README.md 자동 버전 업데이트");
36
38
  err(" ✅ GitHub Actions 워크플로우");
37
39
  if (utilModulesCopied > 0) err(` ✅ 유틸리티 모듈 (${utilModulesCopied} 개)`);
38
40
  err(" ✅ 이슈/PR/Discussion 템플릿");
@@ -42,7 +44,7 @@ export function printSummary(ctx, targetRoot = ".") {
42
44
  break;
43
45
  case "version":
44
46
  err(" ✅ 버전 관리 시스템 (version.yml)");
45
- err(" ✅ README.md 자동 버전 업데이트");
47
+ if (hasReadme) err(" ✅ README.md 자동 버전 업데이트");
46
48
  err(" ✅ .gitignore 필수 항목");
47
49
  err(" ✅ 템플릿 설정 가이드 (SETUP-GUIDE.md)");
48
50
  break;
@@ -72,7 +74,7 @@ export function printSummary(ctx, targetRoot = ".") {
72
74
  err("");
73
75
  err("추가된 파일:");
74
76
  err(` 📄 version.yml (버전: ${version}, 타입: ${types.join(",")})`);
75
- err(" 📝 README.md (버전 섹션 추가)");
77
+ err(hasReadme ? " 📝 README.md (버전 섹션 추가)" : " 📝 README.md 없음 — 버전 섹션 추가를 건너뜀");
76
78
  err("");
77
79
  err("추가된 워크플로우:");
78
80
 
@@ -110,6 +112,16 @@ export function printSummary(ctx, targetRoot = ".") {
110
112
  }
111
113
  }
112
114
 
115
+ // #673 — 업데이트 기준점이 없어 수정 여부를 구분하지 못하고 교체한 파일: 원본이 .bak 으로만 남으므로
116
+ // 사용자가 자기 수정이 사라졌는지 git diff 없이도 알 수 있어야 한다.
117
+ const replaced = ctx?.replacedBak ?? [];
118
+ if (replaced.length > 0) {
119
+ err("");
120
+ err(` ${YELLOW}⚠️ 수정 여부를 확인할 수 없어 ${replaced.length}개 워크플로를 새 템플릿으로 교체하고 원본을 .bak 으로 백업했습니다${NC}`);
121
+ for (const f of replaced) err(` 💾 .github/workflows/${f}.bak`);
122
+ err(" 직접 수정한 내용이 있었다면 .bak 에서 옮겨 오세요. (업데이트 기준점은 이번 실행으로 새로 기록됩니다)");
123
+ }
124
+
113
125
  err("");
114
126
  err(" 🔧 .github/scripts/");
115
127
  err(" ├─ version_manager.sh");
@@ -237,6 +249,13 @@ export function printSummary(ctx, targetRoot = ".") {
237
249
  err("");
238
250
  step++;
239
251
  }
252
+ // #668 — 라벨 동기화는 issue-labels.yml 이 바뀐 push 에서만 도는데, 설치 직후 첫 push 에서
253
+ // 실행 기록이 없는 경우가 실측됐다. 라벨이 없으면 상태 라벨 자동화가 조용히 무시되므로 1회 수동 실행을 안내한다.
254
+ err(` ${step}️⃣ (권장) 템플릿 라벨 만들기`);
255
+ err(" → Actions 탭 > PROJECT-SYNC-GITHUB-LABELS > Run workflow 를 한 번 실행하세요.");
256
+ err(" → 안 하면 작업전·작업중 같은 상태 라벨이 레포에 없어 라벨 자동화가 무시됩니다.");
257
+ err("");
258
+ step++;
240
259
  err(SEPARATOR);
241
260
  err("");
242
261
  err(`${CYAN}📖 자세한 설정 방법은 다음 파일을 참고하세요:${NC}`);