project-auto-wizard 0.12.2 → 0.13.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.
Files changed (91) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +9 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +482 -107
  12. package/payload/version.yml.template +5 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +26 -60
  43. package/src/commands/dry-run.js +59 -5
  44. package/src/commands/full.js +135 -55
  45. package/src/commands/install-settings.js +51 -0
  46. package/src/commands/interactive-flutter.js +1 -1
  47. package/src/commands/interactive.js +135 -69
  48. package/src/commands/purge.js +50 -21
  49. package/src/commands/status.js +18 -12
  50. package/src/commands/uninstall.js +22 -22
  51. package/src/context.js +5 -5
  52. package/src/core/baseline.js +20 -5
  53. package/src/core/branches.js +19 -1
  54. package/src/core/branding.js +1 -1
  55. package/src/core/breaking-check.js +14 -19
  56. package/src/core/breaking.js +7 -3
  57. package/src/core/copy/app-files.js +28 -0
  58. package/src/core/copy/gitignore.js +20 -12
  59. package/src/core/copy/readme.js +24 -5
  60. package/src/core/copy/simple.js +52 -13
  61. package/src/core/copy/workflows.js +133 -111
  62. package/src/core/deploy-style.js +83 -11
  63. package/src/core/detect-fs.js +127 -24
  64. package/src/core/detect.js +177 -47
  65. package/src/core/errors.js +3 -0
  66. package/src/core/flutter-doctor.js +49 -0
  67. package/src/core/flutter-hooks.js +29 -0
  68. package/src/core/flutter-options.js +4 -3
  69. package/src/core/fsutil.js +23 -6
  70. package/src/core/logger.js +55 -19
  71. package/src/core/paths-resolve.js +50 -47
  72. package/src/core/paths.js +26 -1
  73. package/src/core/release-options.js +23 -0
  74. package/src/core/removal-exec.js +67 -0
  75. package/src/core/removal-plan.js +75 -19
  76. package/src/core/types.js +136 -0
  77. package/src/core/verify.js +85 -19
  78. package/src/core/version-yml.js +42 -8
  79. package/src/core/wizard-env.js +21 -14
  80. package/src/core/wizard-labels.js +34 -25
  81. package/src/index.js +128 -60
  82. package/src/ui/ansi.js +3 -1
  83. package/src/ui/banner.js +12 -8
  84. package/src/ui/env-plan.js +68 -44
  85. package/src/ui/prompts.js +22 -12
  86. package/src/ui/readline-engine.js +109 -22
  87. package/src/ui/status-cards.js +10 -5
  88. package/src/ui/summary.js +71 -33
  89. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  90. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  91. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -1,16 +1,18 @@
1
- // 워크플로우 복사 엔진 (.sh copy_workflows + _copy_workflows_for_type 등가).
2
- // 실측: template_integrator.sh 3398~3815.
1
+ // 워크플로우 복사 엔진 — common → 타입별 → server-deploy 순으로 분류·복사·치환한다.
3
2
  // 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
4
3
  // 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
5
4
  import { join, basename } from "node:path";
6
- import { deployFilter, isDeployWorkflow, activateDeployTrigger, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE } from "../deploy-style.js";
7
- import { storeWorkflowFilter } from "../flutter-options.js";
5
+ import {
6
+ deployFilter, isDeployWorkflow, activateDeployTrigger, payloadWorkflowNames, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE,
7
+ } from "../deploy-style.js";
8
+ import { typeInfo } from "../types.js";
8
9
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
9
- import { PATHS, PAYLOAD } from "../paths.js";
10
+ import { PATHS, PAYLOAD, typeWorkflowDirs } from "../paths.js";
10
11
  import { exists, writeText, listYamlFiles } from "../fsutil.js";
11
12
  import { substituteEnv } from "../wizard-env.js";
12
13
  import { substitute } from "../branding.js";
13
14
  import { sha256, readBaseline } from "../baseline.js";
15
+ import { parseDeployBlock } from "../version-yml.js";
14
16
  import { log } from "../logger.js";
15
17
 
16
18
  // 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
@@ -24,37 +26,84 @@ export function makeSrcText(branches, deployStyle = DEFAULT_DEPLOY_STYLE) {
24
26
  };
25
27
  }
26
28
 
27
- // trunk-based 모드에서 설치하지 않는 common 워크플로우 (DESIGN-SPEC §4 설치 매트릭스).
29
+ // trunk-based 모드에서 설치하지 않는 common 워크플로우.
28
30
  // 릴리스 PR 흐름이 없으므로 RELEASE-PUBLISH 하나가 bump→changelog→tag→Release를 흡수한다.
29
31
  const TRUNK_BASED_EXCLUDED = new Set([
30
32
  "PROJECT-COMMON-VERSION-CONTROL.yaml",
31
33
  "PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml",
32
34
  ]);
33
35
 
34
- // 타입 루트 디렉토리에 거는 파일 필터 — "배포 안 함"의 CD 제외와 Flutter 스토어 대상 선택을 합성한다.
36
+ // version.yml deploy 블록에 저장된 배포 값 — Map<type, Map<key,value>>.
37
+ // 설치·분류·baseline이 같은 값을 기본값으로 써야 재실행 때 가짜 변경이 생기지 않는다.
38
+ export function readSavedDeployValues(targetRoot = ".") {
39
+ const p = join(targetRoot, PATHS.versionFile);
40
+ if (!existsSync(p)) return new Map();
41
+ return parseDeployBlock(readFileSync(p, "utf8"));
42
+ }
43
+
44
+ // 타입 루트 디렉토리에 거는 파일 필터 — 배포 방식 필터와 Flutter 스토어 대상 선택을 합성한다.
35
45
  // copyWorkflowsForType·surveyWorkflows·planWorkflows가 같은 함수를 써야 설치·충돌 조사·status/dry-run이
36
46
  // 서로 다른 파일 집합을 보지 않는다. flutterStore가 배열이 아니면(null=미결정, 비대화형 기본) 스토어 필터는
37
47
  // 걸지 않는다. 필터가 없어도 항상 함수를 돌려준다 — processDir은 null을 받지 못한다.
38
- export function buildTypeRootFilter(type, deployStyle, flutterStore) {
48
+ // go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 있어 배포 방식 필터를
49
+ // 항상 건다. available(payload 파일명 집합)이 있어야 고른 방식이 없는 타입을 단일 서버 배포로 대체한다.
50
+ export function buildTypeRootFilter(type, deployStyle, flutterStore, available = null) {
39
51
  const filters = [];
40
- if (deployStyle === NO_DEPLOY_STYLE) filters.push(deployFilter(deployStyle));
41
- if (type === "flutter" && Array.isArray(flutterStore)) filters.push(storeWorkflowFilter(flutterStore));
52
+ if (deployStyle) filters.push(deployFilter(deployStyle, available));
53
+ const typeFilter = typeInfo(type)?.hooks?.workflowFilter?.({ flutterStore });
54
+ if (typeFilter) filters.push(typeFilter);
42
55
  return (filename) => filters.every((keep) => keep(filename));
43
56
  }
44
57
 
45
- // 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
58
+ // 한 타입이 설치하는 원본 디렉토리를 순서대로 돌려준다 — 타입 루트 → server-deploy.
59
+ // 각 항목: { srcDir, type, filter }. 폴더 존재 여부와 "배포 안 함" 제외를 여기서 한 번에 판단한다.
60
+ export function typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore = null, available = null }) {
61
+ const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
62
+ const sources = [];
63
+ // go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 있어 루트에도 배포 방식 필터를 건다.
64
+ if (exists(typeDir)) sources.push({ srcDir: typeDir, type, filter: buildTypeRootFilter(type, deployStyle, flutterStore, available) });
65
+ // "배포 안 함"이면 폴더째 제외
66
+ if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
67
+ sources.push({ srcDir: serverDeployDir, type, filter: deployFilter(deployStyle, available) });
68
+ }
69
+ return sources;
70
+ }
71
+
72
+ // common 원본 — trunk-based 모드의 제외 필터를 반영한다. 폴더가 없으면 null.
73
+ export function commonWorkflowSource(context, payloadRoot) {
74
+ const commonDir = join(payloadRoot, PAYLOAD.workflowsDir, "common");
75
+ if (!exists(commonDir)) return null;
76
+ const branchMode = context.branches?.mode || "pr-flow";
77
+ return {
78
+ srcDir: commonDir, type: "common",
79
+ filter: (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename)),
80
+ };
81
+ }
82
+
83
+ // 설치 대상 워크플로우 원본을 엔진 처리 순서(common → 타입 순회 → 타입 루트 → server-deploy)로 내준다.
84
+ // 복사·충돌 조사·미리보기 계획이 모두 이 순회를 써야 서로 다른 파일 집합을 보지 않는다.
85
+ export function* workflowSources(context, payloadRoot, { deployStyle, available }) {
86
+ const { types = [], flutterStore = null } = context;
87
+ const common = commonWorkflowSource(context, payloadRoot);
88
+ if (common) yield common;
89
+ for (const type of types) yield* typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore, available });
90
+ }
91
+
92
+ // 한 파일에 env 치환을 적용해 대상 파일을 갱신.
46
93
  // values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
47
- function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
94
+ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true, savedValues = null }) {
48
95
  const content = readFileSync(targetPath, "utf8");
49
96
  if (!content.includes("@wizard")) return;
50
- const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks });
97
+ const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks, savedValues });
51
98
  writeFileSync(targetPath, out);
52
99
  }
53
100
 
54
- // payload 원본을 "기본값으로 가상 치환한 최종형" — isUnchanged가 내부에서 쓰는 것과 같은 값이다.
101
+ // payload 원본을 "이번 실행이 쓸 값으로 가상 치환한 최종형" — 저장된 deploy 값(없으면 기본값)과
102
+ // 이번에 답한 값을 반영한다. 기본값만 쓰면 기본값이 아닌 값으로 설치한 파일이 재실행마다 변경으로
103
+ // 잡혀, 다시 쓰이면서 답한 값이 기본값으로 되돌아간다.
55
104
  // baseline.rendered와 비교해 "업스트림이 바뀌었는가"를 판정하는 데 쓴다.
56
105
  function renderVirtual(templateContent, envOpts) {
57
- return substituteEnv(templateContent, { ...envOpts, useDefaults: true });
106
+ return substituteEnv(templateContent, { useDefaults: true, ...envOpts, collectAsks: null });
58
107
  }
59
108
 
60
109
  // 분류 — 대상 워크플로우 디렉토리 기준. srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
@@ -107,7 +156,7 @@ function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null, filte
107
156
  function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () => true) {
108
157
  const { srcText, baseline, decisions, restoreRemoved, baselineTargets } = ctx;
109
158
  const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
110
- const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
159
+ const track = (f, wrote, keepRendered = false) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote, keepRendered });
111
160
  const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
112
161
 
113
162
  for (const f of c.unchanged.filter(filter)) {
@@ -147,12 +196,16 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
147
196
  const decision = decisions.get(f);
148
197
  applyDecision(decision, srcDir, workflowsDir, f, counters, srcText);
149
198
  // 'backup'만 대상 파일 자체를 새로 쓴다. 'template'은 다른 파일명이고 'skip'은 기존 유지.
150
- track(f, decision === "backup");
199
+ // skip은 업스트림 변경을 받지 않은 것이라 rendered를 예전 값으로 둔다 — 새 값으로 바꾸면 다음 실행에서
200
+ // "업스트림 무변경"으로 분류되어 그 변경을 영영 받을 수 없다.
201
+ const kept = decision !== "backup" && decision !== "template";
202
+ if (kept) counters.conflictKept.push(f);
203
+ track(f, decision === "backup", kept);
151
204
  }
152
205
  return c;
153
206
  }
154
207
 
155
- // copy_workflows 본체 (동기 — 기존 호출부 무변경).
208
+ // 복사 엔진 본체 (동기 — 기존 호출부 무변경).
156
209
  // context: { types:[], paths:Map, force, repoName, resolvers,
157
210
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
158
211
  // flutterStore?:string[]|null } ← Flutter 스토어 대상 (null=필터 없음, 배열=선택된 플랫폼만)
@@ -177,20 +230,25 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
177
230
  counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
178
231
  counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
179
232
  counters.restoredFiles = []; // 사용자가 지웠지만 복원하기로 한 파일
233
+ counters.conflictKept = []; // 양쪽이 다 바뀌어 기존 파일을 유지한 파일 (업스트림 변경 미반영)
180
234
  const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
181
235
  const srcText = makeSrcText(context.branches || null, deployStyle);
182
236
  const baseline = readBaseline(targetRoot);
183
237
  const baselineTargets = new Map(); // filename -> { srcPath, envOpts, wrote }
184
238
  // values/useDefaults는 치환 경로에서만 의미 (renderVirtual은 useDefaults:true 강제 — 가상 비교 무손상)
185
- const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
186
- const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets };
239
+ const saved = readSavedDeployValues(targetRoot);
240
+ const envOptsFor = (type) => ({
241
+ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults,
242
+ savedValues: saved.get(type) || null,
243
+ });
244
+ const available = payloadWorkflowNames(payloadRoot);
245
+ const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets, available };
187
246
 
188
247
  // (1) common — 타입별과 동일 규칙 (README 계약).
189
248
  // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
190
- const branchMode = context.branches?.mode || "pr-flow";
191
- const commonDir = join(projectTypesDir, "common");
192
- if (exists(commonDir)) {
193
- const notExcluded = (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename));
249
+ const commonSource = commonWorkflowSource(context, payloadRoot);
250
+ if (commonSource) {
251
+ const { srcDir: commonDir, filter: notExcluded } = commonSource;
194
252
  const c = processDir(commonDir, workflowsDir, envOptsFor("common"), dirCtx, counters, notExcluded);
195
253
  // env 치환 — 타입별 폴더(copyWorkflowsForType)와 동일하게, 손대지 않기로 한 파일(unchanged/localOnly)은
196
254
  // 건너뛴다. common 최상위는 지금까지 @wizard 마커가 없어 이 루프가 없어도 드러나지 않았지만,
@@ -208,7 +266,7 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
208
266
  // (2~4) 타입별
209
267
  for (const type of types) {
210
268
  const asks = new Map();
211
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { ...context, deployStyle, envOptsFor, collectAsks: asks, dirCtx }, counters);
269
+ copyWorkflowsForType(type, payloadRoot, workflowsDir, { ...context, deployStyle, envOptsFor, collectAsks: asks, dirCtx }, counters);
212
270
  if (asks.size) deployValues.set(type, asks);
213
271
  }
214
272
 
@@ -218,12 +276,16 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
218
276
 
219
277
  // copyWorkflows가 끝나고 env 치환까지 마친 뒤에 호출한다 — 그래야 디스크 내용이 최종형이다.
220
278
  // entries: Map<filename, {installed:string|null, rendered:string}>
221
- export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
279
+ // savedDeploy: 이번 실행이 version.yml deploy 블록에 쓴 값(Map<type, Map<key,value>>). 다음 실행은 이 값을
280
+ // 기본값으로 분류하므로 rendered도 같은 값으로 계산해야 재실행이 "업스트림 변경"으로 오인되지 않는다.
281
+ export function computeBaselineEntries(baselineTargets, workflowsDir, srcText, savedDeploy = null) {
222
282
  const entries = new Map();
223
283
  for (const [filename, info] of baselineTargets) {
224
284
  const dst = join(workflowsDir, filename);
225
285
  if (!existsSync(dst)) continue;
226
- const rendered = sha256(renderVirtual(srcText(info.srcPath), info.envOpts));
286
+ const envOpts = savedDeploy ? { ...info.envOpts, savedValues: savedDeploy.get(info.envOpts.type) || null } : info.envOpts;
287
+ // keepRendered: 충돌로 기존 파일을 유지함 — 예전 rendered를 그대로 두도록 null로 넘긴다(writeBaseline이 병합).
288
+ const rendered = info.keepRendered ? null : sha256(renderVirtual(srcText(info.srcPath), envOpts));
227
289
  // installed는 이번에 우리가 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
228
290
  // "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
229
291
  entries.set(filename, { installed: info.wrote ? sha256(readFileSync(dst, "utf8")) : null, rendered });
@@ -231,13 +293,13 @@ export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
231
293
  return entries;
232
294
  }
233
295
 
234
- // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
296
+ // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리.
235
297
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
236
298
  function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
237
299
  const src = join(srcDir, filename);
238
300
  const dst = join(workflowsDir, filename);
239
301
  if (decision === "backup") {
240
- // .sh O) mv → cp: 기존을 .bak으로 백업 후 새 버전으로 교체
302
+ // 기존을 .bak으로 백업 후 새 버전으로 교체
241
303
  renameSync(dst, dst + ".bak");
242
304
  writeText(dst, srcText(src));
243
305
  counters.copied++;
@@ -247,16 +309,19 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
247
309
  return;
248
310
  }
249
311
  if (decision === "template") {
250
- // .sh T) `${filename%.yaml}.template.yaml` — .yaml만 strip (.yml은 그대로 뒤에 붙음, .sh 동일)
312
+ // `${이름}.template.yaml` — .yaml만 떼고 붙인다 (.yml은 그대로 뒤에 붙는다)
251
313
  const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
252
- writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
314
+ writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀
253
315
  counters.templateAdded++;
254
316
  counters.copiedFiles.push(templateName);
255
317
  log.info("copy", "template", `${filename} 유지 + ${templateName} 생성 (사용자 결정)`);
256
318
  return;
257
319
  }
258
- counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
259
- log.info("copy", "skip", `${filename} (사용자 결정: 기존 유지)`);
320
+ counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (force 기본)
321
+ // 결정이 없으면 사용자가 고른 게 아니라 --force 기본값이다 — 로그가 사실과 달라지지 않게 구분한다.
322
+ log.info("copy", "skip", decision
323
+ ? `${filename} (사용자 결정: 기존 유지, 업스트림 변경 미반영)`
324
+ : `${filename} (--force 기본값: 기존 유지, 업스트림 변경 미반영)`);
260
325
  }
261
326
 
262
327
  // 대화형 사전 조사 — 사람이 답해야 하는 것만 뽑는다.
@@ -266,39 +331,26 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
266
331
  // conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
267
332
  // removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
268
333
  export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
269
- const { types = [], paths = new Map(), repoName = "", resolvers = {}, flutterStore = null } = context;
334
+ const { paths = new Map(), repoName = "", resolvers = {} } = context;
270
335
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
271
- const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
272
336
  const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
273
337
  const srcText = makeSrcText(context.branches || null, deployStyle);
274
338
  const baseline = readBaseline(targetRoot);
275
- const branchMode = context.branches?.mode || "pr-flow";
339
+ const saved = readSavedDeployValues(targetRoot);
276
340
  const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
277
341
  const removed = [];
278
-
279
- const keepDeploy = deployFilter(deployStyle);
280
- const collect = (srcDir, envOpts, type, skipFile = () => false, filter = null) => {
342
+ const available = payloadWorkflowNames(payloadRoot);
343
+ const values = context.envValues || new Map();
344
+ const useDefaults = context.envUseDefaults !== false;
345
+
346
+ for (const { srcDir, type, filter } of workflowSources(context, payloadRoot, { deployStyle, available })) {
347
+ // 복사 엔진과 같은 값(이번 답변 포함)으로 분류해야 결정 목록이 실제 처리 대상과 맞는다.
348
+ const envOpts = type === "common"
349
+ ? { type, projectPath: ".", repoName, resolvers, values, useDefaults }
350
+ : { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null, values, useDefaults };
281
351
  const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
282
- for (const f of c.changed) { if (!skipFile(f)) conflicts.push({ filename: f, type }); }
283
- for (const f of c.removed) { if (!skipFile(f)) removed.push({ filename: f, type }); }
284
- };
285
-
286
- const commonDir = join(projectTypesDir, "common");
287
- if (exists(commonDir)) {
288
- collect(commonDir, { type: "common", projectPath: ".", repoName, resolvers }, "common",
289
- (f) => branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f));
290
- }
291
-
292
- for (const type of types) {
293
- const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
294
- const typeDir = join(projectTypesDir, type);
295
- if (exists(typeDir)) {
296
- collect(typeDir, envOpts, type, () => false, buildTypeRootFilter(type, deployStyle, flutterStore));
297
- }
298
- const serverDeployDir = join(typeDir, "server-deploy");
299
- if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
300
- collect(serverDeployDir, envOpts, type, () => false, keepDeploy);
301
- }
352
+ for (const f of c.changed) conflicts.push({ filename: f, type });
353
+ for (const f of c.removed) removed.push({ filename: f, type });
302
354
  }
303
355
  return { conflicts, removed };
304
356
  }
@@ -323,43 +375,32 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
323
375
  return copyWorkflows(context, payloadRoot, targetRoot, { decisions });
324
376
  }
325
377
 
326
- function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
378
+ function copyWorkflowsForType(type, payloadRoot, workflowsDir, ctx, counters) {
327
379
  const { deployStyle = "", flutterStore = null, envOptsFor, collectAsks = null, dirCtx } = ctx;
328
- const keepDeploy = deployFilter(deployStyle);
329
- const keepTypeRoot = buildTypeRootFilter(type, deployStyle, flutterStore);
330
- const { srcText, baselineTargets } = dirCtx;
331
- const typeDir = join(projectTypesDir, type);
380
+ const { srcText } = dirCtx;
381
+ const sources = typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore, available: dirCtx.available });
332
382
  const envOpts = envOptsFor(type);
333
383
  // env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
334
384
  // 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
335
385
  const untouched = [];
336
386
 
337
- // 타입별 워크플로우 (직하위). go/python처럼 CD 워크플로우가 server-deploy 없이 타입 루트에
338
- // 바로 있는 타입도 있다 — "배포 안 함"일 때는 타입 루트에서도 CD 파일(SIMPLE-CICD 등)을
339
- // 걸러야 한다. simple/nginx/traefik은 오늘과 동일하게 필터 없이 전부 복사한다.
340
- if (exists(typeDir)) {
341
- const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters, keepTypeRoot);
342
- untouched.push(...c.unchanged, ...c.localOnly);
343
- }
344
-
345
- // server-deploy
346
- const serverDeployDir = join(typeDir, "server-deploy");
347
- // "배포 안 함"이면 폴더째 제외 (복사 안 함)
348
- if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
349
- const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters, keepDeploy);
387
+ for (const { srcDir, filter } of sources) {
388
+ const c = processDir(srcDir, workflowsDir, envOpts, dirCtx, counters, filter);
350
389
  untouched.push(...c.unchanged, ...c.localOnly);
351
390
  }
352
391
 
353
392
  // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
354
- for (const srcDir of [typeDir, serverDeployDir]) {
355
- if (!exists(srcDir)) continue;
356
- if (srcDir === serverDeployDir && deployStyle === NO_DEPLOY_STYLE) continue; // 폴더째 제외
393
+ for (const { srcDir, filter } of sources) {
357
394
  for (const filename of listYamlFiles(srcDir)) {
358
395
  const target = join(workflowsDir, filename);
359
- if (srcDir === serverDeployDir && !keepDeploy(filename)) continue; // 안 고른 배포 방식
360
- if (srcDir === typeDir && !keepTypeRoot(filename)) continue; // 타입 루트: 배제된 CD·안 고른 스토어 워크플로우
396
+ if (!filter(filename)) continue; // 배제된 CD·안 고른 배포 방식·안 고른 스토어 워크플로우
361
397
  if (!existsSync(target)) continue; // 건너뛴 파일 제외
362
- if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
398
+ // unchanged/localOnly와 치환 마커가 이미 없는 유지본은 다시 치환하지 않는다. 대신 deploy 블록이
399
+ // 사라지지 않도록 저장값(없으면 기본값)으로 ask 값만 수집한다 — 파일은 건드리지 않는다.
400
+ if (untouched.includes(filename) || !readFileSync(target, "utf8").includes("@wizard")) {
401
+ if (collectAsks) substituteEnv(srcText(join(srcDir, filename)), { ...envOpts, useDefaults: true, collectAsks });
402
+ continue;
403
+ }
363
404
  configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
364
405
  }
365
406
  }
@@ -369,43 +410,24 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
369
410
  // changed뿐 아니라 newFiles/unchanged까지 전부 반환한다는 점이 listWorkflowConflicts(changed만
370
411
  // 반환)와 다르다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
371
412
  export function planWorkflows(context, payloadRoot, targetRoot = ".") {
372
- const { types = [], paths = new Map(), repoName = "", resolvers = {}, flutterStore = null } = context;
413
+ const { paths = new Map(), repoName = "", resolvers = {} } = context;
373
414
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
374
- const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
375
415
  const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
376
416
  const srcText = makeSrcText(context.branches || null, deployStyle);
377
417
  const baseline = readBaseline(targetRoot);
378
- const branchMode = context.branches?.mode || "pr-flow";
418
+ const saved = readSavedDeployValues(targetRoot);
419
+ const available = payloadWorkflowNames(payloadRoot);
379
420
  // upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다.
380
421
  const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
381
422
  const BUCKETS = ["newFiles", "unchanged", "changed", "upstreamOnly", "localOnly", "removed"];
382
423
 
383
- const merge = (result, type, excluded = null) => {
424
+ for (const { srcDir, type, filter } of workflowSources(context, payloadRoot, { deployStyle, available })) {
425
+ const envOpts = type === "common"
426
+ ? { type, projectPath: ".", repoName, resolvers }
427
+ : { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null };
428
+ const result = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
384
429
  for (const bucket of BUCKETS) {
385
- for (const filename of result[bucket]) {
386
- if (excluded && excluded.has(filename)) continue;
387
- plan[bucket].push({ filename, type });
388
- }
389
- }
390
- };
391
-
392
- const commonDir = join(projectTypesDir, "common");
393
- if (exists(commonDir)) {
394
- const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
395
- merge(classify(commonDir, workflowsDir, envOpts, srcText, baseline), "common",
396
- branchMode === "trunk-based" ? TRUNK_BASED_EXCLUDED : null);
397
- }
398
-
399
- for (const type of types) {
400
- const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
401
- const typeDir = join(projectTypesDir, type);
402
- if (exists(typeDir)) {
403
- merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, buildTypeRootFilter(type, deployStyle, flutterStore)), type);
404
- }
405
-
406
- const serverDeployDir = join(typeDir, "server-deploy");
407
- if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
408
- merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline, deployFilter(deployStyle)), type);
430
+ for (const filename of result[bucket]) plan[bucket].push({ filename, type });
409
431
  }
410
432
  }
411
433
 
@@ -2,8 +2,10 @@
2
2
  // Nginx 무중단과 Traefik 무중단을 동시에 쓰는 경우는 없으므로 하나만 설치한다.
3
3
  // 고른 것은 push 트리거까지 켜서 설치한다 — 설치했는데 안 도는 상태를 만들지 않는다.
4
4
  import { join } from "node:path";
5
- import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
5
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
6
6
  import { sha256 } from "./baseline.js";
7
+ import { PAYLOAD, typeWorkflowDirs } from "./paths.js";
8
+ import { SINGLE_SERVER_CD_FILES } from "./types.js";
7
9
 
8
10
  // 파일명 접미사로 식별한다 — 타입 접두사(PROJECT-SPRING- 등)는 타입마다 다르기 때문.
9
11
  export const DEPLOY_STYLES = [
@@ -25,17 +27,81 @@ export const isDeployStyle = (v) => v === NO_DEPLOY_STYLE || DEPLOY_STYLES.some(
25
27
  // 이 파일이 CD 본체인가 (= 택1 대상인가). PR 프리뷰는 배포 방식과 직교하는 축이라 제외한다.
26
28
  export const isDeployWorkflow = (filename) => DEPLOY_STYLES.some((s) => filename.endsWith(s.suffix));
27
29
 
30
+ const PREVIEW_SUFFIX = "-PR-PREVIEW.yaml";
31
+
32
+ // 서버에 배포하는 워크플로우 전부 — CD 본체, 단일 CD(react·next), PR 프리뷰.
33
+ // "배포 안 함"은 이 전부를 모든 타입에서 똑같이 뺀다. 일부만 빼면 서버 Secret 요구가 남는다.
34
+ export const isServerDeployWorkflow = (filename) =>
35
+ isDeployWorkflow(filename) || SINGLE_SERVER_CD_FILES.has(filename) || filename.endsWith(PREVIEW_SUFFIX);
36
+
37
+ // 선택한 타입 중 서버 배포 워크플로우를 가진 타입이 있는가 — payload 파일로 판정한다.
38
+ // 없으면(node·flutter 등) 배포 방식은 설치 결과에 아무 영향이 없으므로 묻지도 기록하지도 않는다.
39
+ export function hasServerDeployWorkflows(payloadRoot, types = []) {
40
+ return types.some((type) => typeWorkflowDirs(payloadRoot, type)
41
+ .some((dir) => existsSync(dir) && readdirSync(dir).some(isServerDeployWorkflow)));
42
+ }
43
+
44
+ // 선택한 타입 중 무중단(nginx·traefik) 워크플로우가 있는 타입이 있는가 — 없으면 무중단 선택지를 보이지 않는다.
45
+ export function hasNonstopWorkflows(payloadRoot, types = []) {
46
+ const nonstop = DEPLOY_STYLES.filter((s) => s.value !== DEFAULT_DEPLOY_STYLE).map((s) => s.suffix);
47
+ return types.some((type) => typeWorkflowDirs(payloadRoot, type)
48
+ .some((dir) => existsSync(dir) && readdirSync(dir).some((f) => nonstop.some((sfx) => f.endsWith(sfx)))));
49
+ }
50
+
51
+ // payload/workflows/** 의 워크플로우 파일명 전체. 파일명은 PROJECT-{TYPE}- 접두사로 타입 간 유일하다.
52
+ export function payloadWorkflowNames(payloadRoot) {
53
+ const names = new Set();
54
+ const root = join(payloadRoot, PAYLOAD.workflowsDir);
55
+ if (!existsSync(root)) return names;
56
+ for (const e of readdirSync(root, { recursive: true, withFileTypes: true })) {
57
+ if (e.isFile() && /\.ya?ml$/.test(e.name)) names.add(e.name);
58
+ }
59
+ return names;
60
+ }
61
+
28
62
  // 모르는 값은 기본값으로 수렴시킨다. 빈 접미사를 돌려주면 endsWith("")가 항상 참이라
29
63
  // "전부 통과"가 되어, 잘못된 값이 조용히 CD 전부 설치로 새어나간다.
30
64
  const suffixOf = (style) =>
31
65
  (DEPLOY_STYLES.find((s) => s.value === style) ?? DEPLOY_STYLES.find((s) => s.value === DEFAULT_DEPLOY_STYLE)).suffix;
66
+ const SIMPLE_SUFFIX = suffixOf(DEFAULT_DEPLOY_STYLE);
67
+
68
+ // 고른 방식의 CD가 없는 타입의 단일 서버 배포 CD인가 (예: nginx를 골랐는데 python·go에는 NONSTOP이 없다).
69
+ // 그런 타입은 단일 서버 배포로 대신 설치한다 — 설치 직후 지우거나 .bak으로 옮기면 배포가 사라진다.
70
+ function isFallbackSimple(filename, style, available) {
71
+ if (!available || !filename.endsWith(SIMPLE_SUFFIX)) return false;
72
+ const prefix = filename.slice(0, -SIMPLE_SUFFIX.length);
73
+ return !available.has(prefix + suffixOf(style));
74
+ }
32
75
 
33
76
  // 파일 필터 — 고른 방식의 CD만 통과. CD가 아닌 파일(PR 프리뷰·common 등)은 항상 통과.
34
- // "none"은 CD를 하나도 설치하지 않으므로 접미사 매칭 없이 CD 파일 전부를 거른다.
35
- export function deployFilter(style) {
36
- if (style === NO_DEPLOY_STYLE) return (filename) => !isDeployWorkflow(filename);
77
+ // "none"은 서버 배포 워크플로우(CD·단일 CD·PR 프리뷰)를 하나도 설치하지 않는다.
78
+ // available: payload 워크플로우 파일명 집합(payloadWorkflowNames). 주면 고른 방식이 없는 타입은
79
+ // 단일 서버 배포로 대체한다. 없으면 접미사만으로 가른다.
80
+ export function deployFilter(style, available = null) {
81
+ if (style === NO_DEPLOY_STYLE) return (filename) => !isServerDeployWorkflow(filename);
37
82
  const suffix = suffixOf(style);
38
- return (filename) => !isDeployWorkflow(filename) || filename.endsWith(suffix);
83
+ return (filename) => !isDeployWorkflow(filename) || filename.endsWith(suffix)
84
+ || isFallbackSimple(filename, style, available);
85
+ }
86
+
87
+ // 고른 방식(nginx·traefik)의 워크플로우가 없어 단일 서버 배포로 대신 설치하는 타입 목록 — 안내용.
88
+ export function fallbackStyleTypes(payloadRoot, types = [], style) {
89
+ if (!style || style === NO_DEPLOY_STYLE || suffixOf(style) === SIMPLE_SUFFIX) return [];
90
+ return types.filter((type) => {
91
+ const files = typeWorkflowDirs(payloadRoot, type)
92
+ .filter((dir) => existsSync(dir)).flatMap((dir) => readdirSync(dir));
93
+ return files.some(isServerDeployWorkflow) && !files.some((f) => f.endsWith(suffixOf(style)));
94
+ });
95
+ }
96
+
97
+ // 실제로 설치되는 배포 방식 — 고른 무중단 방식의 워크플로우가 선택한 타입 어디에도 없으면 전부 단일 서버
98
+ // 배포로 대신 설치되므로 simple이다. 고른 값을 그대로 기록하면 version.yml·status가 설치 상태와 어긋나고,
99
+ // 다음 실행도 그 값을 기본값으로 이어받는다. 한 타입이라도 그 방식이 있으면 고른 값을 유지한다.
100
+ export function effectiveDeployStyle(payloadRoot, types = [], style) {
101
+ if (!style || style === NO_DEPLOY_STYLE || suffixOf(style) === SIMPLE_SUFFIX) return style;
102
+ const installed = types.some((type) => typeWorkflowDirs(payloadRoot, type)
103
+ .some((dir) => existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(suffixOf(style)))));
104
+ return installed ? style : DEFAULT_DEPLOY_STYLE;
39
105
  }
40
106
 
41
107
  // 무중단 템플릿은 push 트리거가 주석 처리된 채 들어 있다(기본 배포가 단일 서버라서).
@@ -73,24 +139,30 @@ export function activateDeployTrigger(content) {
73
139
  // 손대지 않은 것(baseline의 installed 해시와 동일) → 삭제. 물어볼 이유가 없다.
74
140
  // 손댄 것 → .bak으로 옮긴다. 내용은 지키고 트리거만 죽인다.
75
141
  //
142
+ // opts.available — payload 워크플로우 파일명 집합. 주면 그 안의 파일(마법사가 까는 파일)만 정리하고,
143
+ // 고른 방식이 없는 타입의 단일 서버 배포는 남긴다.
144
+ // opts.justWritten — 이번 실행에서 마법사가 방금 쓴 파일. 첫 설치처럼 baseline이 없어도 수정본으로 오인하지 않는다.
145
+ // opts.dryRun — 판정만 하고 파일은 건드리지 않는다. --dry-run 미리보기가 실제 실행과 같은 판정을 쓰게 한다.
76
146
  // 반환: { removed:[], backedUp:[] } — 완료 화면·설치 기록에 그대로 보고한다.
77
- export function cleanupOtherDeployWorkflows(workflowsDir, installedFilenames, style, baseline) {
78
- const keep = deployFilter(style);
147
+ export function cleanupOtherDeployWorkflows(workflowsDir, installedFilenames, style, baseline, { available = null, justWritten = [], dryRun = false } = {}) {
148
+ const keep = deployFilter(style, available);
149
+ const written = new Set(justWritten);
79
150
  const removed = [];
80
151
  const backedUp = [];
81
152
 
82
153
  for (const filename of installedFilenames) {
83
- if (!isDeployWorkflow(filename) || keep(filename)) continue;
154
+ if (!isServerDeployWorkflow(filename) || keep(filename)) continue;
155
+ if (available && !available.has(filename)) continue; // 사용자가 만든 비슷한 이름의 워크플로우는 건드리지 않는다
84
156
  const p = join(workflowsDir, filename);
85
157
  if (!existsSync(p)) continue;
86
158
 
87
159
  const known = baseline?.files?.[filename]?.installed;
88
- const untouched = known && sha256(readFileSync(p, "utf8")) === known;
160
+ const untouched = written.has(filename) || (known && sha256(readFileSync(p, "utf8")) === known);
89
161
  if (untouched) {
90
- rmSync(p, { force: true });
162
+ if (!dryRun) rmSync(p, { force: true });
91
163
  removed.push(filename);
92
164
  } else {
93
- renameSync(p, `${p}.bak`);
165
+ if (!dryRun) renameSync(p, `${p}.bak`);
94
166
  backedUp.push(filename);
95
167
  }
96
168
  }