project-auto-wizard 0.13.3 → 0.14.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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/bin/project-auto-wizard.js +18 -4
  3. package/package.json +2 -2
  4. package/payload/config/breaking-changes.json +28 -14
  5. package/payload/config/wizard-prompts.yml +102 -46
  6. package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
  7. package/payload/flutter-app/ios/ExportOptions.plist +8 -8
  8. package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
  9. package/payload/scripts/changelog_manager.py +296 -257
  10. package/payload/scripts/issue_helper.py +54 -50
  11. package/payload/scripts/messages.py +2279 -0
  12. package/payload/scripts/truncate_release_notes.py +7 -6
  13. package/payload/scripts/version_manager.py +64 -61
  14. package/payload/version.yml.template +2 -1
  15. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +17 -12
  16. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +59 -34
  17. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
  18. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +17 -10
  19. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +54 -44
  20. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +24 -17
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
  25. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
  26. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
  27. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
  28. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
  29. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
  30. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
  31. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
  32. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
  33. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
  34. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
  35. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
  36. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
  37. package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
  38. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
  39. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
  40. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
  41. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
  42. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
  43. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
  44. package/src/cli/args.js +98 -52
  45. package/src/cli/help.js +8 -41
  46. package/src/commands/doctor.js +85 -89
  47. package/src/commands/dry-run.js +63 -43
  48. package/src/commands/full.js +79 -77
  49. package/src/commands/install-settings.js +16 -15
  50. package/src/commands/interactive-flutter.js +20 -19
  51. package/src/commands/interactive.js +135 -132
  52. package/src/commands/purge.js +47 -42
  53. package/src/commands/status.js +36 -36
  54. package/src/commands/uninstall.js +43 -41
  55. package/src/context.js +12 -11
  56. package/src/core/assets.js +12 -11
  57. package/src/core/baseline.js +27 -27
  58. package/src/core/branches.js +29 -27
  59. package/src/core/branding.js +7 -6
  60. package/src/core/breaking-check.js +22 -21
  61. package/src/core/breaking.js +15 -8
  62. package/src/core/copy/app-files.js +5 -4
  63. package/src/core/copy/flutter-app.js +8 -8
  64. package/src/core/copy/gitignore.js +58 -50
  65. package/src/core/copy/readme.js +45 -35
  66. package/src/core/copy/simple.js +16 -15
  67. package/src/core/copy/workflows.js +131 -124
  68. package/src/core/deploy-style.js +51 -48
  69. package/src/core/detect-fs.js +57 -57
  70. package/src/core/detect.js +67 -65
  71. package/src/core/errors.js +2 -2
  72. package/src/core/flutter-doctor.js +17 -16
  73. package/src/core/flutter-hooks.js +33 -32
  74. package/src/core/flutter-options.js +34 -33
  75. package/src/core/fsutil.js +12 -12
  76. package/src/core/installed-stores.js +4 -4
  77. package/src/core/logger.js +42 -40
  78. package/src/core/paths-resolve.js +88 -86
  79. package/src/core/paths.js +10 -10
  80. package/src/core/release-options.js +8 -8
  81. package/src/core/removal-exec.js +14 -13
  82. package/src/core/removal-plan.js +37 -37
  83. package/src/core/types.js +47 -47
  84. package/src/core/verify.js +44 -38
  85. package/src/core/version-yml.js +76 -68
  86. package/src/core/wizard-env.js +46 -46
  87. package/src/core/wizard-labels.js +63 -41
  88. package/src/i18n/catalog/en/cli.js +50 -0
  89. package/src/i18n/catalog/en/commands.js +187 -0
  90. package/src/i18n/catalog/en/copy.js +19 -0
  91. package/src/i18n/catalog/en/core.js +74 -0
  92. package/src/i18n/catalog/en/core2.js +15 -0
  93. package/src/i18n/catalog/en/ui.js +221 -0
  94. package/src/i18n/catalog/en.js +12 -0
  95. package/src/i18n/catalog/index.js +5 -0
  96. package/src/i18n/catalog/ko/cli.js +50 -0
  97. package/src/i18n/catalog/ko/commands.js +187 -0
  98. package/src/i18n/catalog/ko/copy.js +19 -0
  99. package/src/i18n/catalog/ko/core.js +74 -0
  100. package/src/i18n/catalog/ko/core2.js +15 -0
  101. package/src/i18n/catalog/ko/ui.js +221 -0
  102. package/src/i18n/catalog/ko.js +10 -0
  103. package/src/i18n/index.js +44 -0
  104. package/src/i18n/languages.js +9 -0
  105. package/src/index.js +128 -94
  106. package/src/ui/ansi.js +7 -7
  107. package/src/ui/banner.js +7 -6
  108. package/src/ui/env-plan.js +82 -81
  109. package/src/ui/prompts.js +97 -122
  110. package/src/ui/readline-engine.js +59 -58
  111. package/src/ui/status-cards.js +37 -31
  112. package/src/ui/summary.js +89 -88
@@ -1,6 +1,7 @@
1
- // 워크플로우 복사 엔진 — common → 타입별 → server-deploy 순으로 분류·복사·치환한다.
2
- // 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
3
- // 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
1
+ // Workflow copy engine: classifies, copies and substitutes in the order common -> per type -> server-deploy.
2
+ // The interactive three-way choice (existing file conflict) is collected by copyWorkflowsInteractive (async)
3
+ // into a decisions Map and passed to the synchronous engine (copyWorkflows) as hooks.decisions; signature and
4
+ // force behavior are unchanged.
4
5
  import { join, basename } from "node:path";
5
6
  import {
6
7
  deployFilter, isDeployWorkflow, activateDeployTrigger, payloadWorkflowNames, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE,
@@ -14,39 +15,42 @@ import { substitute } from "../branding.js";
14
15
  import { sha256, readBaseline } from "../baseline.js";
15
16
  import { parseDeployBlock } from "../version-yml.js";
16
17
  import { log } from "../logger.js";
18
+ import { t } from "../../i18n/index.js";
17
19
 
18
- // 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
19
- // classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
20
+ // Source text loader: applies {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} substitution when context.branches is set.
21
+ // classify (unchanged decision) and the real copy must see the same substituted text, or re-runs get false conflicts.
20
22
  export function makeSrcText(branches, deployStyle = DEFAULT_DEPLOY_STYLE) {
21
23
  return (p) => {
22
24
  const raw = readFileSync(p, "utf8");
23
25
  const out = branches ? substitute(raw, branches) : raw;
24
- // 고른 배포 방식의 CD는 push 트리거를 켜서 설치한다 — 설치했는데 안 도는 상태를 만들지 않는다.
26
+ // The CD for the chosen deploy style is installed with its push trigger enabled, so it never ends up installed but not running.
25
27
  return isDeployWorkflow(basename(p)) ? activateDeployTrigger(out) : out;
26
28
  };
27
29
  }
28
30
 
29
- // trunk-based 모드에서 설치하지 않는 common 워크플로우.
30
- // 릴리스 PR 흐름이 없으므로 RELEASE-PUBLISH 하나가 bump→changelog→tag→Release를 흡수한다.
31
+ // Common workflows not installed in trunk-based mode.
32
+ // There is no release PR flow, so RELEASE-PUBLISH alone absorbs bump -> changelog -> tag -> Release.
31
33
  const TRUNK_BASED_EXCLUDED = new Set([
32
34
  "PROJECT-COMMON-VERSION-CONTROL.yaml",
33
35
  "PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml",
34
36
  ]);
35
37
 
36
- // version.yml deploy 블록에 저장된 배포 값 — Map<type, Map<key,value>>.
37
- // 설치·분류·baseline이 같은 값을 기본값으로 써야 재실행 때 가짜 변경이 생기지 않는다.
38
+ // Deploy values saved in the version.yml deploy block: Map<type, Map<key,value>>.
39
+ // Install, classify and baseline must use the same values as defaults, or re-runs produce false changes.
38
40
  export function readSavedDeployValues(targetRoot = ".") {
39
41
  const p = join(targetRoot, PATHS.versionFile);
40
42
  if (!existsSync(p)) return new Map();
41
43
  return parseDeployBlock(readFileSync(p, "utf8"));
42
44
  }
43
45
 
44
- // 타입 루트 디렉토리에 거는 파일 필터 — 배포 방식 필터와 Flutter 스토어 대상 선택을 합성한다.
45
- // copyWorkflowsForType·surveyWorkflows·planWorkflows가 같은 함수를 써야 설치·충돌 조사·status/dry-run이
46
- // 서로 다른 파일 집합을 보지 않는다. flutterStore가 배열이 아니면(null=미결정, 비대화형 기본) 스토어 필터는
47
- // 걸지 않는다. 필터가 없어도 항상 함수를 돌려준다 — processDir은 null을 받지 못한다.
48
- // go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 있어 배포 방식 필터를
49
- // 항상 건다. available(payload 파일명 집합)이 있어야 고른 방식이 없는 타입을 단일 서버 배포로 대체한다.
46
+ // File filter applied to a type root directory: combines the deploy style filter and the Flutter store selection.
47
+ // copyWorkflowsForType, surveyWorkflows and planWorkflows must use the same function so install, conflict survey
48
+ // and status/dry-run never see different file sets. When flutterStore is not an array (null = undecided,
49
+ // non-interactive default) no store filter is applied. It always returns a function even without filters,
50
+ // because processDir cannot take null.
51
+ // Some types (go/python, react/next) keep server deploy workflows directly in the type root, so the deploy style
52
+ // filter is always applied. `available` (set of payload file names) lets a type without the chosen style fall back
53
+ // to the single server deploy.
50
54
  export function buildTypeRootFilter(type, deployStyle, flutterStore, available = null) {
51
55
  const filters = [];
52
56
  if (deployStyle) filters.push(deployFilter(deployStyle, available));
@@ -55,21 +59,21 @@ export function buildTypeRootFilter(type, deployStyle, flutterStore, available =
55
59
  return (filename) => filters.every((keep) => keep(filename));
56
60
  }
57
61
 
58
- // 한 타입이 설치하는 원본 디렉토리를 순서대로 돌려준다 — 타입 루트 → server-deploy.
59
- // 각 항목: { srcDir, type, filter }. 폴더 존재 여부와 "배포 안 함" 제외를 여기서 한 번에 판단한다.
62
+ // Returns the source directories a type installs, in order: type root -> server-deploy.
63
+ // Each item: { srcDir, type, filter }. Folder existence and the "no deploy" exclusion are decided here in one place.
60
64
  export function typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore = null, available = null }) {
61
65
  const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
62
66
  const sources = [];
63
- // go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 있어 루트에도 배포 방식 필터를 건다.
67
+ // Some types (go/python, react/next) keep server deploy workflows directly in the type root, so filter the root by deploy style too.
64
68
  if (exists(typeDir)) sources.push({ srcDir: typeDir, type, filter: buildTypeRootFilter(type, deployStyle, flutterStore, available) });
65
- // "배포 안 함"이면 폴더째 제외
69
+ // "No deploy" excludes the whole folder
66
70
  if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
67
71
  sources.push({ srcDir: serverDeployDir, type, filter: deployFilter(deployStyle, available) });
68
72
  }
69
73
  return sources;
70
74
  }
71
75
 
72
- // common 원본 — trunk-based 모드의 제외 필터를 반영한다. 폴더가 없으면 null.
76
+ // The common source, reflecting the trunk-based exclusion filter. null when the folder is missing.
73
77
  export function commonWorkflowSource(context, payloadRoot) {
74
78
  const commonDir = join(payloadRoot, PAYLOAD.workflowsDir, "common");
75
79
  if (!exists(commonDir)) return null;
@@ -80,8 +84,8 @@ export function commonWorkflowSource(context, payloadRoot) {
80
84
  };
81
85
  }
82
86
 
83
- // 설치 대상 워크플로우 원본을 엔진 처리 순서(common → 타입 순회 → 타입 루트 → server-deploy)로 내준다.
84
- // 복사·충돌 조사·미리보기 계획이 모두 이 순회를 써야 서로 다른 파일 집합을 보지 않는다.
87
+ // Yields the workflow sources to install in engine order (common -> type iteration -> type root -> server-deploy).
88
+ // Copy, conflict survey and preview planning must all use this iteration so they never see different file sets.
85
89
  export function* workflowSources(context, payloadRoot, { deployStyle, available }) {
86
90
  const { types = [], flutterStore = null } = context;
87
91
  const common = commonWorkflowSource(context, payloadRoot);
@@ -89,8 +93,8 @@ export function* workflowSources(context, payloadRoot, { deployStyle, available
89
93
  for (const type of types) yield* typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore, available });
90
94
  }
91
95
 
92
- // 한 파일에 env 치환을 적용해 대상 파일을 갱신.
93
- // values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
96
+ // Applies env substitution to one file and updates the target file.
97
+ // values/useDefaults: result of the env plan (promptEnvPlan); when unset, the default path (current force behavior).
94
98
  function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true, savedValues = null }) {
95
99
  const content = readFileSync(targetPath, "utf8");
96
100
  if (!content.includes("@wizard")) return;
@@ -98,28 +102,29 @@ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", reso
98
102
  writeFileSync(targetPath, out);
99
103
  }
100
104
 
101
- // payload 원본을 "이번 실행이 쓸 값으로 가상 치환한 최종형" — 저장된 deploy 값(없으면 기본값)과
102
- // 이번에 답한 값을 반영한다. 기본값만 쓰면 기본값이 아닌 값으로 설치한 파일이 재실행마다 변경으로
103
- // 잡혀, 다시 쓰이면서 답한 값이 기본값으로 되돌아간다.
104
- // baseline.rendered와 비교해 "업스트림이 바뀌었는가"를 판정하는 데 쓴다.
105
+ // The payload source as the "final form virtually substituted with the values this run will use": reflects the
106
+ // saved deploy values (defaults if none) and the values answered this time. Using only defaults would make a file
107
+ // installed with non-default values look changed on every re-run, and rewriting it would revert the answered
108
+ // values to defaults.
109
+ // Compared with baseline.rendered to decide "did upstream change".
105
110
  function renderVirtual(templateContent, envOpts) {
106
111
  return substituteEnv(templateContent, { useDefaults: true, ...envOpts, collectAsks: null });
107
112
  }
108
113
 
109
- // 분류 — 대상 워크플로우 디렉토리 기준. srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
114
+ // Classification, relative to the target workflows directory. srcText: source loader with branch substitution
115
+ // applied (makeSrcText).
110
116
  //
111
- // baseline이 있으면 3-way로 가른다. base가 없던 시절에는 업스트림이 한 글자만 고쳐도
112
- // 사용자가 손대지 않은 파일이 changed로 떨어져, "전부 skip" 아니면 "전부 backup" 둘 중 하나만
113
- // 고를 수 있었다.
117
+ // With a baseline it splits 3-way. Before base existed, even a one-character upstream fix pushed files the user
118
+ // never touched into changed, leaving only "skip everything" or "backup everything" as choices.
114
119
  //
115
- // ours === theirs → unchanged 이미 최신, 할 일 없음
116
- // sha(ours) === base.installed → upstreamOnly 사용자 미수정 → 질문 없이 교체
117
- // sha(theirs) === base.rendered → localOnly 업스트림 그대로 → 질문 없이 유지
118
- // 그 외 → changed 진짜 충돌 → 질문
119
- // 디스크에 없는데 baseline에 있음 → removed 사용자가 지움 → 되살리기 전에 물어봄
120
+ // ours === theirs -> unchanged already current, nothing to do
121
+ // sha(ours) === base.installed -> upstreamOnly user did not modify -> replace without asking
122
+ // sha(theirs) === base.rendered -> localOnly upstream unchanged -> keep without asking
123
+ // otherwise -> changed real conflict -> ask
124
+ // missing on disk but in baseline -> removed user deleted it -> ask before restoring
120
125
  //
121
- // baseline이 없는 기존 설치는 base 미상이라 upstreamOnly/localOnly 판정을 할 수 없고,
122
- // 종전대로 unchanged/changed 2분류로 떨어진다(폴백). 그 실행에서 baseline이 심긴다.
126
+ // Existing installs without a baseline have no base, so upstreamOnly/localOnly cannot be decided and they fall
127
+ // back to the old unchanged/changed two-way split. The baseline is planted in that run.
123
128
  function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null, filter = null) {
124
129
  const result = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
125
130
  for (const filename of listYamlFiles(srcDir)) {
@@ -129,8 +134,8 @@ function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null, filte
129
134
  const base = baseline?.files?.[filename] || null;
130
135
 
131
136
  if (!existsSync(dst)) {
132
- // baseline에 있는데 디스크에 없다 = 우리가 깔았던 파일을 사용자가 지웠다.
133
- // 별도의 삭제 이력 파일이 필요 없다는 것이 baseline 설계의 부산물이다.
137
+ // In baseline but missing on disk = the user deleted a file we installed.
138
+ // Needing no separate deletion-history file is a by-product of the baseline design.
134
139
  if (base) result.removed.push(filename);
135
140
  else result.newFiles.push(filename);
136
141
  continue;
@@ -147,12 +152,12 @@ function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null, filte
147
152
  return result;
148
153
  }
149
154
 
150
- // 한 원본 디렉토리를 분류 결과대로 처리한다. common·타입별·server-deploy가 같은 규칙을 쓴다.
151
- // filter: trunk-based 제외 같은 파일 단위 필터.
155
+ // Processes one source directory according to the classification. common, per-type and server-deploy share the rules.
156
+ // filter: per-file filter such as the trunk-based exclusion.
152
157
  //
153
- // 자동 처리되는 두 버킷이 이 함수의 핵심이다:
154
- // upstreamOnly — 사용자가 손대지 않았으니 그냥 최신으로 교체한다. 물어볼 이유가 없다.
155
- // localOnly — 업스트림이 그대로니 사용자 수정본을 그대로 둔다. 역시 물어볼 이유가 없다.
158
+ // The two automatically handled buckets are the heart of this function:
159
+ // upstreamOnly: the user did not touch it, so just replace it with the latest. No reason to ask.
160
+ // localOnly: upstream is unchanged, so keep the user's edited copy. No reason to ask either.
156
161
  function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () => true) {
157
162
  const { srcText, baseline, decisions, restoreRemoved, baselineTargets } = ctx;
158
163
  const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
@@ -166,7 +171,7 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
166
171
 
167
172
  for (const f of c.localOnly.filter(filter)) {
168
173
  counters.skipped++; counters.keptLocal.push(f); track(f, false);
169
- log.info("copy", "keep-local", `${f} (업스트림 무변경, 사용자 수정본 유지)`);
174
+ log.info("copy", "keep-local", t("copy.workflows.log.keepLocal", { f }));
170
175
  }
171
176
 
172
177
  for (const f of c.newFiles.filter(filter)) {
@@ -176,28 +181,28 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
176
181
 
177
182
  for (const f of c.upstreamOnly.filter(filter)) {
178
183
  write(f); counters.autoUpdated.push(f);
179
- log.info("copy", "auto-update", `${f} (사용자 미수정, 최신으로 교체)`);
184
+ log.info("copy", "auto-update", t("copy.workflows.log.autoUpdate", { f }));
180
185
  }
181
186
 
182
- // 사용자가 지운 파일은 조용히 되살리지 않는다. 복원 결정이 있을 때만 다시 쓴다.
183
- // 되살리지 않은 파일은 baselineTargets에 넣지 않는다 — 디스크에 없어 해시할 것이 없고,
184
- // 기존 baseline 항목은 병합으로 남아 다음 실행에서도 "지운 파일"로 인식된다.
187
+ // Files the user deleted are not silently revived; they are rewritten only when there is a restore decision.
188
+ // Files not revived are not put into baselineTargets: they are absent on disk so there is nothing to hash, and
189
+ // the existing baseline entry survives the merge so the next run still recognizes them as "deleted files".
185
190
  for (const f of c.removed.filter(filter)) {
186
191
  if (restoreRemoved.has(f)) {
187
192
  write(f); counters.restoredFiles.push(f);
188
- log.info("copy", "restore", `${f} (사용자가 지웠지만 복원 결정)`);
193
+ log.info("copy", "restore", t("copy.workflows.log.restore", { f }));
189
194
  } else {
190
195
  counters.removedKept.push(f);
191
- log.info("copy", "removed-kept", `${f} (사용자가 지움, 되살리지 않음)`);
196
+ log.info("copy", "removed-kept", t("copy.workflows.log.removedKept", { f }));
192
197
  }
193
198
  }
194
199
 
195
200
  for (const f of c.changed.filter(filter)) {
196
201
  const decision = decisions.get(f);
197
202
  applyDecision(decision, srcDir, workflowsDir, f, counters, srcText);
198
- // 'backup'만 대상 파일 자체를 새로 쓴다. 'template'은 다른 파일명이고 'skip'은 기존 유지.
199
- // skip은 업스트림 변경을 받지 않은 것이라 rendered를 예전 값으로 둔다 — 새 값으로 바꾸면 다음 실행에서
200
- // "업스트림 무변경"으로 분류되어 그 변경을 영영 받을 수 없다.
203
+ // Only 'backup' rewrites the target file itself; 'template' is a different file name and 'skip' keeps the existing one.
204
+ // skip did not take the upstream change, so rendered stays at the old value: switching it to the new value
205
+ // would classify the file as "upstream unchanged" next run and it could never receive that change.
201
206
  const kept = decision !== "backup" && decision !== "template";
202
207
  if (kept) counters.conflictKept.push(f);
203
208
  track(f, decision === "backup", kept);
@@ -205,37 +210,37 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
205
210
  return c;
206
211
  }
207
212
 
208
- // 복사 엔진 본체 (동기 — 기존 호출부 무변경).
213
+ // Copy engine body (synchronous, existing call sites unchanged).
209
214
  // context: { types:[], paths:Map, force, repoName, resolvers,
210
- // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
211
- // flutterStore?:string[]|null } ← Flutter 스토어 대상 (null=필터 없음, 배열=선택된 플랫폼만)
212
- // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'>, — 진짜 충돌(changed) 결정
213
- // restoreRemoved?: Set<filename> } — 사용자가 지운 파일 중 복원할 것
214
- // 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
215
- // 반환: {copied, skipped, templateAdded, backupAdded, autoUpdated, keptLocal, removedKept, restoredFiles}
215
+ // envValues?:Map<key,value>, envUseDefaults?:boolean } <- injection point for the env plan (promptEnvPlan) result
216
+ // flutterStore?:string[]|null } <- Flutter store targets (null = no filter, array = selected platforms only)
217
+ // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'>, - decisions for real conflicts (changed)
218
+ // restoreRemoved?: Set<filename> } - user-deleted files to restore
219
+ // Unspecified files are 'skip' (current force behavior kept 100%). See copyWorkflowsInteractive for interactive collection.
220
+ // Returns: {copied, skipped, templateAdded, backupAdded, autoUpdated, keptLocal, removedKept, restoredFiles}
216
221
  export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
217
222
  const { types = [], paths = new Map(), repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
218
223
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
219
224
  const restoreRemoved = hooks.restoreRemoved instanceof Set ? hooks.restoreRemoved : new Set();
220
225
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
221
226
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
222
- if (!exists(projectTypesDir)) throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
227
+ if (!exists(projectTypesDir)) throw new Error(t("copy.workflows.error.noPayloadWorkflows"));
223
228
 
224
229
  const counters = { copied: 0, skipped: 0, templateAdded: 0, backupAdded: 0 };
225
- const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
230
+ const deployValues = new Map(); // Map<type, Map<key,value>>: ask values for the deploy block
226
231
  counters.deployValues = deployValues;
227
- counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (printSummary 정확성용)
228
- counters.unchangedFiles = []; // skip(unchanged) 대상 — 로그에서 "왜 안 바뀌었나"의 근거
229
- counters.autoUpdated = []; // 질문 없이 최신으로 교체된 파일 (사용자 미수정)
230
- counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
231
- counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
232
- counters.restoredFiles = []; // 사용자가 지웠지만 복원하기로 한 파일
233
- counters.conflictKept = []; // 양쪽이 다 바뀌어 기존 파일을 유지한 파일 (업스트림 변경 미반영)
232
+ counters.copiedFiles = []; // file names actually newly written in this run (for printSummary accuracy)
233
+ counters.unchangedFiles = []; // skip (unchanged) targets: the evidence for "why nothing changed" in the log
234
+ counters.autoUpdated = []; // files replaced with the latest without asking (user did not modify)
235
+ counters.keptLocal = []; // files whose user-edited copy was kept without asking (upstream unchanged)
236
+ counters.removedKept = []; // files the user deleted and that were not revived
237
+ counters.restoredFiles = []; // files the user deleted but decided to restore
238
+ counters.conflictKept = []; // files where both sides changed and the existing file was kept (upstream change not applied)
234
239
  const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
235
240
  const srcText = makeSrcText(context.branches || null, deployStyle);
236
241
  const baseline = readBaseline(targetRoot);
237
242
  const baselineTargets = new Map(); // filename -> { srcPath, envOpts, wrote }
238
- // values/useDefaults는 치환 경로에서만 의미 (renderVirtual은 useDefaults:true 강제 — 가상 비교 무손상)
243
+ // values/useDefaults only matter on the substitution path (renderVirtual forces useDefaults:true, so virtual comparison is unaffected)
239
244
  const saved = readSavedDeployValues(targetRoot);
240
245
  const envOptsFor = (type) => ({
241
246
  type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults,
@@ -244,15 +249,15 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
244
249
  const available = payloadWorkflowNames(payloadRoot);
245
250
  const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets, available };
246
251
 
247
- // (1) common — 타입별과 동일 규칙 (README 계약).
248
- // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
252
+ // (1) common: same rules as per type (README contract).
253
+ // trunk-based mode does not install VERSION-CONTROL/AUTO-CHANGELOG (RELEASE-PUBLISH alone).
249
254
  const commonSource = commonWorkflowSource(context, payloadRoot);
250
255
  if (commonSource) {
251
256
  const { srcDir: commonDir, filter: notExcluded } = commonSource;
252
257
  const c = processDir(commonDir, workflowsDir, envOptsFor("common"), dirCtx, counters, notExcluded);
253
- // env 치환 — 타입별 폴더(copyWorkflowsForType)와 동일하게, 손대지 않기로 한 파일(unchanged/localOnly)은
254
- // 건너뛴다. common 최상위는 지금까지 @wizard 마커가 없어 이 루프가 없어도 드러나지 않았지만,
255
- // ISSUE_HELPER_CREATE_BRANCH부터는 실제로 값이 반영돼야 한다.
258
+ // env substitution: as with per-type folders (copyWorkflowsForType), files decided to be left alone
259
+ // (unchanged/localOnly) are skipped. Common top level had no @wizard markers until now so the missing loop
260
+ // went unnoticed, but from ISSUE_HELPER_CREATE_BRANCH on the values must actually be applied.
256
261
  const untouched = [...c.unchanged, ...c.localOnly];
257
262
  for (const filename of listYamlFiles(commonDir)) {
258
263
  if (!notExcluded(filename)) continue;
@@ -263,73 +268,74 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
263
268
  }
264
269
  }
265
270
 
266
- // (2~4) 타입별
271
+ // (2-4) per type
267
272
  for (const type of types) {
268
273
  const asks = new Map();
269
274
  copyWorkflowsForType(type, payloadRoot, workflowsDir, { ...context, deployStyle, envOptsFor, collectAsks: asks, dirCtx }, counters);
270
275
  if (asks.size) deployValues.set(type, asks);
271
276
  }
272
277
 
273
- counters.baselineTargets = baselineTargets; // 호출부(runFull)가 env 치환 완료 후 baseline을 기록한다
278
+ counters.baselineTargets = baselineTargets; // the caller (runFull) records the baseline after env substitution completes
274
279
  return counters;
275
280
  }
276
281
 
277
- // copyWorkflows가 끝나고 env 치환까지 마친 뒤에 호출한다 — 그래야 디스크 내용이 최종형이다.
282
+ // Call after copyWorkflows has finished including env substitution, so the disk content is the final form.
278
283
  // entries: Map<filename, {installed:string|null, rendered:string}>
279
- // savedDeploy: 이번 실행이 version.yml deploy 블록에 쓴 값(Map<type, Map<key,value>>). 다음 실행은 이 값을
280
- // 기본값으로 분류하므로 rendered도 같은 값으로 계산해야 재실행이 "업스트림 변경"으로 오인되지 않는다.
284
+ // savedDeploy: values this run wrote to the version.yml deploy block (Map<type, Map<key,value>>). The next run
285
+ // classifies with these as defaults, so rendered must be computed with the same values or a re-run is mistaken
286
+ // for an "upstream change".
281
287
  export function computeBaselineEntries(baselineTargets, workflowsDir, srcText, savedDeploy = null) {
282
288
  const entries = new Map();
283
289
  for (const [filename, info] of baselineTargets) {
284
290
  const dst = join(workflowsDir, filename);
285
291
  if (!existsSync(dst)) continue;
286
292
  const envOpts = savedDeploy ? { ...info.envOpts, savedValues: savedDeploy.get(info.envOpts.type) || null } : info.envOpts;
287
- // keepRendered: 충돌로 기존 파일을 유지함 — 예전 rendered를 그대로 두도록 null로 넘긴다(writeBaseline이 병합).
293
+ // keepRendered: the existing file was kept due to a conflict; pass null so the old rendered stays (writeBaseline merges).
288
294
  const rendered = info.keepRendered ? null : sha256(renderVirtual(srcText(info.srcPath), envOpts));
289
- // installed는 이번에 우리가 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
290
- // "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
295
+ // installed is filled only for files we wrote this time. Recording a user-edited copy as installed would
296
+ // falsely claim "we wrote this", and the file would be silently overwritten on the next update.
291
297
  entries.set(filename, { installed: info.wrote ? sha256(readFileSync(dst, "utf8")) : null, rendered });
292
298
  }
293
299
  return entries;
294
300
  }
295
301
 
296
- // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리.
297
- // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
302
+ // Handles one changed file (existing and with different content) according to the decision.
303
+ // 'skip' (default): keep the existing one. 'backup': existing -> .bak, then replace. 'template': keep the existing one + write the new version as .template.yaml.
298
304
  function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
299
305
  const src = join(srcDir, filename);
300
306
  const dst = join(workflowsDir, filename);
301
307
  if (decision === "backup") {
302
- // 기존을 .bak으로 백업 후 새 버전으로 교체
308
+ // Back up the existing file as .bak, then replace it with the new version
303
309
  renameSync(dst, dst + ".bak");
304
310
  writeText(dst, srcText(src));
305
311
  counters.copied++;
306
312
  counters.backupAdded++;
307
313
  counters.copiedFiles.push(filename);
308
- log.info("copy", "backup", `${filename} → ${filename}.bak (사용자 결정, 새 버전으로 교체)`);
314
+ log.info("copy", "backup", t("copy.workflows.log.backup", { filename }));
309
315
  return;
310
316
  }
311
317
  if (decision === "template") {
312
- // `${이름}.template.yaml` — .yaml만 떼고 붙인다 (.yml은 그대로 뒤에 붙는다)
318
+ // `${name}.template.yaml`: strip only .yaml and append (.yml is kept and the suffix is appended after it)
313
319
  const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
314
- writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀
320
+ writeText(join(workflowsDir, templateName), srcText(src)); // overwrites an existing .template.yaml
315
321
  counters.templateAdded++;
316
322
  counters.copiedFiles.push(templateName);
317
- log.info("copy", "template", `${filename} 유지 + ${templateName} 생성 (사용자 결정)`);
323
+ log.info("copy", "template", t("copy.workflows.log.template", { filename, templateName }));
318
324
  return;
319
325
  }
320
- counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (force 기본)
321
- // 결정이 없으면 사용자가 고른 게 아니라 --force 기본값이다 — 로그가 사실과 달라지지 않게 구분한다.
326
+ counters.skipped++; // 'skip'/unspecified/ESC -> keep the existing file (force default)
327
+ // Without a decision the user did not choose; it is the --force default. Distinguish so the log stays truthful.
322
328
  log.info("copy", "skip", decision
323
- ? `${filename} (사용자 결정: 기존 유지, 업스트림 변경 미반영)`
324
- : `${filename} (--force 기본값: 기존 유지, 업스트림 변경 미반영)`);
329
+ ? t("copy.workflows.log.skipDecided", { filename })
330
+ : t("copy.workflows.log.skipForce", { filename }));
325
331
  }
326
332
 
327
- // 대화형 사전 조사 — 사람이 답해야 하는 것만 뽑는다.
328
- // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
329
- // common도 타입별과 동일하게 스캔한다 (이전에는 common 충돌이 질문조차 되지 않았다).
330
- // 반환: { conflicts: [{filename,type}], removed: [{filename,type}] }
331
- // conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
332
- // removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
333
+ // Interactive pre-survey: picks only what a human must answer.
334
+ // It must use the same classify criteria as the copyWorkflows body so the decisions Map matches the actual
335
+ // processing targets 1:1. common is scanned like per-type too (previously common conflicts were never even asked).
336
+ // Returns: { conflicts: [{filename,type}], removed: [{filename,type}] }
337
+ // conflicts: real conflicts where both sides changed. upstreamOnly/localOnly are handled automatically, so not here.
338
+ // removed: files we installed that the user deleted. Must be asked about before reviving.
333
339
  export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
334
340
  const { paths = new Map(), repoName = "", resolvers = {} } = context;
335
341
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
@@ -337,14 +343,14 @@ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
337
343
  const srcText = makeSrcText(context.branches || null, deployStyle);
338
344
  const baseline = readBaseline(targetRoot);
339
345
  const saved = readSavedDeployValues(targetRoot);
340
- const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
346
+ const conflicts = []; // same as the engine processing order (common -> type iteration -> direct children -> server-deploy)
341
347
  const removed = [];
342
348
  const available = payloadWorkflowNames(payloadRoot);
343
349
  const values = context.envValues || new Map();
344
350
  const useDefaults = context.envUseDefaults !== false;
345
351
 
346
352
  for (const { srcDir, type, filter } of workflowSources(context, payloadRoot, { deployStyle, available })) {
347
- // 복사 엔진과 같은 값(이번 답변 포함)으로 분류해야 결정 목록이 실제 처리 대상과 맞는다.
353
+ // Classify with the same values as the copy engine (including this run's answers) so the decision list matches the actual targets.
348
354
  const envOpts = type === "common"
349
355
  ? { type, projectPath: ".", repoName, resolvers, values, useDefaults }
350
356
  : { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null, values, useDefaults };
@@ -355,20 +361,20 @@ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
355
361
  return { conflicts, removed };
356
362
  }
357
363
 
358
- // 진짜 충돌 목록만 필요할 때 (기존 호출부 호환).
364
+ // When only the real conflict list is needed (compatible with existing call sites).
359
365
  export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
360
366
  return surveyWorkflows(context, payloadRoot, targetRoot).conflicts;
361
367
  }
362
368
 
363
- // 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
364
- // 동기 엔진에 위임한다. WHY 분리: copyWorkflows를 async로 바꾸면 await 없이 호출하는
365
- // 기존 호출부(runFull)가 깨진다 — 시그니처 무변경 원칙.
366
- // onConflict 반환값: 'template' | 'skip' | 'backup' (그 외/미지정 → 'skip').
369
+ // Interactive entry point (async): awaits onConflict(filename, type) per conflict to build the decisions Map,
370
+ // then delegates to the synchronous engine. WHY separate: making copyWorkflows async would break existing call
371
+ // sites (runFull) that call it without await; the signature stays unchanged.
372
+ // onConflict return value: 'template' | 'skip' | 'backup' (anything else/unset -> 'skip').
367
373
  export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot = ".", { onConflict } = {}) {
368
374
  const decisions = new Map();
369
375
  if (typeof onConflict === "function") {
370
376
  for (const { filename, type } of listWorkflowConflicts(context, payloadRoot, targetRoot)) {
371
- if (decisions.has(filename)) continue; // 파일명은 PROJECT-{TYPE}- prefix로 타입 간 유일
377
+ if (decisions.has(filename)) continue; // file names are unique across types via the PROJECT-{TYPE}- prefix
372
378
  decisions.set(filename, await onConflict(filename, type));
373
379
  }
374
380
  }
@@ -380,8 +386,8 @@ function copyWorkflowsForType(type, payloadRoot, workflowsDir, ctx, counters) {
380
386
  const { srcText } = dirCtx;
381
387
  const sources = typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore, available: dirCtx.available });
382
388
  const envOpts = envOptsFor(type);
383
- // env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
384
- // 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
389
+ // Files excluded from env substitution: re-applying substitution to files decided to be left alone
390
+ // (unchanged/localOnly/kept deletions) would overwrite the user's edited copy.
385
391
  const untouched = [];
386
392
 
387
393
  for (const { srcDir, filter } of sources) {
@@ -389,26 +395,27 @@ function copyWorkflowsForType(type, payloadRoot, workflowsDir, ctx, counters) {
389
395
  untouched.push(...c.unchanged, ...c.localOnly);
390
396
  }
391
397
 
392
- // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
398
+ // env substitution: only files that were copied from this type's source directories, exist, and were not decided to be left alone
393
399
  for (const { srcDir, filter } of sources) {
394
400
  for (const filename of listYamlFiles(srcDir)) {
395
401
  const target = join(workflowsDir, filename);
396
- if (!filter(filename)) continue; // 배제된 CD·안 고른 배포 방식·안 고른 스토어 워크플로우
397
- if (!existsSync(target)) continue; // 건너뛴 파일 제외
398
- // unchanged/localOnly와 치환 마커가 이미 없는 유지본은 다시 치환하지 않는다. 대신 deploy 블록이
399
- // 사라지지 않도록 저장값(없으면 기본값)으로 ask 값만 수집한다 — 파일은 건드리지 않는다.
402
+ if (!filter(filename)) continue; // excluded CD, unchosen deploy styles, unchosen store workflows
403
+ if (!existsSync(target)) continue; // skip files that were skipped
404
+ // unchanged/localOnly and kept copies that no longer have substitution markers are not substituted again.
405
+ // Instead collect only the ask values from the saved values (defaults if none) so the deploy block does not
406
+ // disappear; the file itself is left untouched.
400
407
  if (untouched.includes(filename) || !readFileSync(target, "utf8").includes("@wizard")) {
401
408
  if (collectAsks) substituteEnv(srcText(join(srcDir, filename)), { ...envOpts, useDefaults: true, collectAsks });
402
409
  continue;
403
410
  }
404
- configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
411
+ configureEnv(target, { ...envOpts, collectAsks }); // includes the env plan values/useDefaults
405
412
  }
406
413
  }
407
414
  }
408
415
 
409
- // 전체 워크플로우 분류(common + 타입별 + server-deploy) — status/dry-run 공용.
410
- // changed뿐 아니라 newFiles/unchanged까지 전부 반환한다는 점이 listWorkflowConflicts(changed만
411
- // 반환)와 다르다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
416
+ // Full workflow classification (common + per type + server-deploy), shared by status/dry-run.
417
+ // Unlike listWorkflowConflicts (which returns only changed), it returns everything including newFiles/unchanged
418
+ // (read-only: writes no files).
412
419
  export function planWorkflows(context, payloadRoot, targetRoot = ".") {
413
420
  const { paths = new Map(), repoName = "", resolvers = {} } = context;
414
421
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
@@ -417,7 +424,7 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
417
424
  const baseline = readBaseline(targetRoot);
418
425
  const saved = readSavedDeployValues(targetRoot);
419
426
  const available = payloadWorkflowNames(payloadRoot);
420
- // upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다.
427
+ // upstreamOnly/localOnly/removed are filled only when a baseline exists.
421
428
  const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
422
429
  const BUCKETS = ["newFiles", "unchanged", "changed", "upstreamOnly", "localOnly", "removed"];
423
430