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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/help.js +4 -1
- package/src/commands/doctor.js +257 -0
- package/src/commands/full.js +32 -15
- package/src/commands/interactive.js +55 -14
- package/src/commands/workflows.js +12 -2
- package/src/core/baseline.js +85 -0
- package/src/core/copy/workflows.js +106 -19
- package/src/core/migration-guide.js +2 -2
- package/src/core/run-trace.js +164 -9
- package/src/index.js +143 -19
- package/src/ui/summary.js +11 -0
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(" 이번 설치에서 바뀐 내용과 직접 확인해야 할 작업이 이 파일에 정리되어 있습니다.");
|