project-auto-wizard 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/project-auto-wizard.js +18 -4
- package/package.json +2 -2
- package/payload/config/breaking-changes.json +28 -14
- package/payload/config/wizard-prompts.yml +102 -46
- package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
- package/payload/flutter-app/ios/ExportOptions.plist +8 -8
- package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
- package/payload/scripts/changelog_manager.py +296 -257
- package/payload/scripts/issue_helper.py +54 -50
- package/payload/scripts/messages.py +2279 -0
- package/payload/scripts/truncate_release_notes.py +7 -6
- package/payload/scripts/version_manager.py +64 -61
- package/payload/version.yml.template +2 -1
- package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +17 -12
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +59 -34
- package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +17 -10
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +54 -44
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +24 -17
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
- package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
- package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
- package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
- package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
- package/src/cli/args.js +98 -52
- package/src/cli/help.js +8 -41
- package/src/commands/doctor.js +85 -89
- package/src/commands/dry-run.js +63 -43
- package/src/commands/full.js +79 -77
- package/src/commands/install-settings.js +16 -15
- package/src/commands/interactive-flutter.js +20 -19
- package/src/commands/interactive.js +135 -132
- package/src/commands/purge.js +47 -42
- package/src/commands/status.js +36 -36
- package/src/commands/uninstall.js +43 -41
- package/src/context.js +12 -11
- package/src/core/assets.js +12 -11
- package/src/core/baseline.js +27 -27
- package/src/core/branches.js +29 -27
- package/src/core/branding.js +7 -6
- package/src/core/breaking-check.js +22 -21
- package/src/core/breaking.js +15 -8
- package/src/core/copy/app-files.js +5 -4
- package/src/core/copy/flutter-app.js +8 -8
- package/src/core/copy/gitignore.js +58 -50
- package/src/core/copy/readme.js +45 -35
- package/src/core/copy/simple.js +16 -15
- package/src/core/copy/workflows.js +131 -124
- package/src/core/deploy-style.js +51 -48
- package/src/core/detect-fs.js +57 -57
- package/src/core/detect.js +67 -65
- package/src/core/errors.js +2 -2
- package/src/core/flutter-doctor.js +17 -16
- package/src/core/flutter-hooks.js +33 -32
- package/src/core/flutter-options.js +34 -33
- package/src/core/fsutil.js +12 -12
- package/src/core/installed-stores.js +4 -4
- package/src/core/logger.js +42 -40
- package/src/core/paths-resolve.js +88 -86
- package/src/core/paths.js +10 -10
- package/src/core/release-options.js +8 -8
- package/src/core/removal-exec.js +14 -13
- package/src/core/removal-plan.js +37 -37
- package/src/core/types.js +47 -47
- package/src/core/verify.js +44 -38
- package/src/core/version-yml.js +76 -68
- package/src/core/wizard-env.js +46 -46
- package/src/core/wizard-labels.js +63 -41
- package/src/i18n/catalog/en/cli.js +50 -0
- package/src/i18n/catalog/en/commands.js +187 -0
- package/src/i18n/catalog/en/copy.js +19 -0
- package/src/i18n/catalog/en/core.js +74 -0
- package/src/i18n/catalog/en/core2.js +15 -0
- package/src/i18n/catalog/en/ui.js +221 -0
- package/src/i18n/catalog/en.js +12 -0
- package/src/i18n/catalog/index.js +5 -0
- package/src/i18n/catalog/ko/cli.js +50 -0
- package/src/i18n/catalog/ko/commands.js +187 -0
- package/src/i18n/catalog/ko/copy.js +19 -0
- package/src/i18n/catalog/ko/core.js +74 -0
- package/src/i18n/catalog/ko/core2.js +15 -0
- package/src/i18n/catalog/ko/ui.js +221 -0
- package/src/i18n/catalog/ko.js +10 -0
- package/src/i18n/index.js +44 -0
- package/src/i18n/languages.js +9 -0
- package/src/index.js +128 -94
- package/src/ui/ansi.js +7 -7
- package/src/ui/banner.js +7 -6
- package/src/ui/env-plan.js +82 -81
- package/src/ui/prompts.js +97 -122
- package/src/ui/readline-engine.js +59 -58
- package/src/ui/status-cards.js +37 -31
- package/src/ui/summary.js +89 -88
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
// io
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Interactive wizard.
|
|
2
|
+
// Testable through io injection. The real run passes the src/ui/prompts.js functions as io.
|
|
3
|
+
// The visual layers (banner/detectionLog/analysisCard/installKind/summary) and the low-level engine (engineIo)
|
|
4
|
+
// are "optional members" of io - a stub that omits them just skips that layer; the execution contract is the same.
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/assets.js";
|
|
@@ -28,115 +28,117 @@ import * as prompts from "../ui/prompts.js";
|
|
|
28
28
|
import { runStatus, printStatus } from "./status.js";
|
|
29
29
|
import { runDoctor, printDoctorReport } from "./doctor.js";
|
|
30
30
|
import { currentLogPath, hasLegacyMdLogs } from "../core/logger.js";
|
|
31
|
+
import { t, getLanguage } from "../i18n/index.js";
|
|
31
32
|
|
|
32
33
|
const CANCEL = prompts.CANCEL;
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
const
|
|
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");
|
|
36
38
|
const isCancel = (v) => v === CANCEL || typeof v === "symbol";
|
|
37
39
|
|
|
38
|
-
// io
|
|
40
|
+
// io defaults to the real prompts. Tests inject a stub io.
|
|
39
41
|
export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot, clock, io = prompts } = {}) {
|
|
40
42
|
const payload = assertPayload(payloadRoot ?? resolvePayloadRoot());
|
|
41
43
|
const templateVersion = readTemplateVersion();
|
|
42
44
|
|
|
43
|
-
//
|
|
44
|
-
if (io.banner) io.banner({ version: templateVersion, modeLabel: "
|
|
45
|
-
else io.intro?.("
|
|
45
|
+
// Start banner. Stubs have no banner -> fall back to intro.
|
|
46
|
+
if (io.banner) io.banner({ version: templateVersion, modeLabel: t("interactive.banner.modeLabel") });
|
|
47
|
+
else io.intro?.(t("interactive.intro"));
|
|
46
48
|
|
|
47
|
-
//
|
|
49
|
+
// Existing version.yml - single source of truth for preserving version/version_code/paths/options
|
|
48
50
|
const vyPath = join(cwd, "version.yml");
|
|
49
51
|
const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
|
|
50
52
|
|
|
51
|
-
//
|
|
53
|
+
// New install vs update detection
|
|
52
54
|
io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
|
|
53
55
|
|
|
54
|
-
// 1)
|
|
55
|
-
// status/doctor
|
|
56
|
-
//
|
|
57
|
-
// CLI
|
|
56
|
+
// 1) Mode selection
|
|
57
|
+
// status/doctor are read-only, so no detection or breaking gate is needed. After showing the result we
|
|
58
|
+
// return to the menu: the point of diagnosis is install preparation, so check -> install should finish in one session.
|
|
59
|
+
// The CLI path (--mode status/doctor in index.js) is a one-shot command, so it exits immediately as before.
|
|
58
60
|
let mode;
|
|
59
61
|
for (let round = 0; ; round++) {
|
|
60
62
|
mode = await io.selectMode({ again: round > 0 });
|
|
61
|
-
if (mode === CANCEL || mode == null) { io.cancelMessage?.("
|
|
63
|
+
if (mode === CANCEL || mode == null) { io.cancelMessage?.(t("interactive.cancel.install")); return 0; }
|
|
62
64
|
if (mode === "status") { printStatus(runStatus(payload, cwd)); continue; }
|
|
63
65
|
if (mode === "doctor") { printDoctorReport(runDoctor(cwd)); continue; }
|
|
64
66
|
break;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
// uninstall
|
|
68
|
-
// runUninstallFlow
|
|
69
|
+
// uninstall mode - delete after per-item opt-in through an interactive checklist. No detection or breaking gate needed.
|
|
70
|
+
// runUninstallFlow returns null on cancel / nothing to remove - then the completion outro is not printed.
|
|
69
71
|
if (mode === "uninstall") {
|
|
70
72
|
const result = await runUninstallFlow(payload, cwd, io);
|
|
71
|
-
if (result) io.outro?.("
|
|
73
|
+
if (result) io.outro?.(t("interactive.outro.uninstall"));
|
|
72
74
|
return 0;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
// Breaking Changes
|
|
77
|
+
// Breaking Changes gate (common to all modes, a confirmation question in interactive mode)
|
|
76
78
|
const proceed = await runBreakingCheck({
|
|
77
79
|
cwd, payloadRoot: payload, templateVersion,
|
|
78
80
|
askYesNo: (msg, def) => io.askYesNo(msg, def),
|
|
79
81
|
});
|
|
80
|
-
if (!proceed) { io.cancelMessage?.("
|
|
82
|
+
if (!proceed) { io.cancelMessage?.(t("interactive.cancel.integration")); return 0; }
|
|
81
83
|
|
|
82
|
-
// full/version/workflows
|
|
83
|
-
//
|
|
84
|
-
//
|
|
84
|
+
// full/version/workflows - detection (the existing version.yml takes top priority for version)
|
|
85
|
+
// Detection warnings are collected and printed inside the detection box instead of immediately - they used to
|
|
86
|
+
// appear before the box and look like warnings about the previous question. The hint text is also adapted for interactive mode.
|
|
85
87
|
const detectWarnings = [];
|
|
86
88
|
let types = detectTypes(cwd);
|
|
87
89
|
let version = resolveVersion({
|
|
88
90
|
cwd, existing, types,
|
|
89
91
|
warn: (m) => detectWarnings.push(m),
|
|
90
|
-
hint: "
|
|
92
|
+
hint: t("interactive.hint.version"),
|
|
91
93
|
});
|
|
92
|
-
//
|
|
94
|
+
// Whether to re-detect in a monorepo subfolder once paths are settled - saved values and hand-typed values are left alone.
|
|
93
95
|
let versionAutoDetected = !existing?.version;
|
|
94
96
|
let branch = detectDefaultBranch(cwd, {
|
|
95
97
|
warn: (m) => detectWarnings.push(m),
|
|
96
|
-
hint: "
|
|
98
|
+
hint: t("interactive.hint.branch"),
|
|
97
99
|
});
|
|
98
100
|
const repoName = detectRepoName(cwd);
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
+
// Initial values of optional workflows: CLI flags (--copilot etc.) -> options saved in version.yml
|
|
102
|
+
// Values set by a flag skip the question - same priority as non-interactive mode.
|
|
101
103
|
let { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } = pickReleaseOptions(
|
|
102
104
|
{ semverAuto: baseCtx?.includeSemverAuto, copilotAi: baseCtx?.includeCopilotAi }, existing);
|
|
103
|
-
//
|
|
105
|
+
// Server deploy style - not asked again when a saved value (version.yml) exists (same convention as semver_auto).
|
|
104
106
|
let deployStyle = savedDeployStyle(existing);
|
|
105
107
|
const showOptional = mode === "full";
|
|
106
108
|
const realTty = process.stdout.isTTY === true;
|
|
107
109
|
|
|
108
|
-
// Flutter
|
|
109
|
-
//
|
|
110
|
-
//
|
|
110
|
+
// Flutter options - not asked again when a saved value exists (same convention as deploy_style).
|
|
111
|
+
// Existing installs without saved values start with dotenv to preserve behaviour, and stores are inferred from the installed workflows.
|
|
112
|
+
// The env default rule (new = dart-define, existing install without saved value = dotenv) lives only in resolveFlutterOptions.
|
|
111
113
|
let flutter = savedFlutterState(existing);
|
|
112
114
|
const flutterAsk = {
|
|
113
115
|
envModeDefault: resolveFlutterOptions({
|
|
114
116
|
cli: { envMode: "", stores: null, androidDeployMode: "", iosDeployMode: "" }, existing,
|
|
115
117
|
}).envMode,
|
|
116
|
-
//
|
|
118
|
+
// The initial selection of a new install must equal the CLI default (--flutter-store unspecified = both) - the install result must not depend on the path taken.
|
|
117
119
|
inferredStores: existing && flutter.stores === null ? inferInstalledStores(join(cwd, PATHS.workflowsDir)) : [...STORE_PLATFORMS],
|
|
118
120
|
};
|
|
119
|
-
//
|
|
121
|
+
// Already-decided values are skipped, so calling this several times never repeats a question.
|
|
120
122
|
const askFlutterOptions = async () => {
|
|
121
123
|
if (types.includes("flutter")) flutter = await askUnsetFlutterOptions(io, flutter, flutterAsk);
|
|
122
124
|
};
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
+
// Server deploy style - asked only for types that have server-deploy (CD) workflows (spring, go, python, ...).
|
|
126
|
+
// For other types the question does not affect the install result, so it is neither asked nor recorded.
|
|
125
127
|
const hasServerDeploy = () => hasServerDeployWorkflows(payload, types);
|
|
126
128
|
const askDeployStyle = async () => {
|
|
127
129
|
if (isDeployStyle(deployStyle) || !hasServerDeploy()) return;
|
|
128
130
|
const picked = await io.selectDeployStyle({ nonstop: hasNonstopWorkflows(payload, types) });
|
|
129
|
-
deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC =
|
|
131
|
+
deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC = default
|
|
130
132
|
};
|
|
131
133
|
|
|
132
|
-
//
|
|
134
|
+
// Detection log. markers = files whose existence was actually confirmed.
|
|
133
135
|
let markers = detectMarkers(cwd, types);
|
|
134
136
|
io.detectionLog?.({ types, version, branch, markers, warnings: detectWarnings });
|
|
135
137
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
138
|
+
// Confirm types - detection is a guess, so get confirmation before any other question. The confirmation UI used to be
|
|
139
|
+
// hidden two steps deep in 'Edit > Project type', so installs often finished with the wrong type.
|
|
140
|
+
// Types decide the scope of the later questions (optional workflows, paths, env), so this is the right place in the order.
|
|
141
|
+
// Not asked for update installs with saved values and in non-interactive mode - unchanged behaviour.
|
|
140
142
|
if (showOptional && !existing?.types?.length) {
|
|
141
143
|
const picked = await io.confirmTypes({ types, markers });
|
|
142
144
|
if (!isCancel(picked) && Array.isArray(picked) && picked.length) {
|
|
@@ -148,37 +150,37 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
//
|
|
153
|
+
// Deploy style, Flutter options, semver/Copilot questions - only when optional items are asked (showOptional)
|
|
152
154
|
if (showOptional) {
|
|
153
155
|
await askDeployStyle();
|
|
154
156
|
|
|
155
|
-
// Flutter
|
|
157
|
+
// Flutter options - env mode -> store deploy targets -> per-platform deploy mode.
|
|
156
158
|
await askFlutterOptions();
|
|
157
159
|
|
|
158
|
-
//
|
|
159
|
-
// version.yml
|
|
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.
|
|
160
162
|
if (mode === "full" && includeSemverAuto === null) {
|
|
161
|
-
const y2 = await io.askYesNo(
|
|
163
|
+
const y2 = await io.askYesNo(semverAutoQuestion(), true);
|
|
162
164
|
includeSemverAuto = y2 === true;
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
// Copilot AI
|
|
167
|
+
// Copilot AI summary - consumes AI Credits, so opt-in (default No). Skipped when a saved value exists.
|
|
166
168
|
if (mode === "full" && includeCopilotAi === null) {
|
|
167
|
-
const y3 = await io.askYesNo(
|
|
169
|
+
const y3 = await io.askYesNo(copilotAiQuestion(), false);
|
|
168
170
|
includeCopilotAi = y3 === true;
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
+
// When a question was actually asked (the full-mode questions above) its answer is respected, and only options that were not asked
|
|
174
|
+
// are filled with defaults - same function as the CLI path (index.js), hence the same rule.
|
|
173
175
|
({ includeSemverAuto, includeCopilotAi } = resolveReleaseOptions(
|
|
174
176
|
{ semverAuto: includeSemverAuto, copilotAi: includeCopilotAi }, existing));
|
|
175
177
|
const showOptionToggles = mode === "full";
|
|
176
178
|
|
|
177
|
-
//
|
|
179
|
+
// Confirm/edit loop - ESC means 'stay' (only an explicit 'No' exits)
|
|
178
180
|
let paths = new Map();
|
|
179
181
|
let confirmed = false;
|
|
180
182
|
while (!confirmed) {
|
|
181
|
-
//
|
|
183
|
+
// Project analysis overview card. Stubs have none -> fall back to note.
|
|
182
184
|
if (io.analysisCard) {
|
|
183
185
|
io.analysisCard({
|
|
184
186
|
mode, modeLabel: modeLabel(mode), types, version, branch, showOptional, paths, flutter, envModeDefault: flutterAsk.envModeDefault,
|
|
@@ -188,40 +190,40 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
188
190
|
io.note?.(summarize({
|
|
189
191
|
mode, types, version, branch, showOptional, flutter, envModeDefault: flutterAsk.envModeDefault,
|
|
190
192
|
options: showOptionToggles ? { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } : null,
|
|
191
|
-
}), "
|
|
193
|
+
}), t("interactive.summary.title"));
|
|
192
194
|
}
|
|
193
195
|
const choice = await io.confirmProjectMenu();
|
|
194
|
-
if (choice === "cancel") { io.cancelMessage?.("
|
|
195
|
-
if (isCancel(choice) || choice == null) continue; // ESC =
|
|
196
|
+
if (choice === "cancel") { io.cancelMessage?.(t("interactive.cancel.install")); return 0; }
|
|
197
|
+
if (isCancel(choice) || choice == null) continue; // ESC = stay (redraw the loop)
|
|
196
198
|
if (choice === "continue") { confirmed = true; break; }
|
|
197
|
-
// edit
|
|
199
|
+
// edit loop
|
|
198
200
|
let editing = true;
|
|
199
201
|
while (editing) {
|
|
200
202
|
const what = await io.editMenu({ showFlutter: showOptional && types.includes("flutter"), showOptions: showOptionToggles });
|
|
201
203
|
if (isCancel(what) || what === "done") { editing = false; break; }
|
|
202
204
|
if (what === "type") {
|
|
203
|
-
const
|
|
204
|
-
if (!isCancel(
|
|
205
|
-
//
|
|
205
|
+
const picked = await io.selectTypes(types);
|
|
206
|
+
if (!isCancel(picked) && Array.isArray(picked) && picked.length) {
|
|
207
|
+
// If the type set really changed, reset it so paths are re-resolved (sorted-set comparison)
|
|
206
208
|
const oldSorted = [...types].sort().join(",");
|
|
207
|
-
types =
|
|
209
|
+
types = picked.filter((x) => VALID_TYPES.includes(x));
|
|
208
210
|
if ([...types].sort().join(",") !== oldSorted) paths = new Map();
|
|
209
211
|
}
|
|
210
212
|
} else if (what === "version") {
|
|
211
|
-
const v = await io.askText("
|
|
213
|
+
const v = await io.askText(t("interactive.edit.versionPrompt"), version);
|
|
212
214
|
if (!isCancel(v) && v !== version) {
|
|
213
|
-
// semver
|
|
215
|
+
// semver format check
|
|
214
216
|
if (/^\d+\.\d+\.\d+$/.test(v)) { version = v; versionAutoDetected = false; }
|
|
215
|
-
else io.note?.("
|
|
217
|
+
else io.note?.(t("interactive.edit.versionInvalid"), t("interactive.edit.versionInvalidTitle"));
|
|
216
218
|
}
|
|
217
219
|
} else if (what === "branch") {
|
|
218
|
-
branch = await askBranchName(io, "
|
|
220
|
+
branch = await askBranchName(io, t("interactive.edit.branchPrompt"), branch, isCancel);
|
|
219
221
|
} else if (what === "semverAuto") {
|
|
220
|
-
//
|
|
221
|
-
const y = await io.askYesNo(
|
|
222
|
+
// 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);
|
|
222
224
|
if (typeof y === "boolean") includeSemverAuto = y;
|
|
223
225
|
} else if (what === "copilotAi") {
|
|
224
|
-
const y = await io.askYesNo(
|
|
226
|
+
const y = await io.askYesNo(copilotAiQuestion(), includeCopilotAi);
|
|
225
227
|
if (typeof y === "boolean") includeCopilotAi = y;
|
|
226
228
|
} else if (FLUTTER_EDIT_ITEMS.has(what)) {
|
|
227
229
|
flutter = await editFlutterOption(io, what, flutter, flutterAsk.envModeDefault);
|
|
@@ -229,13 +231,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
233
|
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
+
// Settle the options even when the flutter type was added late in the edit loop - already-decided values are not asked again.
|
|
235
|
+
// The same goes for a server-deploy type added late.
|
|
234
236
|
if (showOptional) {
|
|
235
237
|
await askDeployStyle();
|
|
236
238
|
await askFlutterOptions();
|
|
237
239
|
}
|
|
238
|
-
//
|
|
240
|
+
// Even when no question was asked (non-full modes etc.) fill in behaviour-preserving defaults so workflow substitution stays consistent.
|
|
239
241
|
const flutterOptions = {
|
|
240
242
|
envMode: flutter.envMode || flutterAsk.envModeDefault,
|
|
241
243
|
stores: flutter.stores,
|
|
@@ -243,9 +245,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
243
245
|
iosDeployMode: flutter.iosDeployMode || DEFAULT_DEPLOY_MODE,
|
|
244
246
|
};
|
|
245
247
|
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
248
|
+
// Branch setup. Only full/workflows ask; version records the defaults.
|
|
249
|
+
// A saved value (version.yml metadata.template.branches) is reused without asking (update mode).
|
|
250
|
+
// A detection-failure value saved by an earlier version ("(unknown)" etc.) counts as not saved and is asked again.
|
|
249
251
|
const savedBranches = existing?.branches
|
|
250
252
|
&& isValidBranchName(existing.branches.main) && isValidBranchName(existing.branches.develop)
|
|
251
253
|
? existing.branches : null;
|
|
@@ -255,114 +257,115 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
255
257
|
let developMissing = false;
|
|
256
258
|
if (showOptional && !savedBranches) {
|
|
257
259
|
const remoteBranches = await detectRemoteBranches(cwd);
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// (
|
|
260
|
+
// Instead of the implicit rule that trunk-based only happens when the same name is typed for both questions,
|
|
261
|
+
// let the user pick the strategy explicitly first. Cancel / any other value falls back to pr-flow, the existing default
|
|
262
|
+
// (same "ESC = default" pattern as selectDeployStyle).
|
|
261
263
|
const strategyPick = await io.selectBranchStrategy();
|
|
262
264
|
const strategy = strategyPick === "trunk-based" ? "trunk-based" : "pr-flow";
|
|
263
|
-
const mainB = await pickBranch(io,
|
|
264
|
-
// trunk-based
|
|
265
|
+
const mainB = await pickBranch(io, t("interactive.branch.releasePrompt", { branch }), branch, remoteBranches, isCancel);
|
|
266
|
+
// For trunk-based the development-branch question is skipped - the only branch (main) doubles as develop.
|
|
265
267
|
const devB = strategy === "trunk-based"
|
|
266
268
|
? mainB
|
|
267
|
-
: await pickBranch(io, "
|
|
269
|
+
: await pickBranch(io, t("interactive.branch.developPrompt"), "develop", remoteBranches, isCancel);
|
|
268
270
|
branches = resolveBranchConfig({ mainBranch: mainB, developBranch: devB, defaultBranch: branch });
|
|
269
271
|
if (branches.mode === "trunk-based") {
|
|
270
|
-
io.note?.(
|
|
272
|
+
io.note?.(t("interactive.branch.trunkNote", { main: branches.main }), t("interactive.branch.modeTitle"));
|
|
271
273
|
} else if (!remoteBranches.length) {
|
|
272
|
-
//
|
|
274
|
+
// With no remote or an empty one there is nothing to push against - report that it was not created and how to do it.
|
|
273
275
|
developMissing = true;
|
|
274
|
-
io.note?.(developMissingNotice(branches), "
|
|
276
|
+
io.note?.(developMissingNotice(branches), t("interactive.branch.title"));
|
|
275
277
|
} else if (!remoteBranches.includes(branches.develop)) {
|
|
276
278
|
const r = await ensureDevelopBranch({
|
|
277
279
|
develop: branches.develop, remoteBranches, cwd,
|
|
278
280
|
confirm: (msg) => io.askYesNo(msg, true),
|
|
279
|
-
log: (m) => io.note?.(m, "
|
|
281
|
+
log: (m) => io.note?.(m, t("interactive.branch.title")),
|
|
280
282
|
});
|
|
281
283
|
developMissing = r.created !== true || r.pushed === false;
|
|
282
284
|
}
|
|
283
285
|
}
|
|
284
286
|
|
|
285
|
-
//
|
|
287
|
+
// Settle paths (saved values, candidate scan, questions)
|
|
286
288
|
if (mode === "full") {
|
|
287
289
|
paths = await resolveProjectPaths({
|
|
288
290
|
root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
|
|
289
291
|
force: false, tty: realTty, io: io.engineIo ?? {},
|
|
290
292
|
});
|
|
291
293
|
} else {
|
|
292
|
-
for (const
|
|
294
|
+
for (const ty of types) if (ty !== "basic" && !paths.has(ty)) paths.set(ty, existing?.paths.get(ty) || ".");
|
|
293
295
|
}
|
|
294
296
|
|
|
295
|
-
//
|
|
297
|
+
// In a monorepo the version/build-number files live inside the type folder - detect there now that paths are settled.
|
|
296
298
|
if (versionAutoDetected && [...paths.values()].some((p) => p && p !== ".")) {
|
|
297
299
|
version = detectVersion(cwd, { types, paths, warn: () => {} });
|
|
298
300
|
}
|
|
299
301
|
const versionCode = resolveVersionCode({ cwd, existing, types, paths });
|
|
300
302
|
|
|
301
|
-
// @wizard env
|
|
303
|
+
// @wizard env plan questions (full/workflows only)
|
|
302
304
|
const resolvers = makeResolvers(cwd, repoName, paths, flutterOptions);
|
|
303
305
|
let envValues = new Map(), envUseDefaults = true, envAnswers = [];
|
|
304
306
|
if (showOptional) {
|
|
305
307
|
const plan = await promptEnvPlan({
|
|
306
308
|
payloadRoot: payload, types, io: io.engineIo ?? null, force: false,
|
|
307
309
|
resolvers, deployStyle, targetRoot: cwd, repoName,
|
|
308
|
-
flutterStore: flutterOptions.stores, //
|
|
310
|
+
flutterStore: flutterOptions.stores, // ask questions of deselected store workflows are not asked
|
|
309
311
|
});
|
|
310
312
|
envValues = plan.values;
|
|
311
313
|
envUseDefaults = plan.useDefaults;
|
|
312
|
-
envAnswers = plan.answers || []; //
|
|
314
|
+
envAnswers = plan.answers || []; // the completion summary and install log share the same answer data
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
const { now, today } = clock || utcNow();
|
|
316
318
|
const ctx = buildInstallContext({
|
|
317
319
|
payload, existing, templateVersion, types,
|
|
318
|
-
//
|
|
320
|
+
// 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.
|
|
319
321
|
deployStyle: resolveDeployStyle({ payload, types, explicit: deployStyle, existing }),
|
|
320
322
|
typeOptions: flutterOptions,
|
|
321
323
|
releaseOptions: { includeSemverAuto, includeCopilotAi },
|
|
322
324
|
mode, force: true, version, versionCode, branch, branches, paths,
|
|
323
325
|
repoName, resolvers, envValues, envUseDefaults, now, today,
|
|
324
|
-
|
|
326
|
+
language: baseCtx.language ?? existing?.language ?? getLanguage(),
|
|
327
|
+
// Extra context used by the install log and completion summary - does not change the install behaviour itself.
|
|
325
328
|
markers, envAnswers, detectWarnings,
|
|
326
329
|
});
|
|
327
330
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
331
|
+
// Ask only what the user has to answer. The baseline 3-way merge filters out the automatically
|
|
332
|
+
// safe cases, so what reaches here is (a) real conflicts where both sides changed and (b) files the user deleted.
|
|
330
333
|
let hooks = {};
|
|
331
334
|
if (showOptional && io.engineIo?.select) {
|
|
332
335
|
const { conflicts, removed } = surveyWorkflows(ctx, payload, cwd);
|
|
333
336
|
|
|
334
|
-
// (b)
|
|
337
|
+
// (b) Deleted files - never restored silently. The key point is that restoring is not the default.
|
|
335
338
|
const restoreRemoved = new Set();
|
|
336
339
|
if (removed.length) {
|
|
337
340
|
io.note?.(removed.map((r) => ` - ${r.filename}`).join("\n"),
|
|
338
|
-
|
|
341
|
+
t("interactive.removed.title", { count: removed.length }));
|
|
339
342
|
for (const { filename } of removed) {
|
|
340
343
|
const sel = await io.engineIo.select({
|
|
341
|
-
message:
|
|
344
|
+
message: t("interactive.removed.message", { filename }),
|
|
342
345
|
options: [
|
|
343
|
-
{ value: "keep", label: "
|
|
344
|
-
{ value: "restore", label: "
|
|
346
|
+
{ value: "keep", label: t("interactive.removed.keep") },
|
|
347
|
+
{ value: "restore", label: t("interactive.removed.restore") },
|
|
345
348
|
],
|
|
346
349
|
});
|
|
347
|
-
if (!isCancel(sel) && sel === "restore") restoreRemoved.add(filename); // ESC =
|
|
350
|
+
if (!isCancel(sel) && sel === "restore") restoreRemoved.add(filename); // ESC = keep
|
|
348
351
|
}
|
|
349
352
|
}
|
|
350
353
|
|
|
351
|
-
// (a)
|
|
354
|
+
// (a) Real conflicts, three choices - one decision per type, applied to its files via a cache
|
|
352
355
|
const decisions = new Map();
|
|
353
356
|
if (conflicts.length) {
|
|
354
357
|
const perType = new Map();
|
|
355
358
|
for (const { filename, type } of conflicts) {
|
|
356
359
|
if (!perType.has(type)) {
|
|
357
360
|
const sel = await io.engineIo.select({
|
|
358
|
-
message:
|
|
361
|
+
message: t("interactive.conflict.message", { type }),
|
|
359
362
|
options: [
|
|
360
|
-
{ value: "skip", label: "
|
|
361
|
-
{ value: "backup", label: ".
|
|
362
|
-
{ value: "template", label: "
|
|
363
|
+
{ value: "skip", label: t("interactive.conflict.skip") },
|
|
364
|
+
{ value: "backup", label: t("interactive.conflict.backup") },
|
|
365
|
+
{ value: "template", label: t("interactive.conflict.template") },
|
|
363
366
|
],
|
|
364
367
|
});
|
|
365
|
-
perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC =
|
|
368
|
+
perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = skip
|
|
366
369
|
}
|
|
367
370
|
decisions.set(filename, perType.get(type));
|
|
368
371
|
}
|
|
@@ -372,7 +375,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
372
375
|
|
|
373
376
|
const result = runFull(ctx, payload, cwd, hooks);
|
|
374
377
|
|
|
375
|
-
//
|
|
378
|
+
// Completion summary
|
|
376
379
|
io.summary?.({
|
|
377
380
|
mode, types, version, versionCode, branches, developMissing,
|
|
378
381
|
copiedFiles: result?.workflows?.copiedFiles ?? [],
|
|
@@ -391,30 +394,30 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
391
394
|
scripts: result?.scripts ?? null,
|
|
392
395
|
});
|
|
393
396
|
const notices = postInstallNotices(result, { interactive: true });
|
|
394
|
-
if (notices.length) io.note?.(notices.join("\n"), "
|
|
395
|
-
io.outro?.(
|
|
397
|
+
if (notices.length) io.note?.(notices.join("\n"), t("interactive.notices.title"));
|
|
398
|
+
io.outro?.(t("interactive.outro.done", { mode }));
|
|
396
399
|
return 0;
|
|
397
400
|
}
|
|
398
401
|
|
|
399
|
-
//
|
|
400
|
-
// def
|
|
402
|
+
// Branch selection - select (+ manual entry) when a remote list exists, otherwise text input. ESC/empty = default.
|
|
403
|
+
// def sorts first, main/develop next, and the cursor is pinned to def.
|
|
401
404
|
export async function pickBranch(io, message, def, remoteBranches, isCancel) {
|
|
402
405
|
if (io.engineIo?.select && remoteBranches.length) {
|
|
403
406
|
const sorted = sortBranchesForSelection(remoteBranches, def);
|
|
404
407
|
const options = [];
|
|
405
|
-
if (!sorted.includes(def)) options.push({ value: def, label:
|
|
406
|
-
for (const b of sorted) options.push({ value: b, label: b === def ?
|
|
407
|
-
options.push({ value: "__custom__", label: "
|
|
408
|
+
if (!sorted.includes(def)) options.push({ value: def, label: t("interactive.branch.defaultNew", { name: def }) });
|
|
409
|
+
for (const b of sorted) options.push({ value: b, label: b === def ? t("interactive.branch.defaultMark", { name: b }) : b });
|
|
410
|
+
options.push({ value: "__custom__", label: t("interactive.branch.custom") });
|
|
408
411
|
const initialIndex = Math.max(0, options.findIndex((o) => o.value === def));
|
|
409
412
|
const sel = await io.engineIo.select({ message, options, initialIndex });
|
|
410
|
-
if (sel === "__custom__") return askBranchName(io, "
|
|
413
|
+
if (sel === "__custom__") return askBranchName(io, t("interactive.branch.namePrompt"), def, isCancel);
|
|
411
414
|
return isCancel(sel) || sel == null ? def : sel;
|
|
412
415
|
}
|
|
413
416
|
return askBranchName(io, message, def, isCancel);
|
|
414
417
|
}
|
|
415
418
|
|
|
416
|
-
//
|
|
417
|
-
//
|
|
419
|
+
// Branch name text input - trim whitespace, use the default when empty or ESC, ask again for unusable names.
|
|
420
|
+
// The value goes into the workflow trigger as is, so names with spaces etc. would keep the workflow from ever running.
|
|
418
421
|
async function askBranchName(io, message, def, isCancel) {
|
|
419
422
|
for (;;) {
|
|
420
423
|
const v = await io.askText(message, def);
|
|
@@ -422,40 +425,40 @@ async function askBranchName(io, message, def, isCancel) {
|
|
|
422
425
|
const name = String(v ?? "").trim();
|
|
423
426
|
if (!name) return def;
|
|
424
427
|
if (isValidBranchName(name)) return name;
|
|
425
|
-
io.note?.(
|
|
428
|
+
io.note?.(t("interactive.branch.invalid", { name }), t("interactive.branch.invalidTitle"));
|
|
426
429
|
}
|
|
427
430
|
}
|
|
428
431
|
|
|
429
432
|
function summarize({ mode, types, version, branch, showOptional, flutter, envModeDefault, options = null }) {
|
|
430
433
|
const lines = [
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
434
|
+
t("interactive.summary.mode", { value: modeLabel(mode) }),
|
|
435
|
+
t("interactive.summary.types", { value: `${types.join(", ")}${types.length > 1 ? t("interactive.summary.multi") : ""}` }),
|
|
436
|
+
t("interactive.summary.version", { value: version }),
|
|
437
|
+
t("interactive.summary.branch", { value: branch }),
|
|
435
438
|
];
|
|
436
439
|
if (showOptional) {
|
|
437
440
|
if (types.includes("flutter")) {
|
|
438
441
|
const stores = flutter.stores ?? [];
|
|
439
442
|
const modeParts = stores.map((p) => `${p}=${(p === "android" ? flutter.androidDeployMode : flutter.iosDeployMode) || DEFAULT_DEPLOY_MODE}`);
|
|
440
|
-
lines.push(
|
|
441
|
-
lines.push(
|
|
442
|
-
lines.push(
|
|
443
|
+
lines.push(t("interactive.summary.envMode", { value: flutter.envMode || envModeDefault }));
|
|
444
|
+
lines.push(t("interactive.summary.stores", { value: stores.length ? stores.join(", ") : t("interactive.summary.none") }));
|
|
445
|
+
lines.push(t("interactive.summary.deployMode", { value: modeParts.length ? modeParts.join(" ") : t("interactive.summary.none") }));
|
|
443
446
|
}
|
|
444
447
|
}
|
|
445
448
|
if (options) {
|
|
446
|
-
lines.push(
|
|
447
|
-
lines.push(
|
|
449
|
+
lines.push(t("interactive.summary.semverAuto", { value: options.semverAuto ? t("interactive.summary.on") : t("interactive.summary.off") }));
|
|
450
|
+
lines.push(t("interactive.summary.copilotAi", { value: options.copilotAi ? t("interactive.summary.on") : t("interactive.summary.off") }));
|
|
448
451
|
}
|
|
449
452
|
return lines.join("\n");
|
|
450
453
|
}
|
|
451
454
|
|
|
452
455
|
function modeLabel(m) {
|
|
453
|
-
return { full: "
|
|
456
|
+
return { full: t("interactive.modeLabel.full"), version: t("interactive.modeLabel.version"), workflows: t("interactive.modeLabel.workflows") }[m] || m;
|
|
454
457
|
}
|
|
455
458
|
|
|
456
459
|
function utcNow(date = new Date()) {
|
|
457
460
|
const p = (n) => String(n).padStart(2, "0");
|
|
458
461
|
const d = `${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())}`;
|
|
459
|
-
const
|
|
460
|
-
return { now: `${d} ${
|
|
462
|
+
const tm = `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}`;
|
|
463
|
+
return { now: `${d} ${tm}`, today: d };
|
|
461
464
|
}
|