project-auto-wizard 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ Everything it installs is plain GitHub Actions in your own repository. No API ke
21
21
  </div>
22
22
 
23
23
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
24
- ## Latest Version : v0.15.1 (2026-10-01)
24
+ ## Latest Version : v0.16.1 (2026-10-01)
25
25
 
26
26
  [Full release history](CHANGELOG.md)
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "One command DevOps: npx wizard that installs GitHub-native Release Automation (optional GitHub Copilot AI summaries) into any project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1313,6 +1313,11 @@ EN = {
1313
1313
  "wf_changelog.pr_already_merged": "PR #{pr_number} is already merged",
1314
1314
  "wf_changelog.automerge_enabled": "automerge enabled: {subject}",
1315
1315
  "wf_changelog.automerge_unavailable": "automerge unavailable — merged directly: {subject}",
1316
+ "wf_changelog.automerge_off": "release_automerge is off — PR renamed to \"{subject}\"; merge it yourself",
1317
+ "wf_changelog.automerge_off_list_failed": "Could not read the comments of PR #{pr_number}; skipped the merge guidance comment to avoid a duplicate",
1318
+ "wf_changelog.automerge_off_rename_hint": "The PR title could not be updated automatically. Before merging, set the merge commit message (or the PR title) to `{subject}` so the release is published.",
1319
+ "wf_changelog.automerge_rename_failed": "Could not update PR #{pr_number} (title or guidance comment); the release still needs \"chore(release):\" in the merge commit message",
1320
+ "wf_changelog.automerge_off_comment": "**Automatic merge is off** (`release_automerge: false` in `version.yml`). Wait until this workflow run has finished (it confirms the version and renames the PR), then merge this PR yourself when you are ready to release, using **Create a merge commit**. Do not use *Squash and merge* or *Rebase and merge*: the release gate reads `chore(release):` from the resulting commit message, and squashing or rebasing also rewrites the history that the next release notes are built from.",
1316
1321
  "wf_changelog.merge_retry": "merge attempt {attempt}/{max_attempts} failed — retrying in {wait}s",
1317
1322
  "wf_changelog.merge_gave_up": "PR merge failed after {max_attempts} attempts (allow_merge_commit={allow_merge_commit}) — check branch protection rules, required checks, and token permissions",
1318
1323
  # --- wf_readme ---
@@ -2583,6 +2588,11 @@ KO = {
2583
2588
  "wf_changelog.pr_already_merged": "PR #{pr_number}은(는) 이미 병합되었습니다",
2584
2589
  "wf_changelog.automerge_enabled": "automerge 활성화: {subject}",
2585
2590
  "wf_changelog.automerge_unavailable": "automerge를 쓸 수 없어 직접 병합했습니다: {subject}",
2591
+ "wf_changelog.automerge_off": "release_automerge가 꺼져 있어 PR 제목을 \"{subject}\"로 바꿨습니다. 직접 머지하세요",
2592
+ "wf_changelog.automerge_off_list_failed": "PR #{pr_number}의 댓글을 읽지 못해 중복을 피하려고 머지 안내 댓글을 건너뛰었습니다",
2593
+ "wf_changelog.automerge_off_rename_hint": "PR 제목을 자동으로 바꾸지 못했습니다. 릴리스가 발행되도록 머지하기 전에 머지 커밋 메시지(또는 PR 제목)를 `{subject}`로 지정하세요.",
2594
+ "wf_changelog.automerge_rename_failed": "PR #{pr_number}의 제목 또는 안내 댓글을 갱신하지 못했습니다. 릴리스가 되려면 머지 커밋 메시지에 \"chore(release):\"가 있어야 합니다",
2595
+ "wf_changelog.automerge_off_comment": "**자동 머지가 꺼져 있습니다** (`version.yml`의 `release_automerge: false`). 이 워크플로우 실행이 끝난 뒤(버전 확정과 PR 제목 변경이 끝난 뒤) 릴리스할 준비가 되면 **Create a merge commit**으로 직접 머지하세요. *Squash and merge*와 *Rebase and merge*는 쓰지 마세요. 릴리스 게이트가 결과 커밋 메시지의 `chore(release):`를 읽고, squash나 rebase는 다음 릴리스 노트가 기대는 커밋 이력까지 바꿉니다.",
2586
2596
  "wf_changelog.merge_retry": "병합 시도 {attempt}/{max_attempts} 실패 — {wait}초 후 다시 시도합니다",
2587
2597
  "wf_changelog.merge_gave_up": "PR 병합이 {max_attempts}회 시도 후에도 실패했습니다 (allow_merge_commit={allow_merge_commit}) — 브랜치 보호 규칙, 필수 체크, 토큰 권한을 확인하세요",
2588
2598
  # --- wf_readme ---
@@ -9,6 +9,9 @@
9
9
  # - copilot_ai: opt-in (default false) — true lets the release/PR summary
10
10
  # workflows call GitHub Copilot CLI, which consumes GitHub Copilot AI Credits.
11
11
  # Falls back to rule-based summaries whenever Copilot is off or unavailable.
12
+ # - release_automerge: default true — false stops the release PR (develop -> main)
13
+ # from being merged automatically. Merge it yourself with a merge commit
14
+ # (not squash or rebase); the workflow renames the PR so the release still publishes.
12
15
  # ===
13
16
  # Supported project types: spring, flutter, react, react-native,
14
17
  # react-native-expo, node, python, basic, go
@@ -53,6 +56,7 @@ metadata:
53
56
  options:
54
57
  semver_auto: {{OPT_SEMVER_AUTO}}
55
58
  copilot_ai: {{OPT_COPILOT_AI}}
59
+ release_automerge: {{OPT_RELEASE_AUTOMERGE}}
56
60
  deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik | none (server deploy workflows)
57
61
  {{TYPE_OPTIONS}}
58
62
  {{DEPLOY}}
@@ -29,6 +29,10 @@
29
29
  # direct merge), retrying with backoff while GitHub catches up with the
30
30
  # freshly pushed head; an already-merged PR counts as success
31
31
  #
32
+ # With version.yml release_automerge: false, step 5 and the wait job are
33
+ # skipped: the PR is renamed to the release-confirm subject and one guidance
34
+ # comment asks the maintainer to merge it with a merge commit (not squash or rebase).
35
+ #
32
36
  # The merge-commit subject carries the release-confirm pattern
33
37
  # "chore(release):" — keep in sync with PROJECT-COMMON-VERSION-CONTROL
34
38
  # and PROJECT-COMMON-RELEASE-PUBLISH.
@@ -127,6 +131,9 @@ jobs:
127
131
  needs: precheck
128
132
  if: needs.precheck.outputs.open == 'true'
129
133
  runs-on: ubuntu-latest
134
+ outputs:
135
+ # Read by wait-for-merge-and-trigger-release: with automerge off there is no merge for it to wait for
136
+ automerge: ${{ steps.automerge_option.outputs.release_automerge }}
130
137
  steps:
131
138
  - name: Checkout PR head
132
139
  uses: actions/checkout@v7
@@ -160,6 +167,15 @@ jobs:
160
167
  echo "copilot_ai=$COPILOT_AI" >> $GITHUB_OUTPUT
161
168
  echo "$(m wf_common.copilot_ai_option copilot_ai="$COPILOT_AI")"
162
169
 
170
+ - name: Read release_automerge option from version.yml
171
+ id: automerge_option
172
+ run: |
173
+ # Same metadata: -> template: -> options: scope as copilot_ai. A missing key means ON:
174
+ # installs from before the option existed already automerge, and that must not change.
175
+ AUTOMERGE=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?^\s+release_automerge:\s*\"?(true|false)", t, re.S|re.M); print(m.group(1) if m else "true")' 2>/dev/null || echo "true")
176
+ echo "release_automerge=$AUTOMERGE" >> $GITHUB_OUTPUT
177
+ echo "release_automerge option: $AUTOMERGE"
178
+
163
179
  - name: Install Copilot CLI
164
180
  if: steps.copilot_options.outputs.copilot_ai == 'true'
165
181
  continue-on-error: true
@@ -431,7 +447,49 @@ jobs:
431
447
  fi
432
448
  fi
433
449
 
450
+ - name: Prepare manual release merge (release_automerge=false)
451
+ if: steps.automerge_option.outputs.release_automerge == 'false'
452
+ env:
453
+ GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
454
+ run: |
455
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
456
+ m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
457
+ PR_NUMBER=${{ github.event.pull_request.number }}
458
+ VERSION="${{ steps.bump.outputs.new_version }}"
459
+
460
+ # The PR title carries the release-confirm pattern, so a human merge commit (its message includes the PR title)
461
+ # still passes the RELEASE-PUBLISH gate. A failed rename only warns: the run must not fail here.
462
+ SUBJECT="chore(release): v${VERSION} (PR #${PR_NUMBER})"
463
+ # The REST endpoint is used instead of `gh pr edit`, which goes through GraphQL and can fail on token scopes.
464
+ RENAMED=true
465
+ if gh api -X PATCH "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" -f title="$SUBJECT" > /dev/null; then
466
+ echo "$(m wf_changelog.automerge_off subject="$SUBJECT")"
467
+ else
468
+ RENAMED=false
469
+ echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
470
+ fi
471
+
472
+ # One guidance comment per PR: re-runs (synchronize) find it by its marker and leave it alone.
473
+ # If the comment list cannot be read we cannot tell whether it exists, so skip rather than post a duplicate.
474
+ MARKER='<!-- release-automerge-off -->'
475
+ if ! COMMENT_IDS=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
476
+ --jq ".[] | select(.body | contains(\"$MARKER\")) | .id"); then
477
+ echo "::warning::$(m wf_changelog.automerge_off_list_failed pr_number="$PR_NUMBER")"
478
+ elif [ -z "$COMMENT_IDS" ]; then
479
+ BODY="$MARKER
480
+ $(m wf_changelog.automerge_off_comment)"
481
+ # A failed rename means the merge message will not carry the release marker unless the maintainer sets it.
482
+ if [ "$RENAMED" = false ]; then
483
+ BODY="$BODY
484
+
485
+ $(m wf_changelog.automerge_off_rename_hint subject="$SUBJECT")"
486
+ fi
487
+ gh pr comment "$PR_NUMBER" --body "$BODY" \
488
+ || echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
489
+ fi
490
+
434
491
  - name: Enable automerge
492
+ if: steps.automerge_option.outputs.release_automerge != 'false'
435
493
  env:
436
494
  # With a PAT the merge push triggers downstream workflows
437
495
  # (RELEASE-PUBLISH etc.); GITHUB_TOKEN merges do not.
@@ -503,6 +561,8 @@ jobs:
503
561
  wait-for-merge-and-trigger-release:
504
562
  name: Wait for merge and trigger RELEASE-PUBLISH (works without WORKFLOW_PAT)
505
563
  needs: changelog-and-merge
564
+ # Nothing merges by itself when release_automerge is false, so there is nothing to wait for.
565
+ if: needs.changelog-and-merge.outputs.automerge != 'false'
506
566
  runs-on: ubuntu-latest
507
567
  timeout-minutes: 35
508
568
  permissions:
package/src/cli/args.js CHANGED
@@ -4,6 +4,7 @@ import { DEPLOY_STYLES, isDeployStyle, NO_DEPLOY_STYLE } from "../core/deploy-st
4
4
  import { isValidBranchName } from "../core/branches.js";
5
5
  import { TYPES, canonicalTypeId } from "../core/types.js";
6
6
  import { CliError } from "../core/errors.js";
7
+ import { OPTIONS, defaultContextFields } from "../core/options.js";
7
8
  import { normalizePath, isRepoRelativePath } from "../core/paths.js";
8
9
  import { t, DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, isSupportedLanguage, normalizeLanguage } from "../i18n/index.js";
9
10
 
@@ -11,6 +12,11 @@ import { t, DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, isSupportedLanguage, normaliz
11
12
  export { CliError, normalizePath, isRepoRelativePath };
12
13
 
13
14
  // Type-specific CLI flags (type hook cliFlags): flag name -> { field, initial, parse }. Hooks are collected in type-table order.
15
+ // --<flag> / --no-<flag> for every registry option - a new option needs no change here.
16
+ const OPTION_CLI_FLAGS = new Map(OPTIONS.flatMap((option) => [
17
+ [`--${option.flag}`, { option, value: true }],
18
+ [`--no-${option.flag}`, { option, value: false }],
19
+ ]));
14
20
  export const TYPE_CLI_FLAGS = TYPES.flatMap((t) => t.hooks?.cliFlags ?? []);
15
21
  const TYPE_CLI_FLAG_BY_NAME = new Map(TYPE_CLI_FLAGS.map((f) => [f.flag, f]));
16
22
 
@@ -22,7 +28,7 @@ const VALUE_FLAGS = new Set([
22
28
  const SWITCH_FLAGS = new Set([
23
29
  "--version", "--help", "--force", "--dry-run", "--purge-readme", "--purge-gitignore", "--purge-version", "--yes", "--allow-dirty",
24
30
  "--delete-develop-branch", "--keep-version-yml", "--keep-readme", "--keep-changelog", "--keep-workflows", "--keep-scripts",
25
- "--semver-auto", "--no-semver-auto", "--copilot", "--no-copilot",
31
+ ...OPTIONS.flatMap((o) => [`--${o.flag}`, `--no-${o.flag}`]),
26
32
  ]);
27
33
 
28
34
  // `--name=value` -> `--name`, `value`, so the parser below only sees the space-separated form.
@@ -54,8 +60,7 @@ export function parseArgs(argv) {
54
60
  version: "", // initial version of the target project (--project-version)
55
61
  types: [],
56
62
  primaryType: "",
57
- includeSemverAuto: null, // --semver-auto / --no-semver-auto (default true; resolved downstream when unset)
58
- includeCopilotAi: null, // --copilot / --no-copilot (default false; opt-in because it consumes AI Credits)
63
+ ...defaultContextFields(), // --<flag> / --no-<flag> per registry option (null = unset, resolved downstream)
59
64
  pathsCsv: "", // raw "flutter=app,react=client" (normalized in the resolve step)
60
65
  mainBranch: "", // release branch (--main-branch). empty = detected default branch
61
66
  developBranch: "", // development branch (--develop-branch). empty = develop
@@ -86,6 +91,13 @@ export function parseArgs(argv) {
86
91
  const a = args.shift();
87
92
  const typeFlag = TYPE_CLI_FLAG_BY_NAME.get(a);
88
93
  if (typeFlag) { result[typeFlag.field] = typeFlag.parse(args.shift()); continue; }
94
+ const optionFlag = OPTION_CLI_FLAGS.get(a);
95
+ if (optionFlag) {
96
+ const { option, value } = optionFlag;
97
+ // --<flag> and --no-<flag> together are contradictory, so reject instead of letting the last one win
98
+ if (seenFlags.has(`--${value ? "no-" : ""}${option.flag}`)) throw new CliError(t(option.conflictKey));
99
+ seenFlags.add(a); result[option.ctxField] = value; continue;
100
+ }
89
101
  switch (a) {
90
102
  case "-m": case "--mode":
91
103
  result.mode = args.shift() ?? ""; break;
@@ -156,18 +168,6 @@ export function parseArgs(argv) {
156
168
  }
157
169
  result.deployStyle = v; break;
158
170
  }
159
- case "--semver-auto":
160
- if (seenFlags.has("--no-semver-auto")) throw new CliError(t("cli.args.semverConflict"));
161
- seenFlags.add("--semver-auto"); result.includeSemverAuto = true; break;
162
- case "--no-semver-auto":
163
- if (seenFlags.has("--semver-auto")) throw new CliError(t("cli.args.semverConflict"));
164
- seenFlags.add("--no-semver-auto"); result.includeSemverAuto = false; break;
165
- case "--copilot":
166
- if (seenFlags.has("--no-copilot")) throw new CliError(t("cli.args.copilotConflict"));
167
- seenFlags.add("--copilot"); result.includeCopilotAi = true; break;
168
- case "--no-copilot":
169
- if (seenFlags.has("--copilot")) throw new CliError(t("cli.args.copilotConflict"));
170
- seenFlags.add("--no-copilot"); result.includeCopilotAi = false; break;
171
171
  case "--paths": {
172
172
  // Silently falling back to auto-detection would install to paths other than the ones the user thinks they set.
173
173
  const v = (args.shift() ?? "").trim();
@@ -172,7 +172,9 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
172
172
  }
173
173
 
174
174
  // Show the actual setting: telling a user who turned it on that it is off would misrepresent the current state.
175
- const copilotAi = installed ? parseExisting(readFileSync(join(cwd, "version.yml"), "utf8")).options.copilotAi : null;
175
+ // version.yml is read once; both option notes below use it.
176
+ const savedOptions = installed ? parseExisting(readFileSync(join(cwd, "version.yml"), "utf8")).options : null;
177
+ const copilotAi = savedOptions ? savedOptions.copilotAi : null;
176
178
  const copilotState = copilotAi === true
177
179
  ? t("cmd.doctor.copilot.on")
178
180
  : copilotAi === false
@@ -187,6 +189,17 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
187
189
  ],
188
190
  });
189
191
 
192
+ // release_automerge is on unless version.yml says false (a missing key also means on), so only an explicit false is reported as off.
193
+ // Without an install there is no saved setting to report, so the item is left out.
194
+ if (savedOptions) {
195
+ add({
196
+ name: t("cmd.doctor.automerge.name"), label: t("cmd.doctor.automerge.name"), purpose: t("cmd.doctor.automerge.purpose"), status: "INFO",
197
+ note: savedOptions.releaseAutomerge === false
198
+ ? [t("cmd.doctor.automerge.off"), t("cmd.doctor.automerge.offHow")]
199
+ : [t("cmd.doctor.automerge.on"), t("cmd.doctor.automerge.onHow")],
200
+ });
201
+ }
202
+
190
203
  return results;
191
204
  }
192
205
 
@@ -18,6 +18,7 @@ import { readBaseline, writeBaseline, appFileHash } from "../core/baseline.js";
18
18
  import { scanUnsubstituted, classifySecrets, narrowSecretsBySshAuth } from "../core/verify.js";
19
19
  import { cleanupOtherDeployWorkflows, payloadWorkflowNames, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
20
20
  import { hooksFor } from "../core/types.js";
21
+ import { OPTIONS } from "../core/options.js";
21
22
  import { findStaleWorkflows, cleanupStaleWorkflows } from "../core/removal-plan.js";
22
23
  import { log, maskValue } from "../core/logger.js";
23
24
  import { t } from "../i18n/index.js";
@@ -207,8 +208,9 @@ export function cleanupWorkflows(context, payloadRoot, targetRoot, baseline, { j
207
208
  // Values picked interactively converge here too, so "why was it installed like this?" can later be traced from the log alone.
208
209
  function logChoices(context, types) {
209
210
  if (context.deployStyle) log.info("option", "deploy", context.deployStyle);
210
- if (context.includeSemverAuto != null) log.info("option", "semver", context.includeSemverAuto ? "on" : "off");
211
- if (context.includeCopilotAi != null) log.info("option", "copilot", context.includeCopilotAi ? "on" : "off");
211
+ for (const o of OPTIONS) {
212
+ if (context[o.ctxField] != null) log.info("option", o.logLabel, context[o.ctxField] ? "on" : "off");
213
+ }
212
214
  for (const { hook } of hooksFor(types, "logChoices")) {
213
215
  for (const [name, value] of hook(context)) log.info("option", name, value);
214
216
  }
@@ -8,6 +8,7 @@ import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/
8
8
  import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "../core/detect-fs.js";
9
9
  import { parseExisting, droppedPathLines } from "../core/version-yml.js";
10
10
  import { pickReleaseOptions, resolveReleaseOptions } from "../core/release-options.js";
11
+ import { OPTIONS, askableOptions, explicitFromContext } from "../core/options.js";
11
12
  import { runBreakingCheck } from "../core/breaking-check.js";
12
13
  import { resolveProjectPaths } from "../core/paths-resolve.js";
13
14
  import {
@@ -32,9 +33,6 @@ import { t, getLanguage } from "../i18n/index.js";
32
33
 
33
34
  const CANCEL = prompts.CANCEL;
34
35
 
35
- // Resolved lazily so the text follows the language chosen at run time.
36
- const semverAutoQuestion = () => t("interactive.question.semverAuto");
37
- const copilotAiQuestion = () => t("interactive.question.copilotAi");
38
36
  const isCancel = (v) => v === CANCEL || typeof v === "symbol";
39
37
 
40
38
  // io defaults to the real prompts. Tests inject a stub io.
@@ -100,8 +98,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
100
98
  const repoName = detectRepoName(cwd);
101
99
  // Initial values of optional workflows: CLI flags (--copilot etc.) -> options saved in version.yml
102
100
  // Values set by a flag skip the question - same priority as non-interactive mode.
103
- let { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } = pickReleaseOptions(
104
- { semverAuto: baseCtx?.includeSemverAuto, copilotAi: baseCtx?.includeCopilotAi }, existing);
101
+ // optionState: option name -> boolean|null (null = undecided; settled by resolveReleaseOptions below). Driven by the option registry.
102
+ const optionState = pickReleaseOptions(explicitFromContext(baseCtx ?? {}), existing);
105
103
  // Server deploy style - not asked again when a saved value (version.yml) exists (same convention as semver_auto).
106
104
  let deployStyle = savedDeployStyle(existing);
107
105
  const showOptional = mode === "full";
@@ -157,23 +155,22 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
157
155
  // Flutter options - env mode -> store deploy targets -> per-platform deploy mode.
158
156
  await askFlutterOptions();
159
157
 
160
- // Auto semver bump question (default ON). Skipped when a saved value exists.
161
- // In workflows mode version.yml is not written and the answer is meaningless, so it is asked only in full.
162
- if (mode === "full" && includeSemverAuto === null) {
163
- const y2 = await io.askYesNo(semverAutoQuestion(), true);
164
- includeSemverAuto = y2 === true;
165
- }
166
-
167
- // Copilot AI summary - consumes AI Credits, so opt-in (default No). Skipped when a saved value exists.
168
- if (mode === "full" && includeCopilotAi === null) {
169
- const y3 = await io.askYesNo(copilotAiQuestion(), false);
170
- includeCopilotAi = y3 === true;
158
+ // Optional-workflow questions (auto semver bump default ON, Copilot AI summary default No - it consumes AI Credits).
159
+ // Each is skipped when a flag or a saved value exists. In workflows mode version.yml is not written and the answer
160
+ // is meaningless, so they are asked only in full. Options without `ask` in the registry are never asked.
161
+ for (const o of askableOptions()) {
162
+ if (mode === "full" && optionState[o.name] === null) {
163
+ const answer = await io.askYesNo(t(o.ask.questionKey), o.ask.initial);
164
+ optionState[o.name] = answer === true;
165
+ }
171
166
  }
172
167
  }
173
168
  // When a question was actually asked (the full-mode questions above) its answer is respected, and only options that were not asked
174
169
  // are filled with defaults - same function as the CLI path (index.js), hence the same rule.
175
- ({ includeSemverAuto, includeCopilotAi } = resolveReleaseOptions(
176
- { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi }, existing));
170
+ // Fill undecided options with defaults; keep the whole result (also the options that are never asked) so a saved
171
+ // value such as release_automerge: false survives an interactive reinstall.
172
+ const settledOptions = resolveReleaseOptions(optionState, existing);
173
+ for (const o of OPTIONS) optionState[o.name] = settledOptions[o.ctxField];
177
174
  const showOptionToggles = mode === "full";
178
175
 
179
176
  // Confirm/edit loop - ESC means 'stay' (only an explicit 'No' exits)
@@ -184,12 +181,12 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
184
181
  if (io.analysisCard) {
185
182
  io.analysisCard({
186
183
  mode, modeLabel: modeLabel(mode), types, version, branch, showOptional, paths, flutter, envModeDefault: flutterAsk.envModeDefault,
187
- options: showOptionToggles ? { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } : null,
184
+ options: showOptionToggles ? { ...optionState } : null,
188
185
  });
189
186
  } else {
190
187
  io.note?.(summarize({
191
188
  mode, types, version, branch, showOptional, flutter, envModeDefault: flutterAsk.envModeDefault,
192
- options: showOptionToggles ? { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } : null,
189
+ options: showOptionToggles ? { ...optionState } : null,
193
190
  }), t("interactive.summary.title"));
194
191
  }
195
192
  const choice = await io.confirmProjectMenu();
@@ -218,13 +215,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
218
215
  }
219
216
  } else if (what === "branch") {
220
217
  branch = await askBranchName(io, t("interactive.edit.branchPrompt"), branch, isCancel);
221
- } else if (what === "semverAuto") {
218
+ } else if (askableOptions().some((o) => o.name === what)) {
222
219
  // A saved value skips the first question, so this is the only place to change a value once decided.
223
- const y = await io.askYesNo(semverAutoQuestion(), includeSemverAuto);
224
- if (typeof y === "boolean") includeSemverAuto = y;
225
- } else if (what === "copilotAi") {
226
- const y = await io.askYesNo(copilotAiQuestion(), includeCopilotAi);
227
- if (typeof y === "boolean") includeCopilotAi = y;
220
+ const o = askableOptions().find((x) => x.name === what);
221
+ const y = await io.askYesNo(t(o.ask.questionKey), optionState[o.name]);
222
+ if (typeof y === "boolean") optionState[o.name] = y;
228
223
  } else if (FLUTTER_EDIT_ITEMS.has(what)) {
229
224
  flutter = await editFlutterOption(io, what, flutter, flutterAsk.envModeDefault);
230
225
  }
@@ -323,7 +318,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
323
318
  // Even if the saved value is zero-downtime, a single-server deploy is installed when the chosen types lack that style - record the style actually installed.
324
319
  deployStyle: resolveDeployStyle({ payload, types, explicit: deployStyle, existing }),
325
320
  typeOptions: flutterOptions,
326
- releaseOptions: { includeSemverAuto, includeCopilotAi },
321
+ releaseOptions: Object.fromEntries(OPTIONS.map((o) => [o.ctxField, optionState[o.name]])),
327
322
  mode, force: true, version, versionCode, branch, branches, paths,
328
323
  repoName, resolvers, envValues, envUseDefaults, now, today,
329
324
  language: baseCtx.language ?? existing?.language ?? getLanguage(),
@@ -449,8 +444,9 @@ function summarize({ mode, types, version, branch, showOptional, flutter, envMod
449
444
  }
450
445
  }
451
446
  if (options) {
452
- lines.push(t("interactive.summary.semverAuto", { value: options.semverAuto ? t("interactive.summary.on") : t("interactive.summary.off") }));
453
- lines.push(t("interactive.summary.copilotAi", { value: options.copilotAi ? t("interactive.summary.on") : t("interactive.summary.off") }));
447
+ for (const o of askableOptions()) {
448
+ lines.push(t(o.ask.summaryKey, { value: options[o.name] ? t("interactive.summary.on") : t("interactive.summary.off") }));
449
+ }
454
450
  }
455
451
  return lines.join("\n");
456
452
  }
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parseExisting, droppedPathLines } from "../core/version-yml.js";
5
+ import { OPTIONS } from "../core/options.js";
5
6
  import { planWorkflows } from "../core/copy/workflows.js";
6
7
  import { makeResolvers, detectRepoName, detectDefaultBranch } from "../core/detect-fs.js";
7
8
  import { PATHS } from "../core/paths.js";
@@ -76,12 +77,12 @@ export function printStatus(status) {
76
77
  if (status.branches) {
77
78
  lines.push(t("cmd.status.branchMode", { mode: status.branches.mode, main: status.branches.main, develop: status.branches.develop }));
78
79
  }
79
- const boolLabel = (v) => (v === null ? t("cmd.status.unsetDefaultFalse") : v);
80
- const semverAutoLabel = status.options.semverAuto === null ? t("cmd.status.unsetDefaultFalse") : status.options.semverAuto;
81
- const copilotAiLabel = boolLabel(status.options.copilotAi ?? null);
80
+ // An unset option is shown with the value it resolves to on this (existing) install, i.e. its legacyDefault.
81
+ 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(" ");
82
83
  const typeLabels = hooksFor(status.types, "statusLabels").map(({ hook }) => hook(status.options)).join("");
83
84
  const deployLabel = status.options.deployStyle ? ` deploy_style=${status.options.deployStyle}` : "";
84
- lines.push(t("cmd.status.options", { value: `semver_auto=${semverAutoLabel} copilot_ai=${copilotAiLabel}${deployLabel}${typeLabels}` }));
85
+ lines.push(t("cmd.status.options", { value: `${optionLabels}${deployLabel}${typeLabels}` }));
85
86
  if (status.modifiedFiles.length) {
86
87
  lines.push("", t("cmd.status.modified", { n: status.modifiedFiles.length }));
87
88
  for (const f of status.modifiedFiles) lines.push(` - ${f}`);
package/src/context.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Wizard-wide state made explicit as a single object
2
2
  import { TYPE_IDS, allHookValues } from "./core/types.js";
3
+ import { defaultContextFields } from "./core/options.js";
3
4
 
4
5
  // --type whitelist: the display order of the type registry (core/types.js).
5
6
  export const VALID_TYPES = TYPE_IDS;
@@ -24,8 +25,9 @@ export function createContext(overrides = {}) {
24
25
  branch: "",
25
26
  branches: null, // { main, develop, mode: "pr-flow"|"trunk-based" } - result of resolveBranchConfig
26
27
  paths: new Map(), // type -> path
27
- includeSemverAuto: null, // null = unset (resolved to true downstream), true/false = explicit
28
- includeCopilotAi: null, // null = unset (resolved to false downstream), true/false = explicit; Copilot AI summary opt-in
28
+ // Release option fields (includeSemverAuto, includeCopilotAi, ...): null = unset (resolved downstream), true/false = explicit.
29
+ // Fields and defaults come from the option registry (core/options.js).
30
+ ...defaultContextFields(),
29
31
  // Type-specific option fields; all ignored in projects without that type. Fields and defaults come from the type hook (contextDefaults).
30
32
  ...allHookValues("contextDefaults"),
31
33
  language: "en", // message language (--lang / env / version.yml), default en
@@ -0,0 +1,88 @@
1
+ // Option registry - one declaration per boolean release option; CLI flags, value resolution, version.yml parse/render
2
+ // and status/log lines are all derived from it (same idea as core/types.js for project types).
3
+ // Adding an option means adding one entry here; see ADDING-AN-OPTION.md.
4
+ //
5
+ // default : value on a new install when neither a flag nor a saved value exists
6
+ // legacyDefault : value on an existing install (version.yml present) whose file lacks this key
7
+ // semver_auto - false: an install from before the feature must not silently turn on and let one ambiguous commit bump major
8
+ // copilot_ai - false: opt-in because it consumes AI Credits
9
+ // release_automerge - true for both: automerge is what every existing install already does, so a missing key means ON
10
+ // conflictKey : i18n key of the error thrown when --<flag> and --no-<flag> are both given (catalog lives in src/i18n)
11
+ // logLabel : label used by the install log line ("option <logLabel> on|off")
12
+ // ask : present only for options the interactive wizard asks about / lists in the edit menu
13
+ // { questionKey, initial, menuLabelKey, summaryKey, cardIcon, cardLabelKey } (all i18n keys except initial/cardIcon)
14
+ // Keys only - this module never imports the i18n layer, so core stays free of UI dependencies.
15
+ export const OPTIONS = Object.freeze([
16
+ {
17
+ key: "semver_auto", name: "semverAuto", ctxField: "includeSemverAuto",
18
+ flag: "semver-auto", default: true, legacyDefault: false,
19
+ conflictKey: "cli.args.semverConflict", logLabel: "semver",
20
+ ask: {
21
+ questionKey: "interactive.question.semverAuto", initial: true,
22
+ menuLabelKey: "ui.prompts.edit.semverAuto", summaryKey: "interactive.summary.semverAuto",
23
+ cardIcon: "🔢", cardLabelKey: "ui.status-cards.card.autoBump",
24
+ },
25
+ },
26
+ {
27
+ key: "copilot_ai", name: "copilotAi", ctxField: "includeCopilotAi",
28
+ flag: "copilot", default: false, legacyDefault: false,
29
+ conflictKey: "cli.args.copilotConflict", logLabel: "copilot",
30
+ ask: {
31
+ questionKey: "interactive.question.copilotAi", initial: false,
32
+ menuLabelKey: "ui.prompts.edit.copilotAi", summaryKey: "interactive.summary.copilotAi",
33
+ cardIcon: "🤖", cardLabelKey: "ui.status-cards.card.copilot",
34
+ },
35
+ },
36
+ {
37
+ // No `ask`: the wizard never asks about it - the flag or version.yml is the control (default ON).
38
+ key: "release_automerge", name: "releaseAutomerge", ctxField: "includeReleaseAutomerge",
39
+ flag: "release-automerge", default: true, legacyDefault: true,
40
+ conflictKey: "cli.args.releaseAutomergeConflict", logLabel: "release-automerge",
41
+ },
42
+ ]);
43
+
44
+ export const optionByKey = (key) => OPTIONS.find((o) => o.key === key);
45
+
46
+ // version.yml template variable name, e.g. semver_auto -> OPT_SEMVER_AUTO
47
+ export const optionVar = (o) => `OPT_${o.key.toUpperCase()}`;
48
+
49
+ // Context fields (includeX) -> explicit-value map (name -> boolean|null)
50
+ export function explicitFromContext(ctx = {}) {
51
+ return Object.fromEntries(OPTIONS.map((o) => [o.name, ctx[o.ctxField] ?? null]));
52
+ }
53
+
54
+ // The option fields of a context/opts object only (what the interactive wizard receives as its starting values)
55
+ export function optionContextFields(ctx = {}) {
56
+ return Object.fromEntries(OPTIONS.map((o) => [o.ctxField, ctx[o.ctxField]]));
57
+ }
58
+
59
+ // Context defaults: null = not decided yet
60
+ export function defaultContextFields() {
61
+ return Object.fromEntries(OPTIONS.map((o) => [o.ctxField, null]));
62
+ }
63
+
64
+ // Only explicit and saved values are applied - null means undecided (interactive mode uses it to decide whether to ask).
65
+ export function pickOptionValues(explicit = {}, existing = null) {
66
+ return Object.fromEntries(
67
+ OPTIONS.map((o) => [o.name, explicit[o.name] ?? existing?.options?.[o.name] ?? null]),
68
+ );
69
+ }
70
+
71
+ // Final values keyed by context field - undecided options get default (new install) or legacyDefault (existing install).
72
+ export function resolveOptionValues(explicit = {}, existing = null) {
73
+ const picked = pickOptionValues(explicit, existing);
74
+ return Object.fromEntries(
75
+ OPTIONS.map((o) => {
76
+ const v = picked[o.name];
77
+ return [o.ctxField, v === null ? (existing ? o.legacyDefault : o.default) : v === true];
78
+ }),
79
+ );
80
+ }
81
+
82
+ // Values for rendering version.yml: a context field that is unset (null/undefined) falls back to the option's default.
83
+ export function renderValues(ctx = {}) {
84
+ return Object.fromEntries(OPTIONS.map((o) => [o.ctxField, (ctx[o.ctxField] ?? o.default) === true]));
85
+ }
86
+
87
+ // Options the interactive wizard asks about (those declaring `ask`).
88
+ export const askableOptions = () => OPTIONS.filter((o) => o.ask);
@@ -1,23 +1,6 @@
1
- // Value-decision rules for the release options (semver_auto, copilot_ai), shared by non-interactive (index.js) and interactive (interactive.js) modes.
2
- // Priority: explicit value (CLI flag, prompt answer), then the value saved in version.yml, then the default.
3
- // Keeping separate copies per path would install the same repo with different settings depending on how it was run.
4
-
5
- // Reflects only explicit and saved values. null means not decided yet (interactive mode uses it to decide whether to ask).
6
- export function pickReleaseOptions(explicit = {}, existing = null) {
7
- return {
8
- semverAuto: explicit.semverAuto ?? existing?.options?.semverAuto ?? null,
9
- copilotAi: explicit.copilotAi ?? existing?.options?.copilotAi ?? null,
10
- };
11
- }
12
-
13
- // Final values: fills undecided options with defaults.
14
- // semver_auto: true only for fresh installs. If an existing install whose version.yml lacked the key (installed
15
- // before the feature existed) were switched on silently, one ambiguous commit could promote a major bump, so it stays false.
16
- // copilot_ai: an opt-in that consumes AI credits, so it is false for both new and existing installs unless stated.
17
- export function resolveReleaseOptions(explicit = {}, existing = null) {
18
- const { semverAuto, copilotAi } = pickReleaseOptions(explicit, existing);
19
- return {
20
- includeSemverAuto: semverAuto === null ? !existing : semverAuto !== false,
21
- includeCopilotAi: copilotAi === true,
22
- };
23
- }
1
+ // Value-decision rules for the release options (semver_auto, copilot_ai, ...), shared by non-interactive (index.js) and
2
+ // interactive (interactive.js) modes. The rules themselves live in the option registry (core/options.js); this file keeps
3
+ // the existing import path and names.
4
+ // Priority: explicit value (CLI flag / prompt answer) -> saved value in version.yml -> default. Keeping one implementation
5
+ // stops the same repo from being installed with different settings depending on how the wizard was run.
6
+ export { pickOptionValues as pickReleaseOptions, resolveOptionValues as resolveReleaseOptions } from "./options.js";
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_DEPLOY_STYLE } from "./deploy-style.js";
2
+ import { OPTIONS, optionVar, renderValues } from "./options.js";
2
3
  import { escapeYamlDoubleQuoted } from "./wizard-env.js";
3
4
  import { hooksFor, mergeHookResults, allHookValues, canonicalTypeId, canonicalTypeIds } from "./types.js";
4
5
  import { normalizePath } from "./paths.js";
@@ -41,15 +42,17 @@ export function parseExtraTopLevel(content) {
41
42
 
42
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.
43
44
  const TYPE_OPTION_KEYS = allHookValues("savedOptionKeys");
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*(.+)`);
44
47
  const TYPE_OPTION_LINE = new RegExp(`^\\s+(${Object.keys(TYPE_OPTION_KEYS).join("|")}):\\s*(.+)`);
45
48
 
46
49
  // State-machine parse of metadata.template.options.
47
- // Returns: { semverAuto: bool|null, copilotAi: bool|null, deployStyle: string|null,
50
+ // Returns: { <one bool|null per registry option, e.g. semverAuto/copilotAi>, deployStyle: string|null,
48
51
  // type-specific option fields (e.g. envMode/flutterStore/androidDeployMode/iosDeployMode): string|null } - null = not written.
49
52
  // Other keys such as the old synology/coderabbit ones hit no branch and are naturally ignored (no parse error).
50
53
  export function parseTemplateOptions(content) {
51
54
  const out = {
52
- semverAuto: null, copilotAi: null, deployStyle: null,
55
+ ...Object.fromEntries(OPTIONS.map((o) => [o.name, null])), deployStyle: null,
53
56
  ...Object.fromEntries(Object.values(TYPE_OPTION_KEYS).map((field) => [field, null])),
54
57
  };
55
58
  // Value normalization: strip quotes + trim
@@ -66,18 +69,12 @@ export function parseTemplateOptions(content) {
66
69
  if (m) { const v = strip(m[1]); if (v) out.deployStyle = v; continue; }
67
70
  m = line.match(TYPE_OPTION_LINE);
68
71
  if (m) { const v = strip(m[2]); if (v) out[TYPE_OPTION_KEYS[m[1]]] = v; continue; }
69
- m = line.match(/^\s+semver_auto:\s*(.+)/);
70
- if (m) {
71
- const v = strip(m[1]);
72
- if (v === "true") out.semverAuto = true;
73
- if (v === "false") out.semverAuto = false;
74
- continue;
75
- }
76
- m = line.match(/^\s+copilot_ai:\s*(.+)/);
77
- if (m) {
78
- const v = strip(m[1]);
79
- if (v === "true") out.copilotAi = true;
80
- if (v === "false") out.copilotAi = false;
72
+ // Boolean options come from the registry; a value other than true/false is left null (= not written).
73
+ const optionLine = line.match(OPTION_LINE);
74
+ 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;
81
78
  continue;
82
79
  }
83
80
  // another key indented 0-4 spaces -> end of the options section
@@ -256,8 +253,9 @@ export function buildVersionYml({
256
253
  const b = branches || { main: branch || "main", develop: "develop", mode: "pr-flow" };
257
254
  const {
258
255
  templateVersion = "unknown",
259
- includeSemverAuto = true, includeCopilotAi = false, deployStyle = "", optionsDate = today,
256
+ deployStyle = "", optionsDate = today,
260
257
  } = templateOptions || {};
258
+ const optionValues = renderValues(templateOptions || {});
261
259
 
262
260
  // project_paths block (full-line token {{PROJECT_PATHS}} - line removed when absent)
263
261
  let pathsBlock = "";
@@ -295,8 +293,7 @@ export function buildVersionYml({
295
293
  NOW: now, TODAY: today || optionsDate, DEFAULT_BRANCH: branch,
296
294
  TEMPLATE_VERSION: templateVersion,
297
295
  MAIN_BRANCH: b.main, DEVELOP_BRANCH: b.develop, BRANCH_MODE: b.mode,
298
- OPT_SEMVER_AUTO: String(includeSemverAuto),
299
- OPT_COPILOT_AI: String(includeCopilotAi),
296
+ ...Object.fromEntries(OPTIONS.map((o) => [optionVar(o), String(optionValues[o.ctxField])])),
300
297
  OPT_DEPLOY_STYLE: String(deployStyle || ""),
301
298
  };
302
299
 
@@ -335,15 +332,14 @@ export function sameIgnoringTimestamps(a, b) {
335
332
  export function renderVersionYml(context, templateText, { pathMarkers, deployValues = new Map(), extraTopLevel = [] }) {
336
333
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
337
334
  now, today, templateVersion = "unknown", branches = null, language,
338
- includeSemverAuto, includeCopilotAi, deployStyle } = context;
335
+ deployStyle } = context;
339
336
  return buildVersionYml({
340
337
  templateText, version, types, paths, pathMarkers, branch, branches, versionCode, now, today,
341
338
  deployValues, extraTopLevel, language,
342
339
  typeOptions: mergeHookResults(types, "optionsFromContext", context),
343
340
  templateOptions: {
344
341
  templateVersion,
345
- includeSemverAuto: includeSemverAuto !== false,
346
- includeCopilotAi: includeCopilotAi === true,
342
+ ...renderValues(context),
347
343
  // null = a type with no server deploy workflow, so the deploy style is meaningless (record omitted)
348
344
  deployStyle: deployStyle === null ? null : (deployStyle || DEFAULT_DEPLOY_STYLE),
349
345
  optionsDate: today,
@@ -5,7 +5,7 @@ export default {
5
5
  "cli.lang.missing": "--lang requires a value. Supported: {supported}",
6
6
  "cli.lang.help": "Message language: {supported} (default: en)",
7
7
  "cli.bin.nodeVersion": "Node.js 20.12 or later is required (current: {current})",
8
- "cli.help.text": "project-auto-wizard — One command DevOps: GitHub-native release automation setup wizard\n\nUsage:\n npx project-auto-wizard [options]\n\nOptions:\n -m, --mode MODE Unified mode (full | uninstall | status | doctor)\n Default: interactive. full = install and update\n uninstall = full removal (interactive checklist; with --force, opt in via --purge-*)\n status = check install state and drift (read-only). doctor = environment diagnosis (read-only)\n -t, --type CSV Project types as csv (e.g. spring,react,python)\n Supported: {types}\n --project-version V Initial version of the target (e.g. 1.0.0). Auto-detected when omitted\n --paths \"t=p,...\" Per-type project paths (monorepo). e.g. flutter=app,react=client\n --main-branch B Release branch (default: detected default branch)\n --develop-branch B Development branch (default: develop). Same as the release branch = trunk-based mode\n --deploy-style STYLE Server deploy style: simple | nginx | traefik | none (default: simple)\n --flutter-env-mode MODE Flutter env var mode: dart-define | dotenv (default: dart-define for new installs; existing installs keep the saved value / dotenv)\n --flutter-store CSV Flutter store deploy targets: android,ios | android | ios | none (default: install both)\n --android-deploy-mode MODE Play Store deploy mode: store_only | store_prepare | store_submit (default: store_only)\n --ios-deploy-mode MODE iOS deploy mode: store_only | store_prepare | store_submit (default: store_only)\n --semver-auto / --no-semver-auto Auto major/minor/patch bump from commit types (default: enabled)\n --copilot / --no-copilot Generate the AI summary with Copilot (default: off; consumes GitHub Copilot AI Credits)\n --lang LANG {langHelp}\n --force Required for full; non-interactive removal for uninstall (skips all confirmations, uses defaults)\n --dry-run Preview what would change without touching any file (supported by full/uninstall)\n --purge-readme With --mode uninstall --force, also remove the README.md version section\n --purge-gitignore With --mode uninstall --force, also remove the auto-added .gitignore entries\n --purge-version With --mode uninstall --force, also remove version.yml\n -v, --version Print the project-auto-wizard version\n -h, --help Show this help\n\nExamples:\n npx project-auto-wizard --mode full --force --type spring,react\n npx project-auto-wizard --mode full --force --type flutter --paths \"flutter=app\"\n npx project-auto-wizard --mode status\n npx project-auto-wizard --mode doctor\n npx project-auto-wizard --mode full --force --type node --dry-run\n npx project-auto-wizard --mode uninstall --force --purge-readme --purge-gitignore --purge-version\n",
8
+ "cli.help.text": "project-auto-wizard — One command DevOps: GitHub-native release automation setup wizard\n\nUsage:\n npx project-auto-wizard [options]\n\nOptions:\n -m, --mode MODE Unified mode (full | uninstall | status | doctor)\n Default: interactive. full = install and update\n uninstall = full removal (interactive checklist; with --force, opt in via --purge-*)\n status = check install state and drift (read-only). doctor = environment diagnosis (read-only)\n -t, --type CSV Project types as csv (e.g. spring,react,python)\n Supported: {types}\n --project-version V Initial version of the target (e.g. 1.0.0). Auto-detected when omitted\n --paths \"t=p,...\" Per-type project paths (monorepo). e.g. flutter=app,react=client\n --main-branch B Release branch (default: detected default branch)\n --develop-branch B Development branch (default: develop). Same as the release branch = trunk-based mode\n --deploy-style STYLE Server deploy style: simple | nginx | traefik | none (default: simple)\n --flutter-env-mode MODE Flutter env var mode: dart-define | dotenv (default: dart-define for new installs; existing installs keep the saved value / dotenv)\n --flutter-store CSV Flutter store deploy targets: android,ios | android | ios | none (default: install both)\n --android-deploy-mode MODE Play Store deploy mode: store_only | store_prepare | store_submit (default: store_only)\n --ios-deploy-mode MODE iOS deploy mode: store_only | store_prepare | store_submit (default: store_only)\n --semver-auto / --no-semver-auto Auto major/minor/patch bump from commit types (default: enabled)\n --copilot / --no-copilot Generate the AI summary with Copilot (default: off; consumes GitHub Copilot AI Credits)\n --release-automerge / --no-release-automerge Merge the release PR automatically (default: enabled; off = you merge it yourself)\n --lang LANG {langHelp}\n --force Required for full; non-interactive removal for uninstall (skips all confirmations, uses defaults)\n --dry-run Preview what would change without touching any file (supported by full/uninstall)\n --purge-readme With --mode uninstall --force, also remove the README.md version section\n --purge-gitignore With --mode uninstall --force, also remove the auto-added .gitignore entries\n --purge-version With --mode uninstall --force, also remove version.yml\n -v, --version Print the project-auto-wizard version\n -h, --help Show this help\n\nExamples:\n npx project-auto-wizard --mode full --force --type spring,react\n npx project-auto-wizard --mode full --force --type flutter --paths \"flutter=app\"\n npx project-auto-wizard --mode status\n npx project-auto-wizard --mode doctor\n npx project-auto-wizard --mode full --force --type node --dry-run\n npx project-auto-wizard --mode uninstall --force --purge-readme --purge-gitignore --purge-version\n",
9
9
  "cli.args.projectVersionInvalid": "Invalid --project-version value: '{raw}' (x.y.z format, e.g. 1.0.0)",
10
10
  "cli.args.typeUnsupported": "Unsupported type: '{type}'\nSupported types: {valid}",
11
11
  "cli.args.typeEmpty": "The --type argument is empty",
@@ -13,6 +13,7 @@ export default {
13
13
  "cli.args.deployStyleInvalid": "Invalid --deploy-style value: {value} ({choices})",
14
14
  "cli.args.semverConflict": "--semver-auto and --no-semver-auto cannot be specified together",
15
15
  "cli.args.copilotConflict": "--copilot and --no-copilot cannot be specified together",
16
+ "cli.args.releaseAutomergeConflict": "--release-automerge and --no-release-automerge cannot be specified together",
16
17
  "cli.args.pathsEmpty": "The --paths argument is empty (e.g. --paths flutter=app,react=client)",
17
18
  "cli.args.branchEmpty": "{flag} cannot be empty",
18
19
  "cli.args.branchInvalid": "{flag} value is not a valid branch name: '{value}'",
@@ -8,6 +8,7 @@ export default {
8
8
  "cmd.status.none": "(none)",
9
9
  "cmd.status.branchMode": "Branch mode : {mode} ({main} / {develop})",
10
10
  "cmd.status.unsetDefaultFalse": "unset (default false)",
11
+ "cmd.status.unsetDefaultTrue": "unset (default true)",
11
12
  "cmd.status.options": "Options : {value}",
12
13
  "cmd.status.modified": "Workflow files modified by the user ({n}):",
13
14
  "cmd.status.unmodified": "All workflow files match their install-time defaults (no modifications).",
@@ -78,6 +79,12 @@ export default {
78
79
  "cmd.doctor.copilot.purpose": "AI release note generation (optional)",
79
80
  "cmd.doctor.copilot.creditsOn": "GitHub Copilot AI Credits are consumed; organizations need the 'Allow use of Copilot CLI billed to the organization' policy.",
80
81
  "cmd.doctor.copilot.creditsOff": "Turning it on consumes GitHub Copilot AI Credits; organizations need the 'Allow use of Copilot CLI billed to the organization' policy.",
82
+ "cmd.doctor.automerge.name": "Release PR automerge",
83
+ "cmd.doctor.automerge.purpose": "Whether the release PR merges by itself",
84
+ "cmd.doctor.automerge.on": "It is on (release_automerge: true in version.yml, or the key is absent).",
85
+ "cmd.doctor.automerge.onHow": "To merge the release PR yourself, reinstall with --no-release-automerge or set release_automerge: false in version.yml.",
86
+ "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.",
81
88
  "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.",
82
89
  "cmd.doctor.heading": "Environment check",
83
90
  "cmd.doctor.details": "Details: ",
@@ -20,6 +20,7 @@ export default {
20
20
  "ui.status-cards.card.on": "on",
21
21
  "ui.status-cards.card.off": "off",
22
22
  "ui.status-cards.card.autoBump": "Auto-bump",
23
+ "ui.status-cards.card.copilot": "Copilot",
23
24
  "ui.status-cards.card.paths": "Paths",
24
25
  "ui.status-cards.kind.update": "{title} — template {from} → {to}",
25
26
  "ui.status-cards.kind.updateTitle": "Update",
@@ -5,7 +5,7 @@ export default {
5
5
  "cli.lang.missing": "--lang 값이 필요합니다. 지원 언어: {supported}",
6
6
  "cli.lang.help": "메시지 언어: {supported} (기본: en)",
7
7
  "cli.bin.nodeVersion": "Node.js 20.12 이상이 필요합니다 (현재: {current})",
8
- "cli.help.text": "project-auto-wizard — One command DevOps: GitHub-native 릴리스 자동화 설치 마법사\n\n사용법:\n npx project-auto-wizard [옵션]\n\n옵션:\n -m, --mode MODE 통합 모드 (full | uninstall | status | doctor)\n 기본: interactive (대화형). full = 설치 및 업데이트\n uninstall = 완전 삭제(대화형 체크리스트, --force 시 --purge-*로 opt-in)\n status = 설치 상태·드리프트 확인(읽기 전용). doctor = 환경 진단(읽기 전용)\n -t, --type CSV 프로젝트 타입 csv (예: spring,react,python)\n 지원: {types}\n --project-version V 통합 대상의 초기 버전 (예: 1.0.0). 미지정 시 자동 감지\n --paths \"t=p,...\" 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client\n --main-branch B 릴리스 브랜치 (기본: 감지된 default branch)\n --develop-branch B 개발 브랜치 (기본: develop). 릴리스 브랜치와 같으면 trunk-based 모드\n --deploy-style STYLE 서버 배포 방식: simple | nginx | traefik | none (기본: simple)\n --flutter-env-mode MODE Flutter 환경변수 방식: dart-define | dotenv (기본: 신규 설치 dart-define, 기존 설치는 저장값·dotenv 유지)\n --flutter-store CSV Flutter 스토어 배포 대상: android,ios | android | ios | none (기본: 둘 다 설치)\n --android-deploy-mode MODE Play Store 배포 모드: store_only | store_prepare | store_submit (기본: store_only)\n --ios-deploy-mode MODE iOS 배포 모드: store_only | store_prepare | store_submit (기본: store_only)\n --semver-auto / --no-semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함)\n --copilot / --no-copilot Copilot으로 AI 요약 생성 (기본: 사용 안 함, GitHub Copilot AI Credits 소비)\n --lang LANG {langHelp}\n --force full 실행에 필수, uninstall은 비대화형 삭제 (모든 확인 생략, 기본값 사용)\n --dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌 (full/uninstall 지원)\n --purge-readme --mode uninstall --force 시 README.md 버전 섹션도 제거\n --purge-gitignore --mode uninstall --force 시 .gitignore 자동 추가 항목도 제거\n --purge-version --mode uninstall --force 시 version.yml도 제거\n -v, --version project-auto-wizard 버전 출력\n -h, --help 이 도움말 표시\n\n예시:\n npx project-auto-wizard --mode full --force --type spring,react\n npx project-auto-wizard --mode full --force --type flutter --paths \"flutter=app\"\n npx project-auto-wizard --mode status\n npx project-auto-wizard --mode doctor\n npx project-auto-wizard --mode full --force --type node --dry-run\n npx project-auto-wizard --mode uninstall --force --purge-readme --purge-gitignore --purge-version\n",
8
+ "cli.help.text": "project-auto-wizard — One command DevOps: GitHub-native 릴리스 자동화 설치 마법사\n\n사용법:\n npx project-auto-wizard [옵션]\n\n옵션:\n -m, --mode MODE 통합 모드 (full | uninstall | status | doctor)\n 기본: interactive (대화형). full = 설치 및 업데이트\n uninstall = 완전 삭제(대화형 체크리스트, --force 시 --purge-*로 opt-in)\n status = 설치 상태·드리프트 확인(읽기 전용). doctor = 환경 진단(읽기 전용)\n -t, --type CSV 프로젝트 타입 csv (예: spring,react,python)\n 지원: {types}\n --project-version V 통합 대상의 초기 버전 (예: 1.0.0). 미지정 시 자동 감지\n --paths \"t=p,...\" 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client\n --main-branch B 릴리스 브랜치 (기본: 감지된 default branch)\n --develop-branch B 개발 브랜치 (기본: develop). 릴리스 브랜치와 같으면 trunk-based 모드\n --deploy-style STYLE 서버 배포 방식: simple | nginx | traefik | none (기본: simple)\n --flutter-env-mode MODE Flutter 환경변수 방식: dart-define | dotenv (기본: 신규 설치 dart-define, 기존 설치는 저장값·dotenv 유지)\n --flutter-store CSV Flutter 스토어 배포 대상: android,ios | android | ios | none (기본: 둘 다 설치)\n --android-deploy-mode MODE Play Store 배포 모드: store_only | store_prepare | store_submit (기본: store_only)\n --ios-deploy-mode MODE iOS 배포 모드: store_only | store_prepare | store_submit (기본: store_only)\n --semver-auto / --no-semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함)\n --copilot / --no-copilot Copilot으로 AI 요약 생성 (기본: 사용 안 함, GitHub Copilot AI Credits 소비)\n --release-automerge / --no-release-automerge 릴리스 PR 자동 머지 (기본: 사용함, 끄면 직접 머지)\n --lang LANG {langHelp}\n --force full 실행에 필수, uninstall은 비대화형 삭제 (모든 확인 생략, 기본값 사용)\n --dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌 (full/uninstall 지원)\n --purge-readme --mode uninstall --force 시 README.md 버전 섹션도 제거\n --purge-gitignore --mode uninstall --force 시 .gitignore 자동 추가 항목도 제거\n --purge-version --mode uninstall --force 시 version.yml도 제거\n -v, --version project-auto-wizard 버전 출력\n -h, --help 이 도움말 표시\n\n예시:\n npx project-auto-wizard --mode full --force --type spring,react\n npx project-auto-wizard --mode full --force --type flutter --paths \"flutter=app\"\n npx project-auto-wizard --mode status\n npx project-auto-wizard --mode doctor\n npx project-auto-wizard --mode full --force --type node --dry-run\n npx project-auto-wizard --mode uninstall --force --purge-readme --purge-gitignore --purge-version\n",
9
9
  "cli.args.projectVersionInvalid": "--project-version 값이 올바르지 않습니다: '{raw}' (x.y.z 형식, 예: 1.0.0)",
10
10
  "cli.args.typeUnsupported": "지원하지 않는 타입: '{type}'\n지원 타입: {valid}",
11
11
  "cli.args.typeEmpty": "--type 인자가 비어 있습니다",
@@ -13,6 +13,7 @@ export default {
13
13
  "cli.args.deployStyleInvalid": "--deploy-style 값이 올바르지 않습니다: {value} ({choices})",
14
14
  "cli.args.semverConflict": "--semver-auto와 --no-semver-auto는 동시에 지정할 수 없습니다",
15
15
  "cli.args.copilotConflict": "--copilot과 --no-copilot은 동시에 지정할 수 없습니다",
16
+ "cli.args.releaseAutomergeConflict": "--release-automerge와 --no-release-automerge는 동시에 지정할 수 없습니다",
16
17
  "cli.args.pathsEmpty": "--paths 인자가 비어 있습니다 (예: --paths flutter=app,react=client)",
17
18
  "cli.args.branchEmpty": "{flag}에 빈 값을 지정할 수 없습니다",
18
19
  "cli.args.branchInvalid": "{flag} 값이 브랜치 이름으로 올바르지 않습니다: '{value}'",
@@ -8,6 +8,7 @@ export default {
8
8
  "cmd.status.none": "(없음)",
9
9
  "cmd.status.branchMode": "브랜치 모드 : {mode} ({main} / {develop})",
10
10
  "cmd.status.unsetDefaultFalse": "미설정(기본 false)",
11
+ "cmd.status.unsetDefaultTrue": "미설정(기본 true)",
11
12
  "cmd.status.options": "옵션 : {value}",
12
13
  "cmd.status.modified": "사용자가 수정한 워크플로우 파일 ({n}개):",
13
14
  "cmd.status.unmodified": "모든 워크플로우 파일이 설치 시점 기본값과 동일합니다 (수정 없음).",
@@ -78,6 +79,12 @@ export default {
78
79
  "cmd.doctor.copilot.purpose": "AI 릴리스 노트 생성(선택)",
79
80
  "cmd.doctor.copilot.creditsOn": "GitHub Copilot AI Credits가 소비됩니다 — 조직은 'Allow use of Copilot CLI billed to the organization' 정책이 필요합니다.",
80
81
  "cmd.doctor.copilot.creditsOff": "켜면 GitHub Copilot AI Credits가 소비됩니다 — 조직은 'Allow use of Copilot CLI billed to the organization' 정책이 필요합니다.",
82
+ "cmd.doctor.automerge.name": "릴리스 PR 자동 머지",
83
+ "cmd.doctor.automerge.purpose": "릴리스 PR이 스스로 머지되는지",
84
+ "cmd.doctor.automerge.on": "켜져 있습니다 (version.yml의 release_automerge: true, 또는 키가 없음).",
85
+ "cmd.doctor.automerge.onHow": "릴리스 PR을 직접 머지하려면 --no-release-automerge로 다시 설치하거나 version.yml의 release_automerge를 false로 바꾸세요.",
86
+ "cmd.doctor.automerge.off": "꺼져 있습니다 (version.yml의 release_automerge: false) — 워크플로우가 릴리스 PR을 준비만 하고 머지하지 않습니다.",
87
+ "cmd.doctor.automerge.offHow": "릴리스가 정상 배포되도록 머지 커밋으로 직접 머지하세요(squash·rebase 금지). 다시 켜려면 --release-automerge를 쓰세요.",
81
88
  "cmd.doctor.copilot.fallback": "꺼져 있거나 사용할 수 없으면 규칙 기반 요약으로 자동 전환되므로 그대로 두셔도 됩니다.",
82
89
  "cmd.doctor.heading": "환경 진단",
83
90
  "cmd.doctor.details": "자세히: ",
@@ -20,6 +20,7 @@ export default {
20
20
  "ui.status-cards.card.on": "켜짐",
21
21
  "ui.status-cards.card.off": "꺼짐",
22
22
  "ui.status-cards.card.autoBump": "자동승격",
23
+ "ui.status-cards.card.copilot": "Copilot",
23
24
  "ui.status-cards.card.paths": "경로",
24
25
  "ui.status-cards.kind.update": "{title} — 템플릿 {from} → {to}",
25
26
  "ui.status-cards.kind.updateTitle": "업데이트",
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "./core/a
16
16
  import { detectTypes, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "./core/detect-fs.js";
17
17
  import { parseExisting, droppedPathLines } from "./core/version-yml.js";
18
18
  import { resolveReleaseOptions } from "./core/release-options.js";
19
+ import { explicitFromContext, optionContextFields } from "./core/options.js";
19
20
  import { runBreakingCheck } from "./core/breaking-check.js";
20
21
  import { resolveProjectPaths } from "./core/paths-resolve.js";
21
22
  import {
@@ -149,7 +150,7 @@ async function runInner(argv, {
149
150
  // The log file is created on the first write, so viewing only status/doctor from the menu leaves nothing behind.
150
151
  startLog("install");
151
152
  return await runInteractive(
152
- { includeSemverAuto: opts.includeSemverAuto, includeCopilotAi: opts.includeCopilotAi, language },
153
+ { ...optionContextFields(opts), language },
153
154
  { cwd, payloadRoot: payload, clock },
154
155
  );
155
156
  }
@@ -366,7 +367,7 @@ async function runInner(argv, {
366
367
  payload, existing, templateVersion: readTemplateVersion(), types, deployStyle: chosenDeployStyle,
367
368
  typeOptions,
368
369
  // Options: CLI flag first -> saved version.yml option -> default (same rule as interactive)
369
- releaseOptions: resolveReleaseOptions({ semverAuto: opts.includeSemverAuto, copilotAi: opts.includeCopilotAi }, existing),
370
+ releaseOptions: resolveReleaseOptions(explicitFromContext(opts), existing),
370
371
  mode: opts.mode, force: opts.force, version, versionCode, branch,
371
372
  branches,
372
373
  paths,
package/src/ui/prompts.js CHANGED
@@ -6,6 +6,7 @@ import * as engine from "./readline-engine.js";
6
6
  import { t } from "../i18n/index.js";
7
7
  import { DEPLOY_STYLES, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
8
8
  import { TYPE_IDS, typeInfo } from "../core/types.js";
9
+ import { askableOptions } from "../core/options.js";
9
10
  import { ENV_MODES, DEFAULT_ENV_MODE, STORE_PLATFORMS, DEPLOY_MODES, DEFAULT_DEPLOY_MODE, deployModeWarning } from "../core/flutter-options.js";
10
11
 
11
12
  export const CANCEL = engine.CANCEL;
@@ -47,8 +48,7 @@ export function editMenuOptions({ showFlutter = false, showOptions = false } = {
47
48
  { value: "branch", label: t("ui.prompts.edit.branch") },
48
49
  ];
49
50
  if (showOptions) {
50
- options.push({ value: "semverAuto", label: t("ui.prompts.edit.semverAuto") });
51
- options.push({ value: "copilotAi", label: t("ui.prompts.edit.copilotAi") });
51
+ for (const o of askableOptions()) options.push({ value: o.name, label: t(o.ask.menuLabelKey) });
52
52
  }
53
53
  if (showFlutter) {
54
54
  options.push({ value: "envMode", label: t("ui.prompts.edit.envMode") });
@@ -3,6 +3,7 @@
3
3
  import { A, paint } from "./ansi.js";
4
4
  import { t } from "../i18n/index.js";
5
5
  import { DEFAULT_DEPLOY_MODE } from "../core/flutter-options.js";
6
+ import { askableOptions } from "../core/options.js";
6
7
 
7
8
  const GUT = paint("│", A.gray);
8
9
  const HEAD = paint("◆", A.cyan);
@@ -59,8 +60,7 @@ export function printAnalysisCard({ mode = "", modeLabel = "", types = [], versi
59
60
  // Optional workflows - a saved value is used without asking, so show the current value before confirmation.
60
61
  if (options) {
61
62
  const onOff = (v) => (v ? paint(t("ui.status-cards.card.on"), A.green) : paint(t("ui.status-cards.card.off"), A.dim));
62
- row("🔢", t("ui.status-cards.card.autoBump"), onOff(options.semverAuto));
63
- row("🤖", "Copilot", onOff(options.copilotAi));
63
+ for (const o of askableOptions()) row(o.ask.cardIcon, t(o.ask.cardLabelKey), onOff(options[o.name]));
64
64
  }
65
65
  // Monorepo paths - shown when at least one entry is not the root
66
66
  const nonRoot = [...paths.entries()].filter(([, p]) => p && p !== ".");