project-auto-wizard 0.16.0 → 0.16.2

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 (47) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +1 -1
  5. package/package.json +1 -1
  6. package/payload/scripts/messages.py +48 -6
  7. package/payload/scripts/version_manager.py +76 -3
  8. package/payload/version.yml.template +3 -2
  9. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +7 -7
  10. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +111 -46
  11. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +10 -1
  12. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +13 -3
  13. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +10 -1
  14. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +2 -2
  15. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +10 -10
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +10 -10
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +14 -12
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +35 -18
  19. package/payload/workflows/go/PROJECT-GO-CI.yaml +3 -1
  20. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +81 -61
  21. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +3 -1
  22. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +81 -61
  23. package/payload/workflows/react/PROJECT-REACT-CI.yaml +3 -2
  24. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +4 -4
  25. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +86 -68
  26. package/src/commands/doctor.js +17 -23
  27. package/src/commands/dry-run.js +4 -3
  28. package/src/commands/full.js +6 -6
  29. package/src/commands/interactive.js +5 -1
  30. package/src/commands/status.js +9 -2
  31. package/src/core/copy/gitignore.js +3 -3
  32. package/src/core/copy/readme.js +11 -10
  33. package/src/core/detect-fs.js +9 -4
  34. package/src/core/options.js +36 -0
  35. package/src/core/version-yml.js +37 -13
  36. package/src/i18n/catalog/en/cli.js +1 -0
  37. package/src/i18n/catalog/en/commands.js +4 -1
  38. package/src/i18n/catalog/en/core.js +1 -0
  39. package/src/i18n/catalog/en/core2.js +2 -0
  40. package/src/i18n/catalog/en/ui.js +3 -0
  41. package/src/i18n/catalog/ko/cli.js +1 -0
  42. package/src/i18n/catalog/ko/commands.js +4 -1
  43. package/src/i18n/catalog/ko/core.js +1 -0
  44. package/src/i18n/catalog/ko/core2.js +2 -0
  45. package/src/i18n/catalog/ko/ui.js +3 -0
  46. package/src/index.js +19 -7
  47. package/src/ui/summary.js +15 -6
@@ -1,7 +1,7 @@
1
1
  // status command: read-only install state check. No network access (local file comparison only).
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { parseExisting, droppedPathLines } from "../core/version-yml.js";
4
+ import { parseExisting, droppedPathLines, invalidOptionLines } from "../core/version-yml.js";
5
5
  import { OPTIONS } from "../core/options.js";
6
6
  import { planWorkflows } from "../core/copy/workflows.js";
7
7
  import { makeResolvers, detectRepoName, detectDefaultBranch } from "../core/detect-fs.js";
@@ -47,6 +47,7 @@ export function runStatus(payloadRoot, targetRoot = ".") {
47
47
  types: existing.types,
48
48
  branches: existing.branches,
49
49
  options: existing.options,
50
+ invalidOptions: existing.invalidOptions,
50
51
  // Files the user touched = real conflicts (changed) + upstream unchanged but locally edited (localOnly).
51
52
  // Without a baseline localOnly is always empty, so behavior is the same as before.
52
53
  modifiedFiles: [...plan.changed, ...plan.localOnly].map((f) => f.filename),
@@ -79,7 +80,13 @@ export function printStatus(status) {
79
80
  }
80
81
  // An unset option is shown with the value it resolves to on this (existing) install, i.e. its legacyDefault.
81
82
  const unsetLabel = (o) => t(o.legacyDefault ? "cmd.status.unsetDefaultTrue" : "cmd.status.unsetDefaultFalse");
82
- const optionLabels = OPTIONS.map((o) => `${o.key}=${status.options[o.name] ?? unsetLabel(o)}`).join(" ");
83
+ // A key written with an unrecognized value reads as false; show that instead of pretending the file is fine.
84
+ const invalidByKey = new Map((status.invalidOptions || []).map((i) => [i.key, i.value]));
85
+ const optionLabel = (o) => {
86
+ if (invalidByKey.has(o.key)) return `${o.key}=${status.options[o.name]} (${t("cmd.status.invalidValue", { value: invalidByKey.get(o.key) })})`;
87
+ return `${o.key}=${status.options[o.name] ?? unsetLabel(o)}`;
88
+ };
89
+ const optionLabels = OPTIONS.map(optionLabel).join(" ");
83
90
  const typeLabels = hooksFor(status.types, "statusLabels").map(({ hook }) => hook(status.options)).join("");
84
91
  const deployLabel = status.options.deployStyle ? ` deploy_style=${status.options.deployStyle}` : "";
85
92
  lines.push(t("cmd.status.options", { value: `${optionLabels}${deployLabel}${typeLabels}` }));
@@ -43,7 +43,7 @@ const newFileContent = (lang) =>
43
43
  "*.template.yaml\n";
44
44
  // The header comment depends on the language it was written in, so a file created in one language must
45
45
  // still be recognized under another: check every supported language's variant.
46
- const NEW_FILE_CONTENT = () => newFileContent(undefined);
46
+ const NEW_FILE_CONTENT = (lang) => newFileContent(lang);
47
47
  const matchingNewFileContent = (content) =>
48
48
  [NEW_FILE_CONTENT(), ...SUPPORTED_LANGUAGES.map(newFileContent)].find((c) => content.startsWith(c)) ?? null;
49
49
 
@@ -57,11 +57,11 @@ export function planGitignore(targetRoot = ".") {
57
57
  }
58
58
 
59
59
  // Returns: {created, added:[...]}
60
- export function ensureGitignore(targetRoot = ".") {
60
+ export function ensureGitignore(targetRoot = ".", lang) {
61
61
  const p = join(targetRoot, ".gitignore");
62
62
  const plan = planGitignore(targetRoot);
63
63
  if (plan.created) {
64
- writeFileSync(p, NEW_FILE_CONTENT());
64
+ writeFileSync(p, NEW_FILE_CONTENT(lang));
65
65
  return plan;
66
66
  }
67
67
  const toAdd = plan.added;
@@ -30,9 +30,9 @@ const historyLink = (lang) => t("copy.readme.historyLink", {}, lang);
30
30
  // default is replaced: a heading or link the user edited is left alone, and so is anything not directly
31
31
  // under the marker (code-block examples, a README without the marker).
32
32
  // Returns the new content, or null when nothing needs to change.
33
- function refreshBlockLanguage(content) {
34
- const currentHeading = defaultHeading();
35
- const currentLink = historyLink();
33
+ function refreshBlockLanguage(content, lang) {
34
+ const currentHeading = defaultHeading(lang);
35
+ const currentLink = historyLink(lang);
36
36
  const markerRe = new RegExp("^" + escapeRe(MARKER_LINE.trimEnd()) + "[ \\t]*\\r?\\n", "gm");
37
37
  let out = "";
38
38
  let last = 0;
@@ -70,22 +70,23 @@ function refreshBlockLanguage(content) {
70
70
  // Skip when README.md is missing, or when a marker or version line exists. Otherwise append to the end.
71
71
  // Returns: 'skip-no-readme' | 'heading-updated' | 'skip-marker' | 'skip-version-line' | 'added'
72
72
  // Only decides without writing, so the real append and the --dry-run preview share one decision.
73
- export function planVersionSection(targetRoot = ".") {
73
+ // lang: the language recorded in version.yml for this install (not necessarily the language of this run's output)
74
+ export function planVersionSection(targetRoot = ".", lang) {
74
75
  const p = join(targetRoot, "README.md");
75
76
  if (!existsSync(p)) return "skip-no-readme";
76
77
  const content = readFileSync(p, "utf8");
77
- if (refreshBlockLanguage(content) !== null) return "heading-updated";
78
+ if (refreshBlockLanguage(content, lang) !== null) return "heading-updated";
78
79
  if (content.includes(MARKER)) return "skip-marker";
79
80
  if (VERSION_LINE_RE.test(content)) return "skip-version-line";
80
81
  return "added";
81
82
  }
82
83
 
83
- export function addVersionSectionToReadme(version, targetRoot = ".") {
84
- const status = planVersionSection(targetRoot);
84
+ export function addVersionSectionToReadme(version, targetRoot = ".", lang) {
85
+ const status = planVersionSection(targetRoot, lang);
85
86
  const p = join(targetRoot, "README.md");
86
87
  if (status === "heading-updated") {
87
88
  // Swap only the bundled default wording; version text after the colon stays as the workflow wrote it.
88
- writeFileSync(p, refreshBlockLanguage(readFileSync(p, "utf8")));
89
+ writeFileSync(p, refreshBlockLanguage(readFileSync(p, "utf8"), lang));
89
90
  return status;
90
91
  }
91
92
  if (status !== "added") return status;
@@ -97,9 +98,9 @@ export function addVersionSectionToReadme(version, targetRoot = ".") {
97
98
  "---\n" +
98
99
  "\n" +
99
100
  "<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n" +
100
- t("copy.readme.versionHeading", { version }) + "\n" +
101
+ t("copy.readme.versionHeading", { version }, lang) + "\n" +
101
102
  "\n" +
102
- t("copy.readme.historyLink") + "\n";
103
+ t("copy.readme.historyLink", {}, lang) + "\n";
103
104
  // Appending directly to a README without a trailing newline puts "---" right after the last line,
104
105
  // and Markdown turns that line into a heading (Setext h2), so terminate the line first.
105
106
  const lead = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
@@ -39,11 +39,16 @@ export function detectTypes(root, { paths = new Map(), warn } = {}) {
39
39
  if (fromRoot.length === 1 && fromRoot[0] === "basic" && warn) {
40
40
  const found = findSubdirProjects(root);
41
41
  if (found.length) {
42
- const firstDir = new Map();
43
- for (const { dir, types } of found) for (const t of types) if (!firstDir.has(t)) firstDir.set(t, dir);
42
+ // Only one folder per type is managed and the hint names the first one, so say which folders of the same type are left out
43
+ const dirsByType = new Map();
44
+ for (const { dir, types } of found) for (const t of types) dirsByType.set(t, [...(dirsByType.get(t) || []), dir]);
44
45
  const list = found.map(({ dir, types }) => `${dir}(${types.join(", ")})`).join(", ");
45
- const hint = [...firstDir].map(([t, d]) => `${t}=${d}`).join(",");
46
- warn(tr("core.detectFs.monorepoWarn", { list, hint }));
46
+ const hint = [...dirsByType].map(([t, dirs]) => `${t}=${dirs[0]}`).join(",");
47
+ const dropped = [...dirsByType].filter(([, dirs]) => dirs.length > 1).flatMap(([type, [kept, ...rest]]) => [
48
+ tr("core.detectFs.monorepoDropped", { type, kept, lost: rest.join(", ") }),
49
+ ...rest.map((path) => tr("core.versionYml.pathMergedHint", { type, path })),
50
+ ]);
51
+ warn([tr("core.detectFs.monorepoWarn", { list, hint }), ...dropped.map((l) => ` ${l}`)].join("\n"));
47
52
  }
48
53
  }
49
54
  return fromRoot;
@@ -11,6 +11,10 @@
11
11
  // logLabel : label used by the install log line ("option <logLabel> on|off")
12
12
  // ask : present only for options the interactive wizard asks about / lists in the edit menu
13
13
  // { questionKey, initial, menuLabelKey, summaryKey, cardIcon, cardLabelKey } (all i18n keys except initial/cardIcon)
14
+ // doctor : present only for options that get an info item in `doctor`
15
+ // { prefix, always, notes } - `<prefix>.name` / `<prefix>.purpose` label the item; `always` shows it even without an install
16
+ // (otherwise it is left out, since there is no saved value to report); `notes` maps the saved value ("true" | "false" | "null")
17
+ // to the `<prefix>.<key>` messages to print, plus an optional `invalid` list (its messages get {value}) that turns the item into a warning
14
18
  // Keys only - this module never imports the i18n layer, so core stays free of UI dependencies.
15
19
  export const OPTIONS = Object.freeze([
16
20
  {
@@ -27,6 +31,14 @@ export const OPTIONS = Object.freeze([
27
31
  key: "copilot_ai", name: "copilotAi", ctxField: "includeCopilotAi",
28
32
  flag: "copilot", default: false, legacyDefault: false,
29
33
  conflictKey: "cli.args.copilotConflict", logLabel: "copilot",
34
+ doctor: {
35
+ prefix: "cmd.doctor.copilot", always: true,
36
+ notes: {
37
+ true: ["on", "creditsOn", "fallback"],
38
+ false: ["off", "creditsOff", "fallback"],
39
+ null: ["default", "creditsOff", "fallback"],
40
+ },
41
+ },
30
42
  ask: {
31
43
  questionKey: "interactive.question.copilotAi", initial: false,
32
44
  menuLabelKey: "ui.prompts.edit.copilotAi", summaryKey: "interactive.summary.copilotAi",
@@ -38,11 +50,35 @@ export const OPTIONS = Object.freeze([
38
50
  key: "release_automerge", name: "releaseAutomerge", ctxField: "includeReleaseAutomerge",
39
51
  flag: "release-automerge", default: true, legacyDefault: true,
40
52
  conflictKey: "cli.args.releaseAutomergeConflict", logLabel: "release-automerge",
53
+ doctor: {
54
+ prefix: "cmd.doctor.automerge", always: false,
55
+ notes: {
56
+ // A missing key also means on (installs from before the option existed already automerge)
57
+ true: ["on", "onHow"], false: ["off", "offHow"], null: ["on", "onHow"],
58
+ invalid: ["invalid", "invalidHow"],
59
+ },
60
+ },
41
61
  },
42
62
  ]);
43
63
 
44
64
  export const optionByKey = (key) => OPTIONS.find((o) => o.key === key);
45
65
 
66
+ // The one rule for reading a boolean option value written in version.yml. The installed workflows read the same
67
+ // value through version_manager.py (`option` command); tests/fixtures/option-value-cases.json runs both readers.
68
+ // - an inline comment (whitespace + #) is dropped, then one pair of matching quotes, then case is ignored
69
+ // - only true / false are recognized; anything else (yes, off, 0, maybe, empty, null) returns null
70
+ // A key that is present with an unrecognized value is NOT the same as a missing key: callers treat it as false (the
71
+ // conservative side - nothing is switched on or merged by itself) and report it, instead of guessing the intent.
72
+ export function parseOptionValue(raw) {
73
+ let v = String(raw ?? "").replace(/(^|\s)#.*$/, "").trim();
74
+ const quoted = v.match(/^(["'])(.*)\1$/);
75
+ if (quoted) v = quoted[2].trim();
76
+ v = v.toLowerCase();
77
+ if (v === "true") return true;
78
+ if (v === "false") return false;
79
+ return null;
80
+ }
81
+
46
82
  // version.yml template variable name, e.g. semver_auto -> OPT_SEMVER_AUTO
47
83
  export const optionVar = (o) => `OPT_${o.key.toUpperCase()}`;
48
84
 
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_DEPLOY_STYLE } from "./deploy-style.js";
2
- import { OPTIONS, optionVar, renderValues } from "./options.js";
2
+ import { OPTIONS, optionVar, renderValues, parseOptionValue } from "./options.js";
3
3
  import { escapeYamlDoubleQuoted } from "./wizard-env.js";
4
4
  import { hooksFor, mergeHookResults, allHookValues, canonicalTypeId, canonicalTypeIds } from "./types.js";
5
5
  import { normalizePath } from "./paths.js";
@@ -43,14 +43,17 @@ export function parseExtraTopLevel(content) {
43
43
  // Type-specific option key -> returned field (type hook savedOptionKeys). The value is returned as the raw string; validity is up to the type hook's resolveOptions.
44
44
  const TYPE_OPTION_KEYS = allHookValues("savedOptionKeys");
45
45
  const OPTION_BY_KEY = new Map(OPTIONS.map((o) => [o.key, o]));
46
- const OPTION_LINE = new RegExp(`^\\s+(${OPTIONS.map((o) => o.key).join("|")}):\\s*(.+)`);
46
+ // (.*) on purpose: a key with an empty value is still a written value (an unrecognized one), not a missing key.
47
+ const OPTION_LINE = new RegExp(`^\\s+(${OPTIONS.map((o) => o.key).join("|")}):\\s*(.*)`);
47
48
  const TYPE_OPTION_LINE = new RegExp(`^\\s+(${Object.keys(TYPE_OPTION_KEYS).join("|")}):\\s*(.+)`);
48
49
 
49
50
  // State-machine parse of metadata.template.options.
50
51
  // Returns: { <one bool|null per registry option, e.g. semverAuto/copilotAi>, deployStyle: string|null,
51
52
  // type-specific option fields (e.g. envMode/flutterStore/androidDeployMode/iosDeployMode): string|null } - null = not written.
52
53
  // Other keys such as the old synology/coderabbit ones hit no branch and are naturally ignored (no parse error).
53
- export function parseTemplateOptions(content) {
54
+ // invalid (optional out array): receives { key, value } for every option key whose written value was not recognized.
55
+ export function parseTemplateOptions(content, invalid = []) {
56
+ const seen = new Set();
54
57
  const out = {
55
58
  ...Object.fromEntries(OPTIONS.map((o) => [o.name, null])), deployStyle: null,
56
59
  ...Object.fromEntries(Object.values(TYPE_OPTION_KEYS).map((field) => [field, null])),
@@ -69,12 +72,18 @@ export function parseTemplateOptions(content) {
69
72
  if (m) { const v = strip(m[1]); if (v) out.deployStyle = v; continue; }
70
73
  m = line.match(TYPE_OPTION_LINE);
71
74
  if (m) { const v = strip(m[2]); if (v) out[TYPE_OPTION_KEYS[m[1]]] = v; continue; }
72
- // Boolean options come from the registry; a value other than true/false is left null (= not written).
75
+ // Boolean options come from the registry and are read with parseOptionValue (same rule as the workflows).
76
+ // The first occurrence wins, like the workflow reader. An unrecognized value reads as false and is reported
77
+ // through `invalid`, so it can never turn an option on (or release_automerge back on) without a word.
73
78
  const optionLine = line.match(OPTION_LINE);
74
79
  if (optionLine) {
75
- const v = strip(optionLine[2]);
76
- if (v === "true") out[OPTION_BY_KEY.get(optionLine[1]).name] = true;
77
- if (v === "false") out[OPTION_BY_KEY.get(optionLine[1]).name] = false;
80
+ const name = OPTION_BY_KEY.get(optionLine[1]).name;
81
+ if (!seen.has(name)) {
82
+ seen.add(name);
83
+ const parsed = parseOptionValue(optionLine[2]);
84
+ out[name] = parsed ?? false;
85
+ if (parsed === null) invalid.push({ key: optionLine[1], value: optionLine[2].replace(/\r$/, "").trim() });
86
+ }
78
87
  continue;
79
88
  }
80
89
  // another key indented 0-4 spaces -> end of the options section
@@ -101,6 +110,11 @@ export function droppedPathLines(droppedPaths = [], finalPaths = null) {
101
110
  });
102
111
  }
103
112
 
113
+ // Warning lines for option values that were written but not recognized (they read as false); empty when all are fine.
114
+ export function invalidOptionLines(invalidOptions = []) {
115
+ return invalidOptions.map((o) => tr("core.versionYml.optionInvalid", { key: o.key, value: o.value }));
116
+ }
117
+
104
118
  // Extract values from an existing version.yml (line-based, avoids false hits on comment lines).
105
119
  export function parseExisting(content) {
106
120
  const text = String(content || "");
@@ -125,6 +139,12 @@ export function parseExisting(content) {
125
139
  // language: "en" - only supported values count; a hand-edited unknown value is treated as unset
126
140
  const langRaw = normalizeLanguage((line(/^language:\s*(.+)/) || "").replace(/\s+#.*$/, "").replace(/["']/g, ""));
127
141
  const language = isSupportedLanguage(langRaw) ? langRaw : null;
142
+ // The unsupported value that was written (null when the key is absent or valid), so callers can tell "no key" from "unknown value"
143
+ const languageUnsupported = langRaw && !language ? langRaw : null;
144
+ // metadata.last_updated_by: the release scripts write the acting user here, so a re-install keeps it instead of resetting it
145
+ // (quoted values are unescaped; an unquoted one ends at whitespace or a comment)
146
+ const byMatch = /^[ \t]+last_updated_by:[ \t]*(?:"((?:[^"\\]|\\.)*)"|([^\s#"']+))/m.exec(text);
147
+ const lastUpdatedBy = byMatch ? (byMatch[1] !== undefined ? byMatch[1].replace(/\\(.)/g, "$1") : byMatch[2]) : null;
128
148
  // project_paths block: ` type: "path"`
129
149
  const paths = new Map();
130
150
  // Entries folded into another entry of the same canonical type with a different folder (react: client + next: web).
@@ -171,11 +191,12 @@ export function parseExisting(content) {
171
191
  }
172
192
  }
173
193
  // Optional workflow options (metadata.template.options)
174
- const options = parseTemplateOptions(text);
194
+ const invalidOptions = [];
195
+ const options = parseTemplateOptions(text, invalidOptions);
175
196
  // metadata.template.branches - main/develop/mode (to skip re-asking in update mode)
176
197
  const branches = parseTemplateBranches(text);
177
198
  return {
178
- version, versionCode, types, language, paths, droppedPaths, templateVersion, options, branches,
199
+ version, versionCode, types, language, languageUnsupported, lastUpdatedBy, paths, droppedPaths, templateVersion, options, invalidOptions, branches,
179
200
  deploy: parseDeployBlock(text), extraTopLevel: parseExtraTopLevel(text),
180
201
  };
181
202
  }
@@ -247,6 +268,7 @@ export function buildVersionYml({
247
268
  templateText, version, types = [], paths = new Map(), pathMarkers = new Map(),
248
269
  branch = "main", branches = null, versionCode = 1, now, today,
249
270
  templateOptions = null, deployValues = new Map(), extraTopLevel = [], typeOptions = {}, language = DEFAULT_LANGUAGE,
271
+ lastUpdatedBy = "",
250
272
  }) {
251
273
  if (!templateText) throw new Error(tr("core.versionYml.error.templateRequired"));
252
274
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(", ")}]` : `["basic"]`;
@@ -260,7 +282,7 @@ export function buildVersionYml({
260
282
  // project_paths block (full-line token {{PROJECT_PATHS}} - line removed when absent)
261
283
  let pathsBlock = "";
262
284
  if (paths.size) {
263
- const rows = [`project_paths: # ${tr("core.versionYml.pathsComment")}`];
285
+ const rows = [`project_paths: # ${tr("core.versionYml.pathsComment", {}, language)}`];
264
286
  for (const [t, p] of paths) {
265
287
  const marker = pathMarkers.get(t) || "";
266
288
  const pf = p === "." ? marker : (marker ? `${p}/${marker}` : p);
@@ -273,7 +295,7 @@ export function buildVersionYml({
273
295
  let deployBlock = "";
274
296
  const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
275
297
  if (deployTypes.length) {
276
- const rows = ["", `deploy: # ${tr("core.versionYml.deployComment")}`];
298
+ const rows = ["", `deploy: # ${tr("core.versionYml.deployComment", {}, language)}`];
277
299
  for (const t of deployTypes) {
278
300
  rows.push(` ${t}:`);
279
301
  // Reuse the same escape - deploy values arrive by the same path as @wizard ask values, so
@@ -291,6 +313,8 @@ export function buildVersionYml({
291
313
  PROJECT_TYPES: typesJson,
292
314
  LANGUAGE: isSupportedLanguage(language) ? language : DEFAULT_LANGUAGE,
293
315
  NOW: now, TODAY: today || optionsDate, DEFAULT_BRANCH: branch,
316
+ // A previously recorded value (e.g. the user who last bumped the version) wins over the installer's own name
317
+ LAST_UPDATED_BY: escapeYamlDoubleQuoted(lastUpdatedBy || "project-auto-wizard"),
294
318
  TEMPLATE_VERSION: templateVersion,
295
319
  MAIN_BRANCH: b.main, DEVELOP_BRANCH: b.develop, BRANCH_MODE: b.mode,
296
320
  ...Object.fromEntries(OPTIONS.map((o) => [optionVar(o), String(optionValues[o.ctxField])])),
@@ -329,13 +353,13 @@ export function sameIgnoringTimestamps(a, b) {
329
353
  // Builds the final version.yml from a single context - the real install (full) and the preview (dry-run)
330
354
  // use the same function, structurally preventing "the preview differs from the result".
331
355
  // deployValues exist only in a real install (the preview performs no substitution, so it is an empty Map).
332
- export function renderVersionYml(context, templateText, { pathMarkers, deployValues = new Map(), extraTopLevel = [] }) {
356
+ export function renderVersionYml(context, templateText, { pathMarkers, deployValues = new Map(), extraTopLevel = [], lastUpdatedBy = "" }) {
333
357
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
334
358
  now, today, templateVersion = "unknown", branches = null, language,
335
359
  deployStyle } = context;
336
360
  return buildVersionYml({
337
361
  templateText, version, types, paths, pathMarkers, branch, branches, versionCode, now, today,
338
- deployValues, extraTopLevel, language,
362
+ deployValues, extraTopLevel, language, lastUpdatedBy,
339
363
  typeOptions: mergeHookResults(types, "optionsFromContext", context),
340
364
  templateOptions: {
341
365
  templateVersion,
@@ -2,6 +2,7 @@
2
2
  export default {
3
3
  "cli.lang.invalid": "Unsupported language '{value}' ({source}). Supported: {supported}",
4
4
  "cli.lang.defaultNotice": "Workflow messages now default to English. Use --lang ko to keep Korean.",
5
+ "cli.lang.unsupportedSavedNotice": "version.yml has language '{value}', which is not supported ({supported}); workflow messages use English. Use --lang to choose one.",
5
6
  "cli.lang.missing": "--lang requires a value. Supported: {supported}",
6
7
  "cli.lang.help": "Message language: {supported} (default: en)",
7
8
  "cli.bin.nodeVersion": "Node.js 20.12 or later is required (current: {current})",
@@ -9,6 +9,7 @@ export default {
9
9
  "cmd.status.branchMode": "Branch mode : {mode} ({main} / {develop})",
10
10
  "cmd.status.unsetDefaultFalse": "unset (default false)",
11
11
  "cmd.status.unsetDefaultTrue": "unset (default true)",
12
+ "cmd.status.invalidValue": "invalid value \"{value}\", read as false",
12
13
  "cmd.status.options": "Options : {value}",
13
14
  "cmd.status.modified": "Workflow files modified by the user ({n}):",
14
15
  "cmd.status.unmodified": "All workflow files match their install-time defaults (no modifications).",
@@ -84,7 +85,9 @@ export default {
84
85
  "cmd.doctor.automerge.on": "It is on (release_automerge: true in version.yml, or the key is absent).",
85
86
  "cmd.doctor.automerge.onHow": "To merge the release PR yourself, reinstall with --no-release-automerge or set release_automerge: false in version.yml.",
86
87
  "cmd.doctor.automerge.off": "It is off (release_automerge: false in version.yml); the workflow prepares the release PR but does not merge it.",
87
- "cmd.doctor.automerge.offHow": "Merge the release PR yourself with a merge commit (not squash or rebase) so the release still publishes; --release-automerge turns it back on.",
88
+ "cmd.doctor.automerge.offHow": "Merge the release PR yourself with a merge commit (recommended): squash and rebase merges skip the release workflow because of [skip ci], so run PROJECT-RELEASE-PUBLISH manually if you used one. --release-automerge turns automerge back on.",
89
+ "cmd.doctor.automerge.invalid": "version.yml has release_automerge: \"{value}\", which is not true or false. It is read as false, so the workflow does not merge the release PR.",
90
+ "cmd.doctor.automerge.invalidHow": "Set release_automerge to true or false in version.yml (or reinstall with --release-automerge / --no-release-automerge).",
88
91
  "cmd.doctor.copilot.fallback": "When it is off or unavailable, it switches to the rule-based summary automatically, so you can leave it as is.",
89
92
  "cmd.doctor.heading": "Environment check",
90
93
  "cmd.doctor.details": "Details: ",
@@ -5,6 +5,7 @@ export default {
5
5
  "core.detect.buildNumber.pubspecMissing": "⚠️ pubspec.yaml has no build number (+N), so version_code could not be detected — using the default 1. Check your actual build number.",
6
6
  "core.detect.buildNumber.gradleMissing": "⚠️ android/app/build.gradle has no versionCode, so version_code could not be detected — using the default 1. Check your actual build number.",
7
7
  "core.detect.buildNumber.expoMissing": "⚠️ expo.android.versionCode is missing in app.json, so version_code could not be detected — using the default 1. Check your actual build number.",
8
+ "core.detectFs.monorepoDropped": "Only one folder per type is managed: {type}={kept} would be used, and {lost} would stay out of CI and version sync.",
8
9
  "core.detectFs.monorepoWarn": "⚠️ No project files found at the root, installing as basic. Found in subfolders: {list}\n For a monorepo, run again with --paths \"{hint}\".",
9
10
  "core.detectFs.defaultBranchWarn": "⚠️ Could not determine the remote default branch (e.g. an empty remote repo), assuming '{fallback}' as the release branch.{hint}",
10
11
  "core.branches.developMissing": "The remote has no '{develop}' branch — commit the installed files, push '{main}', then create it: git push origin {main}:{develop}",
@@ -4,6 +4,8 @@ export default {
4
4
  "core.versionYml.pathMergedTitle": "One folder per type",
5
5
  "core.versionYml.pathMerged": "version.yml lists '{keptName}' (folder {kept}) and '{name}' (folder {path}) separately, but they are now the same type and only one folder per type is managed. Keeping {type}={final}; {lost} drops out of CI and version sync.",
6
6
  "core.versionYml.pathMergedHint": "To manage {path} instead, rerun with --paths {type}={path}; to keep both, add a hand-written CI workflow for the dropped folder.",
7
+ "core.versionYml.optionInvalid": "version.yml option {key} has an unrecognized value \"{value}\"; it is read as false. Use true or false.",
8
+ "core.versionYml.optionInvalidTitle": "Unrecognized option value",
7
9
  "core.breakingCheck.nonInteractive": "⚠️ There are CRITICAL breaking changes - continuing because this is a non-interactive run. Be sure to review the notes above.",
8
10
  "core.logger.error.nameExhausted": "All log file names are in use: {base}",
9
11
  "core.logger.warn.startFailed": "Could not start the run log: {message}",
@@ -34,6 +34,7 @@ export default {
34
34
  "ui.summary.branches.title": "Branch layout:",
35
35
  "ui.summary.branches.trunk": " 🌿 Single branch {main} (trunk-based) — one RELEASE-PUBLISH workflow handles version confirm → changelog → tag → Release in sequence",
36
36
  "ui.summary.branches.prFlow": " 🌿 Development {develop} → release {main} (pr-flow) — version confirm, changelog and automerge happen on the release PR",
37
+ "ui.summary.branches.prFlowManual": " 🌿 Development {develop} → release {main} (pr-flow) — version confirm and changelog happen on the release PR; you merge it yourself (automerge is off)",
37
38
  "ui.summary.branches.developMissing": " ⚠️ Branch '{develop}' does not exist on the remote yet — development-branch workflows will not run until it is created",
38
39
  "ui.summary.branches.developHint": " → After committing the installed files and pushing {main}: git push origin {main}:{develop}",
39
40
  "ui.summary.engine.title": "Release-note summary engine:",
@@ -77,6 +78,8 @@ export default {
77
78
  "ui.summary.todo.commitExample": " e.g. git checkout {develop} && git merge {main} && git push",
78
79
  "ui.summary.todo.changelogLink": " → The README's 'View full version history' (CHANGELOG.md) link works once the first release generates the CHANGELOG",
79
80
  "ui.summary.todo.pat": "PAT for release automerge (optional — falls back to GITHUB_TOKEN automatically if missing)",
81
+ "ui.summary.todo.manualMerge": "Merge the release PR yourself (release_automerge is off — no PAT needed for it)",
82
+ "ui.summary.todo.manualMergeHow": " → Once the workflow run on the release PR has finished, merge it with a merge commit (recommended)",
80
83
  "ui.summary.todo.patAccount": " → Issue it from an organization bot/machine account rather than a personal account",
81
84
  "ui.summary.todo.patOptional": " → Without it, tag/Release publishing and deploy workflow runs still proceed automatically; with it, it is slightly faster",
82
85
  "ui.summary.todo.permissions": "GitHub Actions permissions (no change needed)",
@@ -2,6 +2,7 @@
2
2
  export default {
3
3
  "cli.lang.invalid": "지원하지 않는 언어: '{value}' ({source}). 지원 언어: {supported}",
4
4
  "cli.lang.defaultNotice": "워크플로우 메시지의 기본 언어가 영어로 바뀌었습니다. 한국어를 유지하려면 --lang ko를 사용하세요.",
5
+ "cli.lang.unsupportedSavedNotice": "version.yml의 language '{value}'는 지원하지 않는 값이라({supported}) 워크플로우 메시지는 영어로 나옵니다. --lang으로 언어를 고르세요.",
5
6
  "cli.lang.missing": "--lang 값이 필요합니다. 지원 언어: {supported}",
6
7
  "cli.lang.help": "메시지 언어: {supported} (기본: en)",
7
8
  "cli.bin.nodeVersion": "Node.js 20.12 이상이 필요합니다 (현재: {current})",
@@ -9,6 +9,7 @@ export default {
9
9
  "cmd.status.branchMode": "브랜치 모드 : {mode} ({main} / {develop})",
10
10
  "cmd.status.unsetDefaultFalse": "미설정(기본 false)",
11
11
  "cmd.status.unsetDefaultTrue": "미설정(기본 true)",
12
+ "cmd.status.invalidValue": "잘못된 값 \"{value}\", false로 읽음",
12
13
  "cmd.status.options": "옵션 : {value}",
13
14
  "cmd.status.modified": "사용자가 수정한 워크플로우 파일 ({n}개):",
14
15
  "cmd.status.unmodified": "모든 워크플로우 파일이 설치 시점 기본값과 동일합니다 (수정 없음).",
@@ -84,7 +85,9 @@ export default {
84
85
  "cmd.doctor.automerge.on": "켜져 있습니다 (version.yml의 release_automerge: true, 또는 키가 없음).",
85
86
  "cmd.doctor.automerge.onHow": "릴리스 PR을 직접 머지하려면 --no-release-automerge로 다시 설치하거나 version.yml의 release_automerge를 false로 바꾸세요.",
86
87
  "cmd.doctor.automerge.off": "꺼져 있습니다 (version.yml의 release_automerge: false) — 워크플로우가 릴리스 PR을 준비만 하고 머지하지 않습니다.",
87
- "cmd.doctor.automerge.offHow": "릴리스가 정상 배포되도록 머지 커밋으로 직접 머지하세요(squash·rebase 금지). 다시 켜려면 --release-automerge를 쓰세요.",
88
+ "cmd.doctor.automerge.offHow": "머지 커밋으로 직접 머지하는 것을 권장합니다. squash·rebase 머지는 [skip ci] 때문에 릴리스 워크플로우가 건너뛰어지므로, 그렇게 머지했다면 PROJECT-RELEASE-PUBLISH를 수동 실행하세요. 다시 켜려면 --release-automerge를 쓰세요.",
89
+ "cmd.doctor.automerge.invalid": "version.yml의 release_automerge 값 \"{value}\"이(가) true도 false도 아닙니다. false로 읽으므로 워크플로우가 릴리스 PR을 머지하지 않습니다.",
90
+ "cmd.doctor.automerge.invalidHow": "version.yml의 release_automerge를 true 또는 false로 고치세요(또는 --release-automerge / --no-release-automerge로 다시 설치).",
88
91
  "cmd.doctor.copilot.fallback": "꺼져 있거나 사용할 수 없으면 규칙 기반 요약으로 자동 전환되므로 그대로 두셔도 됩니다.",
89
92
  "cmd.doctor.heading": "환경 진단",
90
93
  "cmd.doctor.details": "자세히: ",
@@ -5,6 +5,7 @@ export default {
5
5
  "core.detect.buildNumber.pubspecMissing": "⚠️ pubspec.yaml에 빌드 번호(+N)가 없어 version_code를 감지하지 못했습니다 — 기본값 1을 사용합니다. 실제 빌드 번호를 확인하세요.",
6
6
  "core.detect.buildNumber.gradleMissing": "⚠️ android/app/build.gradle에 versionCode가 없어 version_code를 감지하지 못했습니다 — 기본값 1을 사용합니다. 실제 빌드 번호를 확인하세요.",
7
7
  "core.detect.buildNumber.expoMissing": "⚠️ app.json의 expo.android.versionCode가 없어 version_code를 감지하지 못했습니다 — 기본값 1을 사용합니다. 실제 빌드 번호를 확인하세요.",
8
+ "core.detectFs.monorepoDropped": "타입당 폴더 하나만 관리합니다: {type}={kept} 를 쓰고, {lost} 는 CI·버전 동기화에서 빠집니다.",
8
9
  "core.detectFs.monorepoWarn": "⚠️ 루트에서 프로젝트 파일을 찾지 못해 basic으로 설치합니다. 하위 폴더에서 발견: {list}\n 모노레포라면 --paths \"{hint}\"로 다시 실행하세요.",
9
10
  "core.detectFs.defaultBranchWarn": "⚠️ 원격 기본 브랜치를 확인할 수 없어(빈 원격 레포 등) 릴리스 브랜치를 '{fallback}'(으)로 가정합니다.{hint}",
10
11
  "core.branches.developMissing": "원격에 '{develop}' 브랜치가 없습니다 — 설치 파일을 커밋해 '{main}'을 push한 뒤 만들어 주세요: git push origin {main}:{develop}",
@@ -4,6 +4,8 @@ export default {
4
4
  "core.versionYml.pathMergedTitle": "타입당 폴더 하나",
5
5
  "core.versionYml.pathMerged": "version.yml 에 '{keptName}'(폴더 {kept})과 '{name}'(폴더 {path})이 따로 적혀 있지만 이제 같은 타입이라 타입당 폴더 하나만 관리합니다. {type}={final} 를 유지하고, {lost} 는 CI·버전 동기화에서 빠집니다.",
6
6
  "core.versionYml.pathMergedHint": "{path} 를 대신 관리하려면 --paths {type}={path} 로 다시 실행하세요. 둘 다 유지하려면 빠진 폴더용 CI 워크플로우를 직접 추가하세요.",
7
+ "core.versionYml.optionInvalid": "version.yml의 옵션 {key} 값 \"{value}\"을(를) 인식하지 못해 false로 읽습니다. true 또는 false를 쓰세요.",
8
+ "core.versionYml.optionInvalidTitle": "인식하지 못한 옵션 값",
7
9
  "core.breakingCheck.nonInteractive": "⚠️ CRITICAL 호환성 변경이 있습니다 — 비대화형 실행이라 계속 진행합니다. 위 내용을 꼭 확인하세요.",
8
10
  "core.logger.error.nameExhausted": "로그 파일 이름이 모두 사용 중입니다: {base}",
9
11
  "core.logger.warn.startFailed": "실행 로그를 시작하지 못했습니다: {message}",
@@ -34,6 +34,7 @@ export default {
34
34
  "ui.summary.branches.title": "브랜치 구성:",
35
35
  "ui.summary.branches.trunk": " 🌿 {main} 단일 브랜치 (trunk-based) — RELEASE-PUBLISH 하나가 버전확정→체인지로그→tag→Release를 순차 처리",
36
36
  "ui.summary.branches.prFlow": " 🌿 개발 {develop} → 릴리스 {main} (pr-flow) — 릴리스 PR에서 버전확정·체인지로그·automerge",
37
+ "ui.summary.branches.prFlowManual": " 🌿 개발 {develop} → 릴리스 {main} (pr-flow) — 릴리스 PR에서 버전확정·체인지로그까지 진행하고, 머지는 직접 합니다 (automerge 꺼짐)",
37
38
  "ui.summary.branches.developMissing": " ⚠️ '{develop}' 브랜치가 아직 원격에 없습니다 — 만들기 전에는 개발 브랜치 워크플로우가 동작하지 않습니다",
38
39
  "ui.summary.branches.developHint": " → 설치 파일을 커밋해 {main}을 push한 뒤: git push origin {main}:{develop}",
39
40
  "ui.summary.engine.title": "릴리스 노트 요약 엔진:",
@@ -77,6 +78,8 @@ export default {
77
78
  "ui.summary.todo.commitExample": " 예) git checkout {develop} && git merge {main} && git push",
78
79
  "ui.summary.todo.changelogLink": " → README의 '전체 버전 기록 보기'(CHANGELOG.md) 링크는 첫 릴리스에서 CHANGELOG가 생성된 뒤부터 열립니다",
79
80
  "ui.summary.todo.pat": "릴리스 automerge용 PAT (선택 — 없으면 GITHUB_TOKEN 폴백으로 자동 진행)",
81
+ "ui.summary.todo.manualMerge": "릴리스 PR은 직접 머지하세요 (release_automerge 꺼짐 — 이를 위한 PAT는 필요 없습니다)",
82
+ "ui.summary.todo.manualMergeHow": " → 릴리스 PR의 워크플로우 실행이 끝나면 머지 커밋(Create a merge commit)으로 머지하는 것을 권장합니다",
80
83
  "ui.summary.todo.patAccount": " → 등록 시 개인 계정이 아닌 조직 bot/machine 계정으로 발급하세요",
81
84
  "ui.summary.todo.patOptional": " → 없어도 태그·Release 발행과 배포 워크플로우 실행까지 자동으로 이어지며, 있으면 조금 더 빠릅니다",
82
85
  "ui.summary.todo.permissions": "GitHub Actions 권한 (변경 불필요)",
package/src/index.js CHANGED
@@ -8,13 +8,13 @@ import { readFileSync, existsSync } from "node:fs";
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import { parseArgs, parsePathsCsv, CliError, TYPE_CLI_FLAGS } from "./cli/args.js";
10
10
  import { helpText } from "./cli/help.js";
11
- import { resolveLanguage, setLanguage, t, DEFAULT_LANGUAGE, LANG_ENV_VAR, normalizeLanguage } from "./i18n/index.js";
11
+ import { resolveLanguage, setLanguage, t, DEFAULT_LANGUAGE, LANG_ENV_VAR, SUPPORTED_LANGUAGES, normalizeLanguage } from "./i18n/index.js";
12
12
  import { fallbackStyleTypes } from "./core/deploy-style.js";
13
13
  import { hooksFor, mergeHookResults } from "./core/types.js";
14
14
  import { PATHS } from "./core/paths.js";
15
15
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "./core/assets.js";
16
16
  import { detectTypes, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "./core/detect-fs.js";
17
- import { parseExisting, droppedPathLines } from "./core/version-yml.js";
17
+ import { parseExisting, droppedPathLines, invalidOptionLines } from "./core/version-yml.js";
18
18
  import { resolveReleaseOptions } from "./core/release-options.js";
19
19
  import { explicitFromContext, optionContextFields } from "./core/options.js";
20
20
  import { runBreakingCheck } from "./core/breaking-check.js";
@@ -100,16 +100,25 @@ async function runInner(argv, {
100
100
  // Language: --lang -> env var -> saved version.yml value -> en. Resolved after --help/--version
101
101
  // so an invalid env var cannot block the help output.
102
102
  let language;
103
+ // Language written to version.yml: the environment variable only decides this run's output, so it must not
104
+ // overwrite a saved choice. Only --lang (or a first install / an unusable saved value) sets the stored language.
105
+ let storedLanguage;
103
106
  try {
104
107
  const savedVy = join(cwd, "version.yml");
105
- const saved = existsSync(savedVy) ? parseExisting(readFileSync(savedVy, "utf8")).language : null;
108
+ const savedParsed = existsSync(savedVy) ? parseExisting(readFileSync(savedVy, "utf8")) : null;
109
+ const saved = savedParsed?.language ?? null;
106
110
  language = resolveLanguage({ flag: opts.lang, env: process.env[LANG_ENV_VAR], saved });
111
+ storedLanguage = normalizeLanguage(opts.lang) ? language : (saved ?? language);
107
112
  // An existing install without a saved language now falls back to English: say so once, since
108
- // its workflow messages switch from Korean on the next update.
113
+ // its workflow messages switch from Korean on the next update. A hand-edited unknown value also
114
+ // falls back to English, but it was never the old default, so it gets its own wording.
109
115
  const existingWithoutLanguage = existsSync(savedVy) && !saved;
110
116
  const unspecified = !normalizeLanguage(opts.lang) && !normalizeLanguage(process.env[LANG_ENV_VAR]);
111
117
  if (existingWithoutLanguage && unspecified && ["full", "interactive"].includes(opts.mode)) {
112
- console.error(t("cli.lang.defaultNotice", {}, DEFAULT_LANGUAGE));
118
+ const unknown = savedParsed?.languageUnsupported;
119
+ console.error(unknown
120
+ ? t("cli.lang.unsupportedSavedNotice", { value: unknown, supported: SUPPORTED_LANGUAGES.join(", ") }, DEFAULT_LANGUAGE)
121
+ : t("cli.lang.defaultNotice", {}, DEFAULT_LANGUAGE));
113
122
  }
114
123
  } catch (e) {
115
124
  if (e instanceof CliError) { console.error(e.message); return 1; }
@@ -150,7 +159,7 @@ async function runInner(argv, {
150
159
  // The log file is created on the first write, so viewing only status/doctor from the menu leaves nothing behind.
151
160
  startLog("install");
152
161
  return await runInteractive(
153
- { ...optionContextFields(opts), language },
162
+ { ...optionContextFields(opts), language: storedLanguage },
154
163
  { cwd, payloadRoot: payload, clock },
155
164
  );
156
165
  }
@@ -319,6 +328,8 @@ async function runInner(argv, {
319
328
 
320
329
  // Warn against the final folders (an explicit --paths may keep the other one). The dry-run preview prints its own notice.
321
330
  if (!opts.dryRun) for (const line of droppedPathLines(existing?.droppedPaths, paths)) console.error(`⚠️ ${line}`);
331
+ // An option value that could not be read is rewritten as false by this run - say so instead of doing it silently.
332
+ for (const line of invalidOptionLines(existing?.invalidOptions)) console.error(`⚠️ ${line}`);
322
333
 
323
334
  // version: existing version.yml first (SSoT; prevents overwriting on re-run) -> CLI value -> file detection.
324
335
  // Non-interactive, so the fallback notice uses the CLI wording (--project-version) as is.
@@ -374,7 +385,7 @@ async function runInner(argv, {
374
385
  repoName,
375
386
  // resolvers that compute @wizard ask/auto token values
376
387
  resolvers: makeResolvers(cwd, repoName, paths, typeOptions),
377
- now, today, language,
388
+ now, today, language: storedLanguage,
378
389
  // Extra context for the install log; does not change the install itself.
379
390
  markers: detectMarkers(cwd, types), detectWarnings,
380
391
  });
@@ -406,6 +417,7 @@ async function runInner(argv, {
406
417
  // Completion summary (also printed in CLI mode)
407
418
  printSummary({
408
419
  mode: opts.mode, types, version, versionCode, branches, developMissing,
420
+ releaseAutomerge: context.includeReleaseAutomerge !== false,
409
421
  copiedFiles: result?.workflows?.copiedFiles ?? [],
410
422
  autoUpdated: result?.workflows?.autoUpdated ?? [],
411
423
  gitignoreUpdated: result?.gitignoreUpdated === true,
package/src/ui/summary.js CHANGED
@@ -13,6 +13,8 @@ export function printSummary(ctx) {
13
13
  const { mode, types = [], version = "", versionCode = null, copiedFiles = [], autoUpdated = [], branches = null, gitignoreUpdated = false,
14
14
  // pr-flow but the develop branch could not be created on the remote - the layout line alone looks ready, so warn separately.
15
15
  developMissing = false,
16
+ // false when version.yml turns the release PR automerge off: the summary must not describe automerge or its PAT then.
17
+ releaseAutomerge = true,
16
18
  // Post-install verification and record
17
19
  answers = [], unresolved = [], secrets = new Map(), optionalSecrets = new Map(), logPath = "", legacyMdLogs = false, cleanup = null,
18
20
  // Flutter store deployment - app files created/kept and cleanup of deselected stores
@@ -49,7 +51,7 @@ export function printSummary(ctx) {
49
51
  if (branches.mode === "trunk-based") {
50
52
  err(t("ui.summary.branches.trunk", { main: branches.main }));
51
53
  } else {
52
- err(t("ui.summary.branches.prFlow", { develop: branches.develop, main: branches.main }));
54
+ err(t(releaseAutomerge ? "ui.summary.branches.prFlow" : "ui.summary.branches.prFlowManual", { develop: branches.develop, main: branches.main }));
53
55
  if (developMissing) {
54
56
  err(t("ui.summary.branches.developMissing", { develop: branches.develop }));
55
57
  err(t("ui.summary.branches.developHint", { main: branches.main, develop: branches.develop }));
@@ -208,11 +210,18 @@ export function printSummary(ctx) {
208
210
  err("");
209
211
  }
210
212
 
211
- err(` ${num()} ${t("ui.summary.todo.pat")}`);
212
- err(t("ui.summary.todo.patPath"));
213
- err(t("ui.summary.todo.patName"));
214
- err(t("ui.summary.todo.patAccount"));
215
- err(t("ui.summary.todo.patOptional"));
213
+ // With automerge off (pr-flow) nothing merges the release PR on the workflow's behalf, so the PAT guidance does not apply.
214
+ // In trunk-based mode there is no release PR, and the PAT block is kept as before.
215
+ if (releaseAutomerge || !branches || branches.mode === "trunk-based") {
216
+ err(` ${num()} ${t("ui.summary.todo.pat")}`);
217
+ err(t("ui.summary.todo.patPath"));
218
+ err(t("ui.summary.todo.patName"));
219
+ err(t("ui.summary.todo.patAccount"));
220
+ err(t("ui.summary.todo.patOptional"));
221
+ } else {
222
+ err(` ${num()} ${t("ui.summary.todo.manualMerge")}`);
223
+ err(t("ui.summary.todo.manualMergeHow"));
224
+ }
216
225
  err("");
217
226
  // Installed workflows declare the permissions they need themselves - report it by the same rule as the doctor guidance.
218
227
  err(` ${num()} ${t("ui.summary.todo.permissions")}`);