projectops 4.4.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -14,9 +14,9 @@ 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
16
  import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
17
- import { createRunTrace } from "./core/run-trace.js";
17
+ import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
18
18
  import { appendGuideEntry } from "./core/migration-guide.js";
19
- import { resolveProjectPaths } from "./core/paths-resolve.js";
19
+ import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
20
20
  import { applicableTargets } from "./core/options-ask.js";
21
21
  import { printBannerCompact } from "./ui/banner.js";
22
22
  import { printSummary } from "./ui/summary.js";
@@ -60,6 +60,13 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
60
60
  if (opts.showVersion) { console.log(readPkgVersion()); return 0; }
61
61
  if (opts.help) { console.log(HELP_TEXT); return 0; }
62
62
 
63
+ // doctor 모드 (#558) — 읽기 전용 진단. 템플릿을 내려받지 않으므로 네트워크 없이도 동작한다.
64
+ if (opts.mode === "doctor") {
65
+ const { runDoctor } = await import("./commands/doctor.js");
66
+ await runDoctor({ cwd });
67
+ return 0; // 진단은 실패가 아니다 — 살펴볼 항목이 있어도 0으로 끝낸다
68
+ }
69
+
63
70
  // skills 모드 — IDE 스킬 설치/업데이트/제거 (템플릿 통합 없음).
64
71
  // Cursor 복사용 skills/ 소스가 필요하므로 템플릿을 획득한 뒤 실행한다.
65
72
  if (opts.mode === "skills") {
@@ -87,23 +94,59 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
87
94
  return 1;
88
95
  }
89
96
 
97
+ // 실행 트레이스 (#494/#561) — 감지·판단 단계부터 기록해야 "왜 이렇게 정해졌는지"가 남는다.
98
+ const trace = createRunTrace();
99
+ const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
100
+ let disarmSignals = () => {};
101
+ if (recordArtifacts) {
102
+ trace.mirrorStart();
103
+ // Ctrl+C로 끊어도 여기까지의 기록이 남는다 (#561). 경로는 아래에서 확정되지만
104
+ // 신호는 언제든 올 수 있으므로 targetRoot만으로 먼저 무장한다.
105
+ disarmSignals = trace.armSignals({ targetRoot: cwd, now: "" });
106
+ }
107
+
90
108
  // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh L2208~2239 SSoT)
91
109
  const vyPath = join(cwd, "version.yml");
92
110
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
111
+ trace.event("detect", "existing-install", existing ? "found" : "none", {
112
+ templateVersion: existing?.templateVersion || null,
113
+ version: existing?.version || null,
114
+ types: existing?.types || null,
115
+ });
93
116
 
94
117
  // 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
95
118
  const types = opts.types.length ? opts.types : detectTypes(cwd);
119
+ trace.event("detect", "types", types.join(",") || "(없음)", {
120
+ source: opts.types.length ? "cli-flag(--type)" : "auto-detect(마커 파일 스캔)",
121
+ });
96
122
  // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
97
123
  const version = (existing?.version) || opts.version || detectVersion(cwd);
124
+ trace.event("detect", "version", version, {
125
+ source: existing?.version ? "version.yml(기존값 보존)"
126
+ : (opts.version ? "cli-flag(--project-version)" : "프로젝트 파일 감지"),
127
+ });
98
128
  const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
99
129
  const branch = detectDefaultBranch(cwd);
100
130
  const repoName = detectRepoName(cwd);
131
+ trace.event("detect", "repo", repoName || "(미상)", { defaultBranch: branch, versionCode });
101
132
  // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
102
133
  const paths = await resolveProjectPaths({
103
134
  root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
104
135
  existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
105
136
  });
106
137
 
138
+ for (const [ty, pth] of paths) {
139
+ // 근거로 "무엇을 보고 그 경로로 정했는지"까지 남긴다 — 경로가 틀렸을 때 추적의 시작점이다.
140
+ const marker = markerForType(ty);
141
+ const at = pth === "." ? marker : `${pth}/${marker}`;
142
+ trace.event("detect", "project-path", ty, {
143
+ path: pth,
144
+ marker: existsSync(join(cwd, at)) ? at : `${at} (없음)`,
145
+ source: parsePathsCsv(opts.pathsCsv).has(ty) ? "cli-flag(--paths)"
146
+ : (existing?.paths?.has(ty) ? "version.yml(저장값)" : "마커 파일 탐색"),
147
+ });
148
+ }
149
+
107
150
  const { now, today } = clock || utcNow();
108
151
  const tempDir = join(cwd, PATHS.tempDir);
109
152
 
@@ -122,10 +165,32 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
122
165
  }
123
166
  // 적용 불가 타겟 조용한 정리 (#498) — 대화형과 동일 규칙. 타입에 성립하지 않는 축 값은
124
167
  // 복사 결과가 동일하므로 경고 없이 none/교집합으로 정리한다 (모바일 앱/basic 단독 등).
168
+ const beforeCleanup = { deploy: deployTarget, publish: [...publishTargets] };
125
169
  if (deployTarget !== "none" && !applicable.deploy.includes(deployTarget)) deployTarget = "none";
126
170
  publishTargets = publishTargets.filter((t) => applicable.publish.includes(t));
127
171
  if (types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0) intent = "none";
128
172
 
173
+ // 축 확정 근거 (#561) — "왜 이 값인가"가 가장 헷갈리는 자리다.
174
+ // CLI 플래그 / 저장값 / intent 유도 / 타입 적용성 정리 중 무엇이 이겼는지 남긴다.
175
+ trace.event("resolve", "intent", String(intent ?? "(미설정)"), {
176
+ source: opts.intent != null ? "cli-flag(--intent)"
177
+ : (existing?.options?.intent ? "version.yml(저장값)" : "미지정 → deploy/publish에서 역추론"),
178
+ });
179
+ trace.event("resolve", "deploy", deployTarget, {
180
+ source: opts.deployTarget != null ? "cli-flag(--deploy)"
181
+ : (existing?.options?.deploy ? "version.yml(저장값)" : "기본값"),
182
+ applicableForTypes: applicable.deploy,
183
+ adjusted: beforeCleanup.deploy !== deployTarget
184
+ ? `${beforeCleanup.deploy} → ${deployTarget} (선택 타입에 적용 불가)` : null,
185
+ });
186
+ trace.event("resolve", "publish", publishTargets.join(",") || "(없음)", {
187
+ source: opts.publishTargets != null ? "cli-flag(--publish)"
188
+ : (existing?.options?.publish ? "version.yml(저장값)" : "기본값"),
189
+ applicableForTypes: applicable.publish,
190
+ adjusted: beforeCleanup.publish.join(",") !== publishTargets.join(",")
191
+ ? `${beforeCleanup.publish.join(",") || "(없음)"} → ${publishTargets.join(",") || "(없음)"} (적용 불가 정리)` : null,
192
+ });
193
+
129
194
  const context = createContext({
130
195
  mode: opts.mode, force: true, types, version, versionCode, branch,
131
196
  paths,
@@ -153,66 +218,111 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
153
218
  now, today,
154
219
  });
155
220
 
221
+ // 최종 확정값 스냅샷 (#561) — 이 한 줄로 "무엇이 어떻게 설치될 것인지"가 고정된다.
222
+ trace.event("resolve", "context", opts.mode || "", {
223
+ types, version, versionCode, branch,
224
+ deploy: deployTarget, publish: publishTargets, intent,
225
+ secretBackup: context.includeSecretBackup,
226
+ changelogProvider: context.changelogProvider,
227
+ coderabbit: context.codeReviewCoderabbit,
228
+ deployBranch: context.deployBranch || "(미지정 → develop 폴백)",
229
+ recordMode: context.recordMode,
230
+ });
231
+ trace.event("resolve", "semver-auto", String(context.semverAuto), {
232
+ reason: existing?.options?.semverAuto != null ? "version.yml 저장값 보존"
233
+ : (existing ? "기존 통합 레포 → 예고 없는 버전 상승 방지를 위해 false"
234
+ : "신규 통합 → true"),
235
+ });
236
+ trace.event("resolve", "app-release", String(context.appRelease), {
237
+ reason: existing?.options?.appRelease != null ? "version.yml 저장값 보존" : "미설정(키를 쓰지 않음)",
238
+ });
239
+
156
240
  let result = null;
157
- // 실행 트레이스 (#494) 비대화형도 이벤트 기록 (터미널 미러는 CLI 실행에서만 유의미하므로 함께 켠다)
158
- const trace = createRunTrace();
159
- const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
241
+ // trace/recordArtifacts는 감지 단계 기록을 위해 위에서 이미 생성했다 (#561)
160
242
  let breakingReport = null;
161
243
  let migrationsResult = null;
162
244
  let orphanPending = [];
163
- if (recordArtifacts) trace.mirrorStart();
245
+ // 실행 경계(#561) — 로그만 보고 "무엇을 어떤 인자로 돌렸는지"를 알 수 있어야 한다.
246
+ trace.event("run", "start", opts.mode || "", {
247
+ cli: "non-interactive", types, version, branch,
248
+ force: true, deploy: deployTarget, publish: publishTargets, intent,
249
+ });
164
250
  try {
165
- acquireTemplate({ tempDir, source });
251
+ trace.step("acquire-template", () => acquireTemplate({ tempDir, source }), { source: source?.type || "git" });
166
252
  context.templateVersion = readTemplateVersion(tempDir);
253
+ trace.event("detect", "template-version", context.templateVersion, { tempDir: PATHS.tempDir });
167
254
 
168
255
  // 비대화형 축약 배너 (#446 확정 — 1줄, 로그 오염 최소)
169
256
  printBannerCompact({ version: context.templateVersion, mode: opts.mode });
170
257
 
171
258
  // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
172
- const proceed = await runBreakingCheck({
259
+ const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
173
260
  cwd, tempDir, templateVersion: context.templateVersion,
174
261
  onItems: (items) => { breakingReport = items; },
175
- });
176
- if (!proceed) return 0;
262
+ }));
263
+ trace.event("breaking", "result", proceed ? "proceed" : "halt", { items: (breakingReport ?? []).length });
264
+ if (!proceed) {
265
+ trace.event("run", "cancelled", "breaking-gate", { reason: "호환성 경고로 중단" });
266
+ return 0;
267
+ }
177
268
 
178
269
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
179
270
  if (recordArtifacts) {
180
- migrationsResult = await runMigrations({ targetRoot: cwd });
271
+ migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd }));
181
272
  for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
182
273
  for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
183
274
  }
184
275
 
185
276
  // 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
186
277
  if (recordArtifacts) {
187
- const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
278
+ const orphans = trace.step("orphan-scan",
279
+ () => detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types }),
280
+ { selectedTypes: types });
188
281
  orphanPending = orphans.map((o) => o.filename);
282
+ for (const o of orphans) trace.event("orphan", "detected", o.filename, { type: o.type, action: "안내만(비대화형)" });
189
283
  for (const o of orphans) {
190
284
  console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
191
285
  }
192
286
  }
193
287
 
194
288
  switch (opts.mode) {
195
- case "full": result = runFull(context, tempDir, cwd, { trace }); break;
196
- case "version": result = runVersion(context, tempDir, cwd); break;
197
- case "workflows": result = runWorkflows(context, tempDir, cwd, { trace }); break;
198
- case "issues": result = runIssues(context, tempDir, cwd); break;
289
+ case "full": result = trace.step("install-full", () => runFull(context, tempDir, cwd, { trace })); break;
290
+ case "version": result = trace.step("install-version", () => runVersion(context, tempDir, cwd)); break;
291
+ case "workflows": result = trace.step("install-workflows", () => runWorkflows(context, tempDir, cwd, { trace })); break;
292
+ case "issues": result = trace.step("install-issues", () => runIssues(context, tempDir, cwd)); break;
199
293
  default:
200
294
  // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
201
295
  break;
202
296
  }
297
+ } catch (err) {
298
+ // 실패 원인을 로그에 남긴다 (#561) — 서버 로그처럼 사후에 바로 짚을 수 있어야 한다.
299
+ trace.event("run", "error", opts.mode || "", {
300
+ message: err?.message || String(err),
301
+ stack: String(err?.stack || "").split("\n").slice(0, 3).join(" | "),
302
+ });
303
+ throw err;
203
304
  } finally {
204
- trace.mirrorStop();
305
+ // 예외로 빠져나가도 기록을 남긴다 (#561). finalize는 멱등 — 정상 경로에서 이미
306
+ // 호출됐으면 여기서는 아무 일도 하지 않는다.
307
+ if (recordArtifacts) {
308
+ trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
309
+ }
310
+ disarmSignals();
205
311
  remove(tempDir);
206
312
  }
207
313
 
208
314
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
209
315
  let migrationGuidePath = null;
316
+ // 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
317
+ // 그래야 터미널 미러에 완료 화면까지 담긴다 (#561).
318
+ const files = recordArtifacts
319
+ ? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now })
320
+ : null;
210
321
  if (recordArtifacts) {
211
- const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
212
322
  migrationGuidePath = appendGuideEntry(cwd, {
213
323
  now, mode: opts.mode, types, repoName,
214
324
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
215
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto },
325
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease },
216
326
  branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
217
327
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
218
328
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -225,6 +335,20 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
225
335
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
226
336
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
227
337
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
338
+ logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
339
+ logFile: files?.logFile ?? null,
340
+ traceFile: files?.traceFile ?? null,
228
341
  }, cwd);
342
+
343
+ // 완료 화면까지 캡처한 뒤 종료하고 기록한다 (#561)
344
+ trace.event("run", "end", opts.mode || "", {
345
+ workflowsCopied: result?.workflows?.copied ?? 0,
346
+ workflowsSkipped: result?.workflows?.skipped ?? 0,
347
+ });
348
+ if (recordArtifacts) {
349
+ trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
350
+ } else {
351
+ trace.mirrorStop();
352
+ }
229
353
  return 0;
230
354
  }
package/src/ui/summary.js CHANGED
@@ -130,6 +130,17 @@ export function printSummary(ctx, targetRoot = ".") {
130
130
  err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
131
131
  err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
132
132
  // #493 — 이번 실행의 마이그레이션 기록. "뭐가 남았고 AI에게 어떻게 시키는지"가 바로 보이게 행동 유도형으로 안내.
133
+ // 실행 기록 위치 (#561) — 무슨 일이 있었는지 나중에 확인할 자리를 알린다.
134
+ // 폴더가 아니라 이번 실행의 파일을 정확히 짚어준다 — 실행이 쌓이면 어느 것이 이번 건인지
135
+ // 모른다. 붙여넣어 바로 열 수 있는 경로가 목적이다.
136
+ if (ctx?.logFile || ctx?.logDir) {
137
+ err(` 📁 실행 로그: ${ctx.logFile || `${ctx.logDir}/`}`);
138
+ if (ctx.traceFile) err(` 이벤트(JSONL): ${ctx.traceFile}`);
139
+ err(" 이번 실행에서 무엇을 어떤 근거로 정했는지 전부 기록돼 있습니다.");
140
+ err(" 문제가 생기면 이 파일을 확인하세요 (저장소에 추적되지 않습니다).");
141
+ err(" 💡 AI Agent에게 \"실행 로그 확인해줘\"라고 요청하면 원인을 짚어줍니다.");
142
+ err("");
143
+ }
133
144
  if (ctx?.migrationGuidePath) {
134
145
  err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
135
146
  err(" 이번 설치에서 바뀐 내용과 직접 확인해야 할 작업이 이 파일에 정리되어 있습니다.");