project-auto-wizard 0.8.2 → 0.9.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 +17 -8
- package/package.json +1 -1
- package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
- package/payload/scripts/issue_helper.py +6 -2
- package/payload/version.yml.template +1 -1
- package/src/cli/args.js +2 -2
- package/src/cli/help.js +1 -1
- package/src/commands/full.js +29 -28
- package/src/commands/interactive.js +3 -1
- package/src/commands/purge.js +6 -5
- package/src/commands/uninstall.js +5 -4
- package/src/core/copy/workflows.js +49 -15
- package/src/core/deploy-style.js +9 -1
- package/src/core/logger.js +141 -0
- package/src/index.js +27 -4
- package/src/ui/env-plan.js +14 -4
- package/src/ui/prompts.js +10 -4
- package/src/ui/summary.js +9 -4
- package/src/core/install-log.js +0 -182
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ npx project-auto-wizard
|
|
|
17
17
|
[](package.json)
|
|
18
18
|
|
|
19
19
|
<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
|
|
20
|
-
## 최신 버전 : v0.
|
|
20
|
+
## 최신 버전 : v0.9.0 (2026-09-18)
|
|
21
21
|
|
|
22
22
|
[전체 버전 기록 보기](CHANGELOG.md)
|
|
23
23
|
|
|
@@ -67,19 +67,28 @@ flutter.APP_ARTIFACT_NAME:
|
|
|
67
67
|
`spring`/`flutter`는 아래처럼 단일 CI 이상으로 깊게 구성되어 있습니다:
|
|
68
68
|
|
|
69
69
|
- **flutter**: Android(Firebase/Playstore/Selfhosted/TestAPK 배포), iOS(TestFlight/Test-TestFlight), CI, Lab 트리거까지 8종
|
|
70
|
-
- **spring**: 서버 배포 1종(단일 서버 / 무중단 Nginx / 무중단 Traefik 중 택1) + PR 프리뷰 + 라이브러리 publish 2종(Nexus·GitHub Packages, `--nexus` opt-in)
|
|
71
|
-
- 서버 배포 워크플로우는 **서로 대체재**라 하나만 설치합니다. 대화형에서 고르면 그것만 깔리고 **`push` 트리거까지 켜진 채로** 설치됩니다. 비대화형은 `--deploy-style simple|nginx|traefik` (기본: `simple`).
|
|
70
|
+
- **spring**: 서버 배포 1종(단일 서버 / 무중단 Nginx / 무중단 Traefik / 배포 안 함 중 택1) + PR 프리뷰 + 라이브러리 publish 2종(Nexus·GitHub Packages, `--nexus` opt-in)
|
|
71
|
+
- 서버 배포 워크플로우는 **서로 대체재**라 하나만 설치합니다. 대화형에서 고르면 그것만 깔리고 **`push` 트리거까지 켜진 채로** 설치됩니다. 비대화형은 `--deploy-style simple|nginx|traefik|none` (기본: `simple`).
|
|
72
72
|
- 고른 방식은 `version.yml`에 기록되므로 다시 실행해도 묻지 않습니다. 방식을 바꾸면 **이전 워크플로우를 마법사가 정리합니다** — 손대지 않은 파일은 삭제하고, 수정한 파일은 `.bak`으로 옮겨 내용을 보존합니다. 남겨두면 배포가 두 번 돕니다.
|
|
73
|
-
- PR 프리뷰는 배포 방식과 무관한 별개 축이라 선택과 관계없이 함께
|
|
73
|
+
- PR 프리뷰는 배포 방식과 무관한 별개 축이라 선택과 관계없이 함께 설치됩니다 (단, `none`을 고르면 PR 프리뷰도 함께 제외됩니다 — 서버 배포 자체를 하지 않는 프로젝트를 위한 선택지입니다).
|
|
74
74
|
- **react/next**: CI와 CI+CD 분리 구성
|
|
75
75
|
- **python**: CI / PR 프리뷰 / SimpleCICD
|
|
76
76
|
- **go**: CI(Dockerfile 불필요, go test/vet/build/lint) / PR 프리뷰 / SimpleCICD(Dockerfile 있는 프로젝트만 해당)
|
|
77
77
|
|
|
78
|
-
###
|
|
78
|
+
### 실행 로그 (`.github/.wizard/logs/`)
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
설치·업데이트·삭제를 실행할 때마다 `.github/.wizard/logs/<시각>-<동작>.log`에 실행 추적이 남습니다. 감지 근거, **파일별 처리 결정과 그 사유**, 치환된 값, 미치환 항목, 등록해야 하는 GitHub Secret이 시간순으로 기록되고, 파일 끝에 결과 요약이 붙습니다.
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
```
|
|
83
|
+
07:46:01 INFO detect type spring (근거: build.gradle)
|
|
84
|
+
07:46:01 INFO copy write PROJECT-SPRING-SIMPLE-CICD.yaml (new)
|
|
85
|
+
07:46:01 INFO copy keep-local PROJECT-COMMON-VERSION-CONTROL.yaml (업스트림 무변경, 사용자 수정본 유지)
|
|
86
|
+
07:46:01 WARN verify unresolved PROJECT-SPRING-PR-PREVIEW.yaml:43 __APPLICATION_YML_PATH__
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
업데이트에서 "내가 고친 워크플로우가 유지됐는지 덮였는지"를 이 로그로 확인할 수 있습니다. 한 줄씩 즉시 기록하므로 도중에 중단되어도 직전까지의 흐름이 남습니다. 로그 기록에 실패해도 설치 자체는 정상 완료됩니다.
|
|
90
|
+
|
|
91
|
+
이 폴더에는 자체 `.gitignore`(`*`, `!.gitignore`)가 함께 생성되어 **로그가 git에 올라가지 않습니다**. 최근 20개만 보관하고 오래된 것부터 정리합니다. `--dry-run`은 파일을 만들지 않는 것이 계약이므로 로그도 남기지 않습니다.
|
|
83
92
|
|
|
84
93
|
`.github/.wizard/`에는 업데이트 3-way 판정에 쓰는 `baseline.json`도 함께 들어 있어, 완전 삭제 시 워크플로우와 함께 제거됩니다.
|
|
85
94
|
|
|
@@ -144,7 +153,7 @@ npx project-auto-wizard [옵션]
|
|
|
144
153
|
--paths "t=p,..." 모노레포 타입별 경로
|
|
145
154
|
--main-branch B 릴리스 브랜치 (기본: 감지된 default branch)
|
|
146
155
|
--develop-branch B 개발 브랜치 (기본: develop)
|
|
147
|
-
--deploy-style S 서버 배포 방식: simple | nginx | traefik (기본: simple)
|
|
156
|
+
--deploy-style S 서버 배포 방식: simple | nginx | traefik | none (기본: simple)
|
|
148
157
|
--nexus 라이브러리 publish 워크플로우 포함 (Nexus + GitHub Packages)
|
|
149
158
|
--secret-backup Secret 서버 백업 워크플로우 포함
|
|
150
159
|
--semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함, --no-semver-auto로 끔)
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -61,11 +61,15 @@ def extract_issue_number(issue_url):
|
|
|
61
61
|
return parts[-1] if parts and parts[-1] else ""
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
_BRANCH_ISSUE_HASH_RE = re.compile(r"#(\d+)")
|
|
65
|
+
_BRANCH_ISSUE_WORD_RE = re.compile(r"issues?[-_/](\d+)", re.IGNORECASE)
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
def extract_issue_number_from_branch(branch_name):
|
|
68
|
-
match =
|
|
69
|
+
match = _BRANCH_ISSUE_HASH_RE.search(branch_name)
|
|
70
|
+
if match:
|
|
71
|
+
return match.group(1)
|
|
72
|
+
match = _BRANCH_ISSUE_WORD_RE.search(branch_name)
|
|
69
73
|
return match.group(1) if match else None
|
|
70
74
|
|
|
71
75
|
|
|
@@ -47,5 +47,5 @@ metadata:
|
|
|
47
47
|
nexus: {{OPT_NEXUS}}
|
|
48
48
|
secret_backup: {{OPT_SECRET_BACKUP}}
|
|
49
49
|
semver_auto: {{OPT_SEMVER_AUTO}}
|
|
50
|
-
deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik (서버 배포 워크플로우)
|
|
50
|
+
deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik | none (서버 배포 워크플로우)
|
|
51
51
|
{{DEPLOY}}
|
package/src/cli/args.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// CLI 인자 파싱 (.sh top-level while-case 등가) — template_integrator.sh 842~920.
|
|
2
2
|
import { VALID_TYPES, VALID_MODES } from "../context.js";
|
|
3
|
-
import { DEPLOY_STYLES, isDeployStyle } from "../core/deploy-style.js";
|
|
3
|
+
import { DEPLOY_STYLES, isDeployStyle, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
|
|
4
4
|
|
|
5
5
|
// argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
|
|
6
6
|
export function parseArgs(argv) {
|
|
@@ -80,7 +80,7 @@ export function parseArgs(argv) {
|
|
|
80
80
|
case "--deploy-style": {
|
|
81
81
|
const v = args.shift();
|
|
82
82
|
if (!isDeployStyle(v)) {
|
|
83
|
-
throw new CliError(`--deploy-style 값이 올바르지 않습니다: ${v ?? "(없음)"} (${DEPLOY_STYLES.map((s) => s.value).join(" | ")})`);
|
|
83
|
+
throw new CliError(`--deploy-style 값이 올바르지 않습니다: ${v ?? "(없음)"} (${[...DEPLOY_STYLES.map((s) => s.value), NO_DEPLOY_STYLE].join(" | ")})`);
|
|
84
84
|
}
|
|
85
85
|
result.deployStyle = v; break;
|
|
86
86
|
}
|
package/src/cli/help.js
CHANGED
|
@@ -16,7 +16,7 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
|
|
|
16
16
|
--paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
|
|
17
17
|
--main-branch B 릴리스 브랜치 (기본: 감지된 default branch)
|
|
18
18
|
--develop-branch B 개발 브랜치 (기본: develop). 릴리스 브랜치와 같으면 trunk-based 모드
|
|
19
|
-
--deploy-style STYLE 서버 배포 방식: simple | nginx | traefik (기본: simple)
|
|
19
|
+
--deploy-style STYLE 서버 배포 방식: simple | nginx | traefik | none (기본: simple)
|
|
20
20
|
--nexus / --no-nexus 라이브러리 publish 워크플로우 포함/제외 (Nexus + GitHub Packages)
|
|
21
21
|
--secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
|
|
22
22
|
--semver-auto / --no-semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함)
|
package/src/commands/full.js
CHANGED
|
@@ -16,7 +16,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
|
|
|
16
16
|
import { readBaseline, writeBaseline } from "../core/baseline.js";
|
|
17
17
|
import { scanUnsubstituted, collectRequiredSecrets, narrowSecretsBySshAuth } from "../core/verify.js";
|
|
18
18
|
import { cleanupOtherDeployWorkflows, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
|
|
19
|
-
import {
|
|
19
|
+
import { log, maskValue } from "../core/logger.js";
|
|
20
20
|
|
|
21
21
|
// context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
|
|
22
22
|
// force, repoName, resolvers, now, today }
|
|
@@ -31,7 +31,16 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
|
31
31
|
// 대표 마커명이 아니라 그 폴더에 실제로 있는 파일을 쓴다 — build.gradle.kts만 있는 레포의
|
|
32
32
|
// version.yml에 "# build.gradle"이라고 적히면 감지 로그와 같은 종류의 거짓말이 된다 (이슈 #77).
|
|
33
33
|
const pathMarkers = new Map();
|
|
34
|
-
for (const [t, p] of paths)
|
|
34
|
+
for (const [t, p] of paths) {
|
|
35
|
+
const marker = existingMarkerInDir(t, join(targetRoot, p || "."));
|
|
36
|
+
pathMarkers.set(t, marker);
|
|
37
|
+
log.info("detect", "type", `${t} (근거: ${marker || "직접 선택"})`);
|
|
38
|
+
}
|
|
39
|
+
log.info("detect", "version", `${version}${context.versionSource ? ` (${context.versionSource})` : ""}`);
|
|
40
|
+
log.info("detect", "branch", `${branch}${context.branches ? ` | main=${context.branches.main} develop=${context.branches.develop} mode=${context.branches.mode}` : ""}`);
|
|
41
|
+
for (const a of context.envAnswers || []) {
|
|
42
|
+
log.info("prompt", a.isDefault ? "default" : "answer", `${a.key}=${maskValue(a.key, a.value)}`);
|
|
43
|
+
}
|
|
35
44
|
|
|
36
45
|
// 1. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
|
|
37
46
|
// hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
|
|
@@ -46,6 +55,8 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
|
46
55
|
writeText(join(targetRoot, PATHS.versionFile),
|
|
47
56
|
renderVersionYml(context, readVersionYmlTemplate(payloadRoot), { pathMarkers, deployValues, extraTopLevel }));
|
|
48
57
|
|
|
58
|
+
log.info("version", "write", `version.yml (v${version}, code=${versionCode})`);
|
|
59
|
+
|
|
49
60
|
// 3. README 버전 섹션
|
|
50
61
|
addVersionSectionToReadme(version, targetRoot);
|
|
51
62
|
|
|
@@ -67,6 +78,8 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
|
67
78
|
// 지운 파일의 기준점은 baseline에서도 빼야 다음 실행에서 "사용자가 지웠다"로 오인하지 않는다.
|
|
68
79
|
for (const f of [...cleanup.removed, ...cleanup.backedUp]) delete previousBaseline?.files?.[f];
|
|
69
80
|
|
|
81
|
+
for (const f of cleanup.removed || []) log.info("cleanup", "remove", `${f} (이전 배포 방식 정리)`);
|
|
82
|
+
for (const f of cleanup.backedUp || []) log.info("cleanup", "backup", `${f} → ${f}.bak`);
|
|
70
83
|
const gitignoreUpdated = gitignoreUpdated0 || cleanup.backedUp.length > 0;
|
|
71
84
|
if (gitignoreUpdated) ensureGitignore(targetRoot);
|
|
72
85
|
|
|
@@ -95,33 +108,21 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
|
95
108
|
firstDeployValue(deployValues, "SSH_AUTH_METHOD"),
|
|
96
109
|
);
|
|
97
110
|
|
|
98
|
-
// 9.
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
secretBackup: includeSecretBackup,
|
|
112
|
-
semverAuto: includeSemverAuto !== false,
|
|
113
|
-
deployStyle: context.deployStyle || "",
|
|
114
|
-
},
|
|
115
|
-
answers: context.envAnswers || [],
|
|
116
|
-
warnings: context.detectWarnings || [],
|
|
117
|
-
result: {
|
|
118
|
-
copiedFiles: wfCounters.copiedFiles || [],
|
|
119
|
-
gitignoreUpdated,
|
|
120
|
-
},
|
|
121
|
-
unresolved, secrets, cleanup,
|
|
122
|
-
});
|
|
111
|
+
// 9. 요약 — 파일 끝에 결과 블록을 붙인다. tail만 봐도 결과가 보이도록.
|
|
112
|
+
for (const u of unresolved) log.warn("verify", "unresolved", `${u.filename}:${u.line} ${u.token}`);
|
|
113
|
+
for (const [name, users] of secrets) log.info("verify", "secret", `${name} ← ${users.join(", ")}`);
|
|
114
|
+
log.summary([
|
|
115
|
+
["설치", `${(wfCounters.copiedFiles || []).length}개 파일`],
|
|
116
|
+
["자동 갱신", `${(wfCounters.autoUpdated || []).length}개 (사용자 미수정)`],
|
|
117
|
+
["유지", `${(wfCounters.keptLocal || []).length}개 (사용자 수정본)`],
|
|
118
|
+
["변경 없음", `${(wfCounters.unchangedFiles || []).length}개`],
|
|
119
|
+
["백업 교체", `${wfCounters.backupAdded || 0}개 (.bak 생성)`],
|
|
120
|
+
["미치환", `${unresolved.length}건${unresolved.length ? " ← 조치 필요" : ""}`],
|
|
121
|
+
["필요 Secret", `${secrets.size}개`],
|
|
122
|
+
["결과", unresolved.length ? `주의 (미치환 ${unresolved.length}건)` : "OK"],
|
|
123
|
+
]);
|
|
123
124
|
|
|
124
|
-
return { workflows: wfCounters, gitignoreUpdated, unresolved, secrets,
|
|
125
|
+
return { workflows: wfCounters, gitignoreUpdated, unresolved, secrets, cleanup };
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
// deployValues는 Map<type, Map<key,value>> — 타입 구분 없이 첫 값만 필요할 때 쓴다.
|
|
@@ -20,6 +20,7 @@ import { runUninstallFlow } from "./uninstall.js";
|
|
|
20
20
|
import * as prompts from "../ui/prompts.js";
|
|
21
21
|
import { runStatus, printStatus } from "./status.js";
|
|
22
22
|
import { runDoctor, printDoctorReport } from "./doctor.js";
|
|
23
|
+
import { currentLogPath, hasLegacyMdLogs } from "../core/logger.js";
|
|
23
24
|
|
|
24
25
|
const CANCEL = prompts.CANCEL;
|
|
25
26
|
const isCancel = (v) => v === CANCEL || typeof v === "symbol";
|
|
@@ -305,7 +306,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
305
306
|
answers: envAnswers,
|
|
306
307
|
unresolved: result?.unresolved ?? [],
|
|
307
308
|
secrets: result?.secrets ?? new Map(),
|
|
308
|
-
|
|
309
|
+
logPath: currentLogPath(),
|
|
310
|
+
legacyMdLogs: hasLegacyMdLogs(cwd),
|
|
309
311
|
cleanup: result?.cleanup ?? null,
|
|
310
312
|
});
|
|
311
313
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
package/src/commands/purge.js
CHANGED
|
@@ -9,6 +9,7 @@ import { PATHS } from "../core/paths.js";
|
|
|
9
9
|
import { remove } from "../core/fsutil.js";
|
|
10
10
|
import { planRemoval } from "../core/removal-plan.js";
|
|
11
11
|
import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
|
|
12
|
+
import { log } from "../core/logger.js";
|
|
12
13
|
|
|
13
14
|
const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
|
|
14
15
|
|
|
@@ -54,12 +55,12 @@ export function printPurgePlan(plan, { dryRun = false } = {}) {
|
|
|
54
55
|
export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
|
|
55
56
|
const plan = planPurge(payloadRoot, targetRoot, keepFlags);
|
|
56
57
|
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
57
|
-
for (const name of plan.workflows) remove(join(wfDir, name));
|
|
58
|
-
for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
|
|
59
|
-
for (const p of plan.baseline || []) remove(join(targetRoot, p));
|
|
60
|
-
if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
|
|
58
|
+
for (const name of plan.workflows) { remove(join(wfDir, name)); log.info("remove", "workflow", name); }
|
|
59
|
+
for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); log.info("remove", "script", name); }
|
|
60
|
+
for (const p of plan.baseline || []) { remove(join(targetRoot, p)); log.info("remove", "metadata", p); }
|
|
61
|
+
if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); log.info("remove", "version", PATHS.versionFile); }
|
|
61
62
|
const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
|
|
62
|
-
for (const f of plan.changelog) remove(join(targetRoot, f));
|
|
63
|
+
for (const f of plan.changelog) { remove(join(targetRoot, f)); log.info("remove", "changelog", f); }
|
|
63
64
|
return { ...plan, readmeSection };
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -9,6 +9,7 @@ import { planRemoval } from "../core/removal-plan.js";
|
|
|
9
9
|
import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
|
|
10
10
|
import { removeAutoAddedEntriesFromGitignore, hasAutoAddedEntries } from "../core/copy/gitignore.js";
|
|
11
11
|
import { CANCEL } from "../ui/prompts.js";
|
|
12
|
+
import { log } from "../core/logger.js";
|
|
12
13
|
|
|
13
14
|
// selection: { workflows, scripts, readme, gitignore, versionYml } (모두 boolean).
|
|
14
15
|
// 반환: 위와 동일한 키의 boolean/배열 — 실제로 제거 "대상"인지 여부(순수 함수, 아무것도 지우지 않음).
|
|
@@ -29,16 +30,16 @@ export function planUninstall(payloadRoot, targetRoot, selection) {
|
|
|
29
30
|
export function runUninstall(context, payloadRoot, targetRoot, selection) {
|
|
30
31
|
const plan = planUninstall(payloadRoot, targetRoot, selection);
|
|
31
32
|
const wfDir = join(targetRoot, PATHS.workflowsDir);
|
|
32
|
-
for (const name of plan.workflows) remove(join(wfDir, name));
|
|
33
|
-
for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
|
|
34
|
-
for (const p of plan.baseline || []) remove(join(targetRoot, p));
|
|
33
|
+
for (const name of plan.workflows) { remove(join(wfDir, name)); log.info("remove", "workflow", name); }
|
|
34
|
+
for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); log.info("remove", "script", name); }
|
|
35
|
+
for (const p of plan.baseline || []) { remove(join(targetRoot, p)); log.info("remove", "metadata", p); }
|
|
35
36
|
// removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
|
|
36
37
|
// 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
|
|
37
38
|
// 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
|
|
38
39
|
const readmeRemoved = plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
|
|
39
40
|
const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
|
|
40
41
|
const gitignoreRemoved = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
|
|
41
|
-
if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
|
|
42
|
+
if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); log.info("remove", "version", PATHS.versionFile); }
|
|
42
43
|
return { ...plan, readme: readmeRemoved, gitignore: gitignoreRemoved };
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
|
|
4
4
|
// 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
|
|
5
5
|
import { join, basename } from "node:path";
|
|
6
|
-
import { deployFilter, isDeployWorkflow, activateDeployTrigger, DEFAULT_DEPLOY_STYLE } from "../deploy-style.js";
|
|
6
|
+
import { deployFilter, isDeployWorkflow, activateDeployTrigger, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE } from "../deploy-style.js";
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
8
8
|
import { PATHS, PAYLOAD } from "../paths.js";
|
|
9
9
|
import { exists, writeText, listYamlFiles } from "../fsutil.js";
|
|
10
10
|
import { substituteEnv } from "../wizard-env.js";
|
|
11
11
|
import { substitute } from "../branding.js";
|
|
12
12
|
import { sha256, readBaseline } from "../baseline.js";
|
|
13
|
+
import { log } from "../logger.js";
|
|
13
14
|
|
|
14
15
|
// 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
|
|
15
16
|
// classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
|
|
@@ -97,20 +98,37 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
|
|
|
97
98
|
const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
|
|
98
99
|
const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
|
|
99
100
|
|
|
100
|
-
for (const f of c.unchanged.filter(filter)) {
|
|
101
|
+
for (const f of c.unchanged.filter(filter)) {
|
|
102
|
+
counters.skipped++; counters.unchangedFiles.push(f); track(f, false);
|
|
103
|
+
log.info("copy", "skip", `${f} (unchanged)`);
|
|
104
|
+
}
|
|
101
105
|
|
|
102
|
-
for (const f of c.localOnly.filter(filter)) {
|
|
106
|
+
for (const f of c.localOnly.filter(filter)) {
|
|
107
|
+
counters.skipped++; counters.keptLocal.push(f); track(f, false);
|
|
108
|
+
log.info("copy", "keep-local", `${f} (업스트림 무변경, 사용자 수정본 유지)`);
|
|
109
|
+
}
|
|
103
110
|
|
|
104
|
-
for (const f of c.newFiles.filter(filter))
|
|
111
|
+
for (const f of c.newFiles.filter(filter)) {
|
|
112
|
+
write(f);
|
|
113
|
+
log.info("copy", "write", `${f} (new)`);
|
|
114
|
+
}
|
|
105
115
|
|
|
106
|
-
for (const f of c.upstreamOnly.filter(filter)) {
|
|
116
|
+
for (const f of c.upstreamOnly.filter(filter)) {
|
|
117
|
+
write(f); counters.autoUpdated.push(f);
|
|
118
|
+
log.info("copy", "auto-update", `${f} (사용자 미수정, 최신으로 교체)`);
|
|
119
|
+
}
|
|
107
120
|
|
|
108
121
|
// 사용자가 지운 파일은 조용히 되살리지 않는다. 복원 결정이 있을 때만 다시 쓴다.
|
|
109
122
|
// 되살리지 않은 파일은 baselineTargets에 넣지 않는다 — 디스크에 없어 해시할 것이 없고,
|
|
110
123
|
// 기존 baseline 항목은 병합으로 남아 다음 실행에서도 "지운 파일"로 인식된다.
|
|
111
124
|
for (const f of c.removed.filter(filter)) {
|
|
112
|
-
if (restoreRemoved.has(f)) {
|
|
113
|
-
|
|
125
|
+
if (restoreRemoved.has(f)) {
|
|
126
|
+
write(f); counters.restoredFiles.push(f);
|
|
127
|
+
log.info("copy", "restore", `${f} (사용자가 지웠지만 복원 결정)`);
|
|
128
|
+
} else {
|
|
129
|
+
counters.removedKept.push(f);
|
|
130
|
+
log.info("copy", "removed-kept", `${f} (사용자가 지움, 되살리지 않음)`);
|
|
131
|
+
}
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
for (const f of c.changed.filter(filter)) {
|
|
@@ -141,6 +159,7 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
|
|
|
141
159
|
const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
|
|
142
160
|
counters.deployValues = deployValues;
|
|
143
161
|
counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (issue #19 — printSummary 정확성용)
|
|
162
|
+
counters.unchangedFiles = []; // skip(unchanged) 대상 — 로그에서 "왜 안 바뀌었나"의 근거
|
|
144
163
|
counters.autoUpdated = []; // 질문 없이 최신으로 교체된 파일 (사용자 미수정)
|
|
145
164
|
counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
|
|
146
165
|
counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
|
|
@@ -228,6 +247,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
|
|
|
228
247
|
counters.copied++;
|
|
229
248
|
counters.backupAdded++;
|
|
230
249
|
counters.copiedFiles.push(filename);
|
|
250
|
+
log.info("copy", "backup", `${filename} → ${filename}.bak (사용자 결정, 새 버전으로 교체)`);
|
|
231
251
|
return;
|
|
232
252
|
}
|
|
233
253
|
if (decision === "template") {
|
|
@@ -236,9 +256,11 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
|
|
|
236
256
|
writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
|
|
237
257
|
counters.templateAdded++;
|
|
238
258
|
counters.copiedFiles.push(templateName);
|
|
259
|
+
log.info("copy", "template", `${filename} 유지 + ${templateName} 생성 (사용자 결정)`);
|
|
239
260
|
return;
|
|
240
261
|
}
|
|
241
262
|
counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
|
|
263
|
+
log.info("copy", "skip", `${filename} (사용자 결정: 기존 유지)`);
|
|
242
264
|
}
|
|
243
265
|
|
|
244
266
|
// 대화형 사전 조사 — 사람이 답해야 하는 것만 뽑는다 (issue #69).
|
|
@@ -274,9 +296,13 @@ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
274
296
|
for (const type of types) {
|
|
275
297
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
276
298
|
const typeDir = join(projectTypesDir, type);
|
|
277
|
-
if (exists(typeDir))
|
|
299
|
+
if (exists(typeDir)) {
|
|
300
|
+
collect(typeDir, envOpts, type, () => false, deployStyle === NO_DEPLOY_STYLE ? keepDeploy : null);
|
|
301
|
+
}
|
|
278
302
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
279
|
-
if (exists(serverDeployDir) && !includeNexus
|
|
303
|
+
if (exists(serverDeployDir) && !includeNexus && deployStyle !== NO_DEPLOY_STYLE) {
|
|
304
|
+
collect(serverDeployDir, envOpts, type, () => false, keepDeploy);
|
|
305
|
+
}
|
|
280
306
|
}
|
|
281
307
|
return { conflicts, removed };
|
|
282
308
|
}
|
|
@@ -311,17 +337,20 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
311
337
|
// 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
|
|
312
338
|
const untouched = [];
|
|
313
339
|
|
|
314
|
-
// 타입별 워크플로우 (직하위)
|
|
340
|
+
// 타입별 워크플로우 (직하위). go/python처럼 CD 워크플로우가 server-deploy 없이 타입 루트에
|
|
341
|
+
// 바로 있는 타입도 있다 — "배포 안 함"일 때는 타입 루트에서도 CD 파일(SIMPLE-CICD 등)을
|
|
342
|
+
// 걸러야 한다. simple/nginx/traefik은 오늘과 동일하게 필터 없이 전부 복사한다.
|
|
315
343
|
if (exists(typeDir)) {
|
|
316
|
-
const
|
|
344
|
+
const typeRootFilter = deployStyle === NO_DEPLOY_STYLE ? keepDeploy : undefined;
|
|
345
|
+
const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters, typeRootFilter);
|
|
317
346
|
untouched.push(...c.unchanged, ...c.localOnly);
|
|
318
347
|
}
|
|
319
348
|
|
|
320
349
|
// server-deploy
|
|
321
350
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
322
351
|
if (exists(serverDeployDir)) {
|
|
323
|
-
if (includeNexus) {
|
|
324
|
-
// Nexus 프로젝트 → 폴더째 제외 (복사 안 함)
|
|
352
|
+
if (includeNexus || deployStyle === NO_DEPLOY_STYLE) {
|
|
353
|
+
// Nexus 프로젝트 또는 "배포 안 함" → 폴더째 제외 (복사 안 함)
|
|
325
354
|
} else {
|
|
326
355
|
const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters, keepDeploy);
|
|
327
356
|
untouched.push(...c.unchanged, ...c.localOnly);
|
|
@@ -341,9 +370,11 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
341
370
|
// env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
|
|
342
371
|
for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
|
|
343
372
|
if (!exists(srcDir)) continue;
|
|
373
|
+
if (srcDir === serverDeployDir && (includeNexus || deployStyle === NO_DEPLOY_STYLE)) continue; // 폴더째 제외
|
|
344
374
|
for (const filename of listYamlFiles(srcDir)) {
|
|
345
375
|
const target = join(workflowsDir, filename);
|
|
346
376
|
if (srcDir === serverDeployDir && !keepDeploy(filename)) continue; // 안 고른 배포 방식
|
|
377
|
+
if (srcDir === typeDir && deployStyle === NO_DEPLOY_STYLE && !keepDeploy(filename)) continue; // 타입 루트 CD도 배제
|
|
347
378
|
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
348
379
|
if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
|
|
349
380
|
configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
|
|
@@ -395,10 +426,13 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
395
426
|
for (const type of types) {
|
|
396
427
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
397
428
|
const typeDir = join(projectTypesDir, type);
|
|
398
|
-
if (exists(typeDir))
|
|
429
|
+
if (exists(typeDir)) {
|
|
430
|
+
const typeRootFilter = deployStyle === NO_DEPLOY_STYLE ? deployFilter(deployStyle) : null;
|
|
431
|
+
merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, typeRootFilter), type);
|
|
432
|
+
}
|
|
399
433
|
|
|
400
434
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
401
|
-
if (exists(serverDeployDir) && !includeNexus) {
|
|
435
|
+
if (exists(serverDeployDir) && !includeNexus && deployStyle !== NO_DEPLOY_STYLE) {
|
|
402
436
|
merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline, deployFilter(deployStyle)), type);
|
|
403
437
|
}
|
|
404
438
|
|
package/src/core/deploy-style.js
CHANGED
|
@@ -14,7 +14,13 @@ export const DEPLOY_STYLES = [
|
|
|
14
14
|
|
|
15
15
|
export const DEFAULT_DEPLOY_STYLE = "simple";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// 서버 배포 자체를 하지 않는 프로젝트(프론트엔드 전용, 라이브러리 등)를 위한 값.
|
|
18
|
+
// DEPLOY_STYLES에는 넣지 않는다 — isDeployWorkflow/suffixOf가 이 배열을 순회하는데, 빈 접미사를
|
|
19
|
+
// 돌려주면 endsWith("")가 항상 참이라 모든 파일이 CD로 오판되어 cleanupOtherDeployWorkflows가
|
|
20
|
+
// 설치된 워크플로우 전체를 지우는 회귀가 생긴다 (이슈 #126).
|
|
21
|
+
export const NO_DEPLOY_STYLE = "none";
|
|
22
|
+
|
|
23
|
+
export const isDeployStyle = (v) => v === NO_DEPLOY_STYLE || DEPLOY_STYLES.some((s) => s.value === v);
|
|
18
24
|
|
|
19
25
|
// 이 파일이 CD 본체인가 (= 택1 대상인가). PR 프리뷰는 배포 방식과 직교하는 축이라 제외한다.
|
|
20
26
|
export const isDeployWorkflow = (filename) => DEPLOY_STYLES.some((s) => filename.endsWith(s.suffix));
|
|
@@ -25,7 +31,9 @@ const suffixOf = (style) =>
|
|
|
25
31
|
(DEPLOY_STYLES.find((s) => s.value === style) ?? DEPLOY_STYLES.find((s) => s.value === DEFAULT_DEPLOY_STYLE)).suffix;
|
|
26
32
|
|
|
27
33
|
// 파일 필터 — 고른 방식의 CD만 통과. CD가 아닌 파일(PR 프리뷰·common 등)은 항상 통과.
|
|
34
|
+
// "none"은 CD를 하나도 설치하지 않으므로 접미사 매칭 없이 CD 파일 전부를 거른다.
|
|
28
35
|
export function deployFilter(style) {
|
|
36
|
+
if (style === NO_DEPLOY_STYLE) return (filename) => !isDeployWorkflow(filename);
|
|
29
37
|
const suffix = suffixOf(style);
|
|
30
38
|
return (filename) => !isDeployWorkflow(filename) || filename.endsWith(suffix);
|
|
31
39
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// 실행 추적 로그 — "무엇을 어떤 순서로 왜 그렇게 했는지"를 시간순으로 남긴다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 즉시 append인가: 디버깅에서 가장 알고 싶은 순간은 크래시 직전이다. 끝나고 한 번에
|
|
4
|
+
// 쓰는 구조는 예외가 나면 아무것도 남기지 못한다(구 install-log.js가 그랬다).
|
|
5
|
+
//
|
|
6
|
+
// 왜 로컬 전용인가: 상세도를 제약하지 않기 위해서다. 로그 디렉토리에 .gitignore를 직접
|
|
7
|
+
// 두어 그 폴더만 추적에서 뺀다 — 루트 .gitignore는 건드리지 않는다(이슈 #7 원칙).
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const LOG_DIR = ".github/.wizard/logs";
|
|
12
|
+
const KEEP = 20; // 유지할 로그 파일 수
|
|
13
|
+
const GITIGNORE_BODY = "*\n!.gitignore\n";
|
|
14
|
+
|
|
15
|
+
// 값에 비밀이 들어갈 수 있는 키 — 현재 질문 항목에는 없지만(도메인·경로·포트·인증 '방식'),
|
|
16
|
+
// 앞으로 추가될 때 그냥 평문으로 남지 않도록 처음부터 걸어둔다.
|
|
17
|
+
const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
|
|
18
|
+
const MASK = "***";
|
|
19
|
+
|
|
20
|
+
let state = null; // { file, clock, startedAt, disabled }
|
|
21
|
+
|
|
22
|
+
export function maskValue(key, value) {
|
|
23
|
+
// SSH_AUTH_METHOD처럼 "방식"만 담는 키는 비밀이 아니다 — 이름에 KEY가 들어가도 마스킹하지 않는다.
|
|
24
|
+
if (key === "SSH_AUTH_METHOD") return value;
|
|
25
|
+
return SECRET_KEY_RE.test(key) ? MASK : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// "2026-08-26 12:03:41" → "20260826-120341". 파일명이 곧 정렬 키가 되도록.
|
|
29
|
+
export function stampFrom(now = "") {
|
|
30
|
+
const m = String(now).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
|
31
|
+
if (!m) return "unknown";
|
|
32
|
+
return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function logFilename(now, action = "install") {
|
|
36
|
+
return `${stampFrom(now)}-${action}.log`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 최근 KEEP개만 남기고 오래된 것부터 지운다. 파일명이 시각 오름차순이라 이름 정렬로 충분하다.
|
|
40
|
+
// 새 파일이 곧 하나 추가되므로 KEEP-1개까지 줄인다.
|
|
41
|
+
function rotate(dir) {
|
|
42
|
+
const logs = readdirSync(dir).filter((f) => f.endsWith(".log")).sort();
|
|
43
|
+
for (const f of logs.slice(0, Math.max(0, logs.length - (KEEP - 1)))) {
|
|
44
|
+
rmSync(join(dir, f), { force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function initLogger(targetRoot, opts = {}) {
|
|
49
|
+
const { action = "install", now = "", argv = [], templateVersion = "unknown", clock = () => new Date() } = opts;
|
|
50
|
+
try {
|
|
51
|
+
const dir = join(targetRoot, LOG_DIR);
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
// 사용자가 직접 둔 .gitignore가 있으면 존중한다.
|
|
54
|
+
const gi = join(dir, ".gitignore");
|
|
55
|
+
if (!existsSync(gi)) writeFileSync(gi, GITIGNORE_BODY);
|
|
56
|
+
rotate(dir);
|
|
57
|
+
|
|
58
|
+
const rel = `${LOG_DIR}/${logFilename(now, action)}`;
|
|
59
|
+
const file = join(targetRoot, rel);
|
|
60
|
+
const header =
|
|
61
|
+
`=== project-auto-wizard v${templateVersion} | ${action} | ${now} ===\n` +
|
|
62
|
+
`argv : ${["project-auto-wizard", ...argv].join(" ")}\n` +
|
|
63
|
+
`node : ${process.version} | ${process.platform} ${process.arch}\n` +
|
|
64
|
+
`target : ${targetRoot}\n\n`;
|
|
65
|
+
writeFileSync(file, header);
|
|
66
|
+
state = { file, rel, clock, startedAt: Date.now(), disabled: false };
|
|
67
|
+
return { path: rel };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// 로그를 못 남긴 것이 설치를 되돌릴 이유는 아니다 — 다만 조용히 삼키지는 않는다.
|
|
70
|
+
process.stderr.write(`[warn] 실행 로그를 시작하지 못했습니다: ${e.message}\n`);
|
|
71
|
+
state = null;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resetLogger() {
|
|
77
|
+
state = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 이번 실행의 로그 경로(레포 상대). 설치 요약 화면이 사용자에게 안내할 때 쓴다.
|
|
81
|
+
export function currentLogPath() {
|
|
82
|
+
return state && !state.disabled ? state.rel : "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 구버전(.md) 설치 기록이 남아 있는지 — .gitignore는 이미 git이 추적 중인 파일에는
|
|
86
|
+
// 영향이 없으므로, 있으면 사용자가 직접 추적을 끊도록 안내해야 한다.
|
|
87
|
+
export function hasLegacyMdLogs(targetRoot) {
|
|
88
|
+
try {
|
|
89
|
+
const dir = join(targetRoot, LOG_DIR);
|
|
90
|
+
return existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".md"));
|
|
91
|
+
} catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 열 너비 — 사람이 훑을 때 컬럼이 맞고, 에이전트가 컬럼 단위로 끊어 읽을 수 있게 고정한다.
|
|
95
|
+
const SCOPE_W = 8; // 가장 긴 scope('baseline')에 맞춘다 — 컬럼이 밀리면 훑기가 나빠진다
|
|
96
|
+
const ACTION_W = 10;
|
|
97
|
+
|
|
98
|
+
// 동아시아 전각 문자는 폭 2로 센다 (요약 블록 정렬용).
|
|
99
|
+
const WIDE_RE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/;
|
|
100
|
+
const dispWidth = (s) => [...String(s)].reduce((n, c) => n + (WIDE_RE.test(c) ? 2 : 1), 0);
|
|
101
|
+
|
|
102
|
+
// 헤더의 실행 시각과 파일명이 UTC 기준(utcNow)이므로 라인 시각도 UTC로 맞춘다 —
|
|
103
|
+
// 로컬 시간을 쓰면 같은 파일 안에서 헤더와 라인이 시간대만큼 어긋난다.
|
|
104
|
+
function hhmmss(date) {
|
|
105
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
106
|
+
return `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}.${p(date.getUTCMilliseconds(), 3)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function write(level, scope, action, detail = "") {
|
|
110
|
+
if (!state || state.disabled) return;
|
|
111
|
+
try {
|
|
112
|
+
const line = `${hhmmss(state.clock())} ${level} ${String(scope).padEnd(SCOPE_W)} ${String(action).padEnd(ACTION_W)} ${detail}`.trimEnd();
|
|
113
|
+
appendFileSync(state.file, line + "\n");
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// 첫 실패에서 한 번만 알리고 이후는 조용히 끈다 — 매 줄 경고를 뱉으면 설치 화면이 무너진다.
|
|
116
|
+
state.disabled = true;
|
|
117
|
+
process.stderr.write(`[warn] 실행 로그 기록을 중단합니다: ${e.message}\n`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const log = {
|
|
122
|
+
info: (scope, action, detail) => write("INFO", scope, action, detail),
|
|
123
|
+
warn: (scope, action, detail) => write("WARN", scope, action, detail),
|
|
124
|
+
fail: (scope, action, detail) => write("FAIL", scope, action, detail),
|
|
125
|
+
// rows: Array<[label, value]> — 라벨 폭을 맞춰 정렬한다.
|
|
126
|
+
summary(rows = []) {
|
|
127
|
+
if (!state || state.disabled || !rows.length) return;
|
|
128
|
+
// 한글은 터미널에서 2칸을 차지한다 — 문자 수로 맞추면 눈으로 볼 때 어긋난다.
|
|
129
|
+
const w = Math.max(...rows.map(([k]) => dispWidth(k)));
|
|
130
|
+
const body = rows.map(([k, v]) => `${k}${" ".repeat(w - dispWidth(k))} : ${v}`).join("\n");
|
|
131
|
+
try {
|
|
132
|
+
appendFileSync(state.file, `\n=== 요약 ===\n${body}\n`);
|
|
133
|
+
} catch {
|
|
134
|
+
state.disabled = true;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function closeLogger() {
|
|
140
|
+
state = null;
|
|
141
|
+
}
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { runFull } from "./commands/full.js";
|
|
|
21
21
|
import { runUninstall, runUninstallFlow } from "./commands/uninstall.js";
|
|
22
22
|
import * as prompts from "./ui/prompts.js";
|
|
23
23
|
import { runInteractive } from "./commands/interactive.js";
|
|
24
|
+
import { initLogger, closeLogger, currentLogPath, hasLegacyMdLogs } from "./core/logger.js";
|
|
24
25
|
import { runStatus, printStatus } from "./commands/status.js";
|
|
25
26
|
import { runDoctor, printDoctorReport } from "./commands/doctor.js";
|
|
26
27
|
import { planDryRun, printDryRun } from "./commands/dry-run.js";
|
|
@@ -59,7 +60,7 @@ async function defaultPromptRepoName(repoName) {
|
|
|
59
60
|
// payloadRoot: 테스트 픽스처 주입점 (기본: 패키지 동봉 payload/)
|
|
60
61
|
// clock: {now, today} 주입 (기본 현재 UTC).
|
|
61
62
|
// exec/promptRepoName: purge 모드 안전장치 게이트용 주입점 (기본 실제 구현, 테스트는 mock 주입).
|
|
62
|
-
|
|
63
|
+
async function runInner(argv, {
|
|
63
64
|
cwd = process.cwd(), payloadRoot, clock,
|
|
64
65
|
exec = defaultExec, promptRepoName = defaultPromptRepoName,
|
|
65
66
|
} = {}) {
|
|
@@ -75,6 +76,19 @@ export async function run(argv, {
|
|
|
75
76
|
|
|
76
77
|
const payload = assertPayload(payloadRoot ?? resolvePayloadRoot());
|
|
77
78
|
|
|
79
|
+
// 시각은 여기서 한 번만 계산한다 — 로그 파일명과 설치 기록이 같은 값을 쓰도록.
|
|
80
|
+
const { now, today } = clock || utcNow();
|
|
81
|
+
|
|
82
|
+
// dry-run은 "파일을 바꾸지 않는다"가 계약이므로 로그도 남기지 않는다.
|
|
83
|
+
// --version/--help는 이 지점 이전에 이미 반환되므로 자연히 제외된다.
|
|
84
|
+
const loggedAction = opts.dryRun ? null
|
|
85
|
+
: opts.mode === "uninstall" ? "uninstall"
|
|
86
|
+
: opts.mode === "purge" ? "purge"
|
|
87
|
+
: "install";
|
|
88
|
+
if (loggedAction) {
|
|
89
|
+
initLogger(cwd, { action: loggedAction, now, argv, templateVersion: readTemplateVersion() });
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
// 대화형 모드 — 인자 없이 실행 or --mode interactive
|
|
79
93
|
if (opts.mode === "interactive") {
|
|
80
94
|
// --dry-run은 대화형 모드에서 조용히 무시되면 안 됨(실제 설치가 진행돼버림) — 명시 에러로 차단.
|
|
@@ -254,8 +268,6 @@ export async function run(argv, {
|
|
|
254
268
|
}
|
|
255
269
|
}
|
|
256
270
|
|
|
257
|
-
const { now, today } = clock || utcNow();
|
|
258
|
-
|
|
259
271
|
const context = createContext({
|
|
260
272
|
mode: opts.mode, force: opts.force, types, version, versionCode, branch,
|
|
261
273
|
branches,
|
|
@@ -304,8 +316,19 @@ export async function run(argv, {
|
|
|
304
316
|
gitignoreUpdated: result?.gitignoreUpdated === true,
|
|
305
317
|
unresolved: result?.unresolved ?? [],
|
|
306
318
|
secrets: result?.secrets ?? new Map(),
|
|
307
|
-
|
|
319
|
+
logPath: currentLogPath(),
|
|
320
|
+
legacyMdLogs: hasLegacyMdLogs(cwd),
|
|
308
321
|
cleanup: result?.cleanup ?? null,
|
|
309
322
|
});
|
|
310
323
|
return 0;
|
|
311
324
|
}
|
|
325
|
+
|
|
326
|
+
// 공개 진입점 — 어떤 경로로 끝나든(정상 반환·CliError·예외) 로거를 닫는다.
|
|
327
|
+
// 본문을 통째로 try로 감싸면 들여쓰기가 전부 바뀌므로 얇은 래퍼로 분리했다.
|
|
328
|
+
export async function run(argv, opts = {}) {
|
|
329
|
+
try {
|
|
330
|
+
return await runInner(argv, opts);
|
|
331
|
+
} finally {
|
|
332
|
+
closeLogger();
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/ui/env-plan.js
CHANGED
|
@@ -10,7 +10,7 @@ import { PAYLOAD } from "../core/paths.js";
|
|
|
10
10
|
import { exists, listYamlFiles } from "../core/fsutil.js";
|
|
11
11
|
import { parseWizardLine, resolveToken, replaceProjectTokens } from "../core/wizard-env.js";
|
|
12
12
|
import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
|
|
13
|
-
import { deployFilter } from "../core/deploy-style.js";
|
|
13
|
+
import { deployFilter, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
|
|
14
14
|
import * as engine from "./readline-engine.js";
|
|
15
15
|
|
|
16
16
|
const CANCEL = engine.CANCEL;
|
|
@@ -58,9 +58,19 @@ export function collectAsks(payloadRoot, types = [], opts = {}) {
|
|
|
58
58
|
for (const type of types) {
|
|
59
59
|
const typeDir = join(baseDir, type);
|
|
60
60
|
if (!exists(typeDir)) continue;
|
|
61
|
-
// 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (nexus
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (nexus면) nexus + (그 외엔, "배포 안 함"이
|
|
62
|
+
// 아닐 때만) server-deploy. server-deploy가 있는 타입은 "none"일 때 그 폴더(PR 프리뷰
|
|
63
|
+
// 포함) 자체를 스캔에서 뺀다.
|
|
64
|
+
// go/python처럼 CD가 server-deploy 없이 타입 루트에 바로 있는 타입은, "배포 안 함"일 때
|
|
65
|
+
// 타입 루트 스캔에도 keepDeploy를 걸어야 CD 전용 ask 키(예: DEPLOY_PORT)가 걸러진다.
|
|
66
|
+
// PR 프리뷰 자체의 ask 키(SSH_AUTH_METHOD 등)는 이 필터로는 걸러지지 않는다 — PR 프리뷰는
|
|
67
|
+
// 배포 방식과 무관하게 항상 설치되는 별도 축이라 의도된 잔여 범위다.
|
|
68
|
+
units.push([type, typeDir, deployStyle === NO_DEPLOY_STYLE ? keepDeploy : null]);
|
|
69
|
+
if (includeNexus) {
|
|
70
|
+
units.push([type, join(typeDir, "nexus"), null]);
|
|
71
|
+
} else if (deployStyle !== NO_DEPLOY_STYLE) {
|
|
72
|
+
units.push([type, join(typeDir, "server-deploy"), keepDeploy]);
|
|
73
|
+
}
|
|
64
74
|
}
|
|
65
75
|
if (includeSecretBackup) units.push(["common", join(baseDir, "common", "secret-backup"), null]);
|
|
66
76
|
|
package/src/ui/prompts.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY에서 Enter가 멈추는 버그로 제거).
|
|
3
3
|
// 취소(ESC/Ctrl+C)는 각 함수가 CANCEL 심볼을 반환 → 호출부가 정상 종료(exit 0) 처리.
|
|
4
4
|
import * as engine from "./readline-engine.js";
|
|
5
|
-
import { DEPLOY_STYLES } from "../core/deploy-style.js";
|
|
5
|
+
import { DEPLOY_STYLES, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
|
|
6
6
|
|
|
7
7
|
export const CANCEL = engine.CANCEL;
|
|
8
8
|
|
|
@@ -82,18 +82,24 @@ export async function confirmTypes({ types = [], markers = null } = {}) {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// 배포 방식 선택 (이슈 #80). 서버 배포 CD 워크플로우는 서로 대체재라 하나만 쓴다.
|
|
85
|
+
// 배포 방식 선택 (이슈 #80, #126). 서버 배포 CD 워크플로우는 서로 대체재라 하나만 쓴다.
|
|
86
86
|
// 고른 것만 설치하고 push 트리거까지 켜준다 — 종전에는 넷을 다 깔고 SIMPLE만 켜져 있어,
|
|
87
87
|
// 무중단을 원한 사람은 설치 후 YAML을 직접 고쳐야 했다.
|
|
88
|
+
// "서버 배포 안 함"은 server-deploy 폴더 자체(PR 프리뷰 포함)를 제외한다 — 서버 배포를
|
|
89
|
+
// 하지 않는 프로젝트(프론트엔드 전용, 라이브러리 등)를 위한 선택지다.
|
|
88
90
|
export async function selectDeployStyle() {
|
|
89
91
|
engine.note(
|
|
90
92
|
"서버 배포 워크플로우는 서로 대체재입니다 (Nginx와 Traefik을 동시에 쓰지 않습니다).\n" +
|
|
91
|
-
"고른 방식만 설치하고 자동 실행(push 트리거)까지 켭니다. PR 프리뷰는 선택과 무관하게 함께
|
|
93
|
+
"고른 방식만 설치하고 자동 실행(push 트리거)까지 켭니다. PR 프리뷰는 선택과 무관하게 함께 설치됩니다\n" +
|
|
94
|
+
"(단, server-deploy 폴더가 있는 타입(spring)은 \"서버 배포 안 함\"을 고르면 PR 프리뷰도 함께 제외됩니다).",
|
|
92
95
|
"배포 방식",
|
|
93
96
|
);
|
|
94
97
|
return engine.select({
|
|
95
98
|
message: "서버 배포는 어떤 방식으로 할까요?",
|
|
96
|
-
options:
|
|
99
|
+
options: [
|
|
100
|
+
...DEPLOY_STYLES.map((s) => ({ value: s.value, label: s.label })),
|
|
101
|
+
{ value: NO_DEPLOY_STYLE, label: "서버 배포 안 함 — CD 워크플로우/배포 설정을 생성하지 않음" },
|
|
102
|
+
],
|
|
97
103
|
});
|
|
98
104
|
}
|
|
99
105
|
|
package/src/ui/summary.js
CHANGED
|
@@ -8,7 +8,7 @@ const SEPARATOR = "────────────────────
|
|
|
8
8
|
export function printSummary(ctx) {
|
|
9
9
|
const { mode, types = [], version = "", versionCode = null, copiedFiles = [], branches = null, gitignoreUpdated = false,
|
|
10
10
|
// 설치 후 검증·기록 (#79, #80, #81)
|
|
11
|
-
answers = [], unresolved = [], secrets = new Map(),
|
|
11
|
+
answers = [], unresolved = [], secrets = new Map(), logPath = "", legacyMdLogs = false, cleanup = null } = ctx || {};
|
|
12
12
|
const err = (s = "") => process.stderr.write(`${s}\n`);
|
|
13
13
|
// 색상은 ansi.js의 공용 가드로 통일 (NO_COLOR + stderr TTY 여부)
|
|
14
14
|
const enabled = colorEnabled(process.stderr);
|
|
@@ -111,9 +111,14 @@ export function printSummary(ctx) {
|
|
|
111
111
|
for (const f of cleanup.backedUp || []) err(` • ${f} → ${f}.bak ${paint("수정하신 내용이 있어 백업", A.dim, enabled)}`);
|
|
112
112
|
err("");
|
|
113
113
|
}
|
|
114
|
-
if (
|
|
115
|
-
err(` 📋
|
|
116
|
-
err(" →
|
|
114
|
+
if (logPath) {
|
|
115
|
+
err(` 📋 실행 로그: ${logPath}`);
|
|
116
|
+
err(" → 무엇을 어떤 값으로 설치했는지 시간순으로 남아 있습니다 (git에 올라가지 않습니다)");
|
|
117
|
+
err("");
|
|
118
|
+
}
|
|
119
|
+
if (legacyMdLogs) {
|
|
120
|
+
err(" ℹ️ 이전 버전의 설치 기록(.md)이 git에 추적 중입니다:");
|
|
121
|
+
err(" git rm -r --cached .github/.wizard/logs");
|
|
117
122
|
err("");
|
|
118
123
|
}
|
|
119
124
|
|
package/src/core/install-log.js
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// 설치 로그 (이슈 #79) — 실행마다 "무엇을 어떤 값으로 설치했는지"를 레포에 한 건 남긴다.
|
|
2
|
-
//
|
|
3
|
-
// 왜 필요한가: version.yml에는 버전·타입·경로·브랜치·옵션만 남는다. 감지 근거, 질문별 답변,
|
|
4
|
-
// 특히 환경설정 답변(도메인·포트·볼륨 경로)은 워크플로우 YAML 안으로 흩어질 뿐 한 곳에
|
|
5
|
-
// 기록되지 않아, 배포가 실패했을 때 "설치할 때 뭘로 답했더라"를 역추적할 방법이 없었다.
|
|
6
|
-
// 터미널 스크롤백은 에이전트가 볼 수 없고 다른 클론에도 남지 않는다.
|
|
7
|
-
//
|
|
8
|
-
// 위치는 .github/.wizard/ 아래 — 마법사가 이미 baseline.json을 두는 자기 메타데이터 폴더다.
|
|
9
|
-
// 새 최상위 폴더를 만들 이유가 없고, 삭제 모드에서도 경계가 이 폴더 하나로 끝난다.
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { writeText } from "./fsutil.js";
|
|
12
|
-
|
|
13
|
-
export const LOG_DIR = ".github/.wizard/logs";
|
|
14
|
-
|
|
15
|
-
// 값에 비밀이 들어갈 수 있는 키 — 현재 질문 항목에는 없지만(도메인·경로·포트·인증 '방식'),
|
|
16
|
-
// 앞으로 추가될 때 그냥 평문으로 커밋되지 않도록 처음부터 걸어둔다. 이 파일은 커밋 대상이다.
|
|
17
|
-
const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
|
|
18
|
-
const MASK = "***";
|
|
19
|
-
|
|
20
|
-
export function maskValue(key, value) {
|
|
21
|
-
// SSH_AUTH_METHOD처럼 "방식"만 담는 키는 비밀이 아니다 — 이름에 KEY가 들어가도 마스킹하지 않는다.
|
|
22
|
-
if (key === "SSH_AUTH_METHOD") return value;
|
|
23
|
-
return SECRET_KEY_RE.test(key) ? MASK : value;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// "2026-08-12 18:15:30" → "20260812-181530". 파일명이 곧 정렬 키가 되도록.
|
|
27
|
-
export function stampFrom(now = "") {
|
|
28
|
-
const m = String(now).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
|
29
|
-
if (!m) return "unknown";
|
|
30
|
-
return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function logFilename(now, action = "install") {
|
|
34
|
-
return `${stampFrom(now)}-${action}.md`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const yamlStr = (v) => `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
38
|
-
const yamlList = (arr) => `[${(arr || []).map(yamlStr).join(", ")}]`;
|
|
39
|
-
|
|
40
|
-
// 마크다운 렌더 — 상단은 기계가 읽는 front matter, 아래는 사람이 읽는 본문.
|
|
41
|
-
// 에이전트가 파싱만 해도 핵심을 얻고, 사람이 열면 그대로 읽힌다.
|
|
42
|
-
export function renderInstallLog(d = {}) {
|
|
43
|
-
const {
|
|
44
|
-
action = "install", at = "", templateVersion = "",
|
|
45
|
-
previousTemplateVersion = "", mode = "", types = [], markers = new Map(),
|
|
46
|
-
version = "", branch = "", branches = null, paths = new Map(),
|
|
47
|
-
options = {}, answers = [], result = {}, unresolved = [], secrets = new Map(),
|
|
48
|
-
warnings = [], cleanup = null,
|
|
49
|
-
} = d;
|
|
50
|
-
|
|
51
|
-
const L = [];
|
|
52
|
-
L.push("---");
|
|
53
|
-
L.push(`action: ${yamlStr(action)}`);
|
|
54
|
-
L.push(`at: ${yamlStr(at)}`);
|
|
55
|
-
L.push(`template_version: ${yamlStr(templateVersion)}`);
|
|
56
|
-
if (previousTemplateVersion) L.push(`previous_template_version: ${yamlStr(previousTemplateVersion)}`);
|
|
57
|
-
L.push(`mode: ${yamlStr(mode)}`);
|
|
58
|
-
L.push(`project_types: ${yamlList(types)}`);
|
|
59
|
-
L.push(`version: ${yamlStr(version)}`);
|
|
60
|
-
L.push(`default_branch: ${yamlStr(branch)}`);
|
|
61
|
-
if (branches) {
|
|
62
|
-
L.push(`branches: { main: ${yamlStr(branches.main)}, develop: ${yamlStr(branches.develop)}, mode: ${yamlStr(branches.mode)} }`);
|
|
63
|
-
}
|
|
64
|
-
L.push(`unresolved_count: ${unresolved.length}`);
|
|
65
|
-
L.push(`required_secrets: ${yamlList([...secrets.keys()])}`);
|
|
66
|
-
L.push("---");
|
|
67
|
-
L.push("");
|
|
68
|
-
L.push(`# 설치 로그 — ${at}`);
|
|
69
|
-
L.push("");
|
|
70
|
-
L.push("project-auto-wizard가 이 레포에 무엇을 설치했는지 남긴 기록입니다. 직접 편집하지 마세요.");
|
|
71
|
-
L.push("");
|
|
72
|
-
|
|
73
|
-
L.push("## 실행");
|
|
74
|
-
L.push("");
|
|
75
|
-
L.push("| 항목 | 값 |");
|
|
76
|
-
L.push("|---|---|");
|
|
77
|
-
L.push(`| 동작 | ${action === "install" ? "신규 설치" : action === "update" ? "업데이트" : action} |`);
|
|
78
|
-
L.push(`| 템플릿 버전 | ${previousTemplateVersion ? `${previousTemplateVersion} → ${templateVersion}` : templateVersion || "-"} |`);
|
|
79
|
-
L.push(`| 설치 모드 | ${mode || "-"} |`);
|
|
80
|
-
L.push("");
|
|
81
|
-
|
|
82
|
-
L.push("## 감지 결과");
|
|
83
|
-
L.push("");
|
|
84
|
-
L.push("| 항목 | 값 | 근거 |");
|
|
85
|
-
L.push("|---|---|---|");
|
|
86
|
-
for (const t of types) {
|
|
87
|
-
L.push(`| 타입 | ${t} | ${markers?.get?.(t) || "직접 선택"} |`);
|
|
88
|
-
}
|
|
89
|
-
L.push(`| 버전 | ${version} | ${d.versionSource || "자동 감지"} |`);
|
|
90
|
-
L.push(`| 브랜치 | ${branch} | git |`);
|
|
91
|
-
for (const [t, p] of paths) L.push(`| 경로 (${t}) | ${p} | |`);
|
|
92
|
-
L.push("");
|
|
93
|
-
if (warnings.length) {
|
|
94
|
-
L.push("감지 중 경고:");
|
|
95
|
-
L.push("");
|
|
96
|
-
for (const w of warnings) L.push(`- ${w}`);
|
|
97
|
-
L.push("");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
L.push("## 선택 항목");
|
|
101
|
-
L.push("");
|
|
102
|
-
L.push("| 항목 | 값 |");
|
|
103
|
-
L.push("|---|---|");
|
|
104
|
-
L.push(`| 라이브러리 publish (Nexus·GitHub Packages) | ${options.nexus ? "포함" : "제외"} |`);
|
|
105
|
-
L.push(`| Secret 서버 백업 | ${options.secretBackup ? "포함" : "제외"} |`);
|
|
106
|
-
L.push(`| 자동 버전 승격 | ${options.semverAuto === false ? "사용 안 함" : "사용"} |`);
|
|
107
|
-
L.push(`| 서버 배포 방식 | ${options.deployStyle || "-"} |`);
|
|
108
|
-
L.push("");
|
|
109
|
-
|
|
110
|
-
L.push("## 환경설정 답변");
|
|
111
|
-
L.push("");
|
|
112
|
-
if (!answers.length) {
|
|
113
|
-
L.push("이 설치에서는 환경설정 질문이 없었습니다.");
|
|
114
|
-
} else {
|
|
115
|
-
L.push("`기본값` 열이 `예`면 질문에서 그대로 Enter를 누른 값입니다. 배포가 예상과 다르게 동작하면 여기부터 확인하세요.");
|
|
116
|
-
L.push("");
|
|
117
|
-
L.push("| 키 | 항목 | 값 | 기본값 | 사용처 |");
|
|
118
|
-
L.push("|---|---|---|---|---|");
|
|
119
|
-
for (const a of answers) {
|
|
120
|
-
L.push(`| \`${a.key}\` | ${a.label} | \`${maskValue(a.key, a.value)}\` | ${a.isDefault ? "예" : "아니오"} | ${a.scope || ""} |`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
L.push("");
|
|
124
|
-
|
|
125
|
-
L.push("## 설치 결과");
|
|
126
|
-
L.push("");
|
|
127
|
-
const sec = (title, items, fmt = (x) => `- \`${x}\``) => {
|
|
128
|
-
if (!items || !items.length) return;
|
|
129
|
-
L.push(`### ${title} (${items.length})`);
|
|
130
|
-
L.push("");
|
|
131
|
-
for (const it of items) L.push(fmt(it));
|
|
132
|
-
L.push("");
|
|
133
|
-
};
|
|
134
|
-
sec("새로 설치된 파일", result.copiedFiles);
|
|
135
|
-
sec("이전 배포 방식 정리 — 삭제", cleanup?.removed);
|
|
136
|
-
sec("이전 배포 방식 정리 — .bak 백업 (수정 내용 보존)", cleanup?.backedUp);
|
|
137
|
-
sec("건너뛴 파일", result.skippedFiles);
|
|
138
|
-
sec("백업 후 교체한 파일", result.backupFiles);
|
|
139
|
-
if (result.gitignoreUpdated) { L.push("`.gitignore`를 갱신했습니다 (충돌 백업 파일 무시 항목 추가)."); L.push(""); }
|
|
140
|
-
|
|
141
|
-
L.push("## 남은 할 일");
|
|
142
|
-
L.push("");
|
|
143
|
-
if (unresolved.length) {
|
|
144
|
-
L.push("### ⚠️ 값이 채워지지 않은 항목");
|
|
145
|
-
L.push("");
|
|
146
|
-
L.push("마법사가 값을 계산하지 못해 플레이스홀더가 그대로 남았습니다. **이 상태로는 해당 워크플로우가 정상 동작하지 않습니다.** 직접 채워 주세요.");
|
|
147
|
-
L.push("");
|
|
148
|
-
L.push("| 파일 | 줄 | 토큰 |");
|
|
149
|
-
L.push("|---|---|---|");
|
|
150
|
-
for (const u of unresolved) L.push(`| \`${u.filename}\` | ${u.line} | \`${u.token}\` |`);
|
|
151
|
-
L.push("");
|
|
152
|
-
}
|
|
153
|
-
if (secrets.size) {
|
|
154
|
-
L.push("### 등록해야 하는 GitHub Secret");
|
|
155
|
-
L.push("");
|
|
156
|
-
L.push("Settings > Secrets and variables > Actions 에서 등록합니다. 등록 전에는 해당 워크플로우가 실패합니다.");
|
|
157
|
-
L.push("");
|
|
158
|
-
L.push("| Secret | 사용하는 워크플로우 |");
|
|
159
|
-
L.push("|---|---|");
|
|
160
|
-
for (const [name, users] of secrets) L.push(`| \`${name}\` | ${users.map((u) => `\`${u}\``).join(", ")} |`);
|
|
161
|
-
L.push("");
|
|
162
|
-
}
|
|
163
|
-
if (!unresolved.length && !secrets.size) {
|
|
164
|
-
L.push("추가로 조치할 항목이 없습니다.");
|
|
165
|
-
L.push("");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return L.join("\n") + "\n";
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// 로그 기록. 실패해도 설치 자체는 성공으로 끝나야 하므로 예외를 삼키고 null을 돌려준다 —
|
|
172
|
-
// 로그를 못 남긴 것이 설치를 되돌릴 이유는 아니다.
|
|
173
|
-
export function writeInstallLog(targetRoot, data = {}) {
|
|
174
|
-
try {
|
|
175
|
-
const filename = logFilename(data.at, data.action || "install");
|
|
176
|
-
const rel = `${LOG_DIR}/${filename}`;
|
|
177
|
-
writeText(join(targetRoot, rel), renderInstallLog(data));
|
|
178
|
-
return { path: rel };
|
|
179
|
-
} catch {
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
}
|