project-auto-wizard 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.ja.md +3 -1
  2. package/README.ko.md +3 -1
  3. package/README.md +4 -2
  4. package/README.zh-CN.md +3 -1
  5. package/package.json +1 -1
  6. package/payload/config/breaking-changes.json +31 -5
  7. package/payload/config/wizard-prompts.yml +0 -4
  8. package/payload/scripts/changelog_manager.py +8 -8
  9. package/payload/scripts/messages.py +460 -24
  10. package/payload/scripts/version_manager.py +102 -73
  11. package/payload/version.yml.template +2 -2
  12. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +9 -4
  13. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +62 -30
  14. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +96 -44
  15. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +79 -35
  16. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +71 -35
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +24 -8
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +29 -10
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +57 -23
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +155 -72
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +49 -20
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +162 -62
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +184 -87
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +38 -15
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +14 -5
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +375 -120
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +9 -4
  28. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +12 -4
  29. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +375 -120
  30. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +9 -4
  31. package/payload/workflows/react/PROJECT-REACT-CI.yaml +38 -9
  32. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +19 -5
  33. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +71 -35
  34. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +9 -6
  35. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +7 -4
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +425 -144
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +7 -4
  38. package/src/cli/args.js +17 -7
  39. package/src/commands/doctor.js +16 -0
  40. package/src/commands/dry-run.js +10 -1
  41. package/src/commands/full.js +5 -2
  42. package/src/commands/interactive.js +4 -1
  43. package/src/commands/status.js +14 -1
  44. package/src/core/copy/readme.js +58 -2
  45. package/src/core/copy/simple.js +31 -0
  46. package/src/core/copy/workflows.js +2 -2
  47. package/src/core/deploy-style.js +1 -1
  48. package/src/core/detect.js +3 -2
  49. package/src/core/types.js +15 -8
  50. package/src/core/version-yml.js +52 -6
  51. package/src/i18n/catalog/en/cli.js +1 -0
  52. package/src/i18n/catalog/en/commands.js +9 -0
  53. package/src/i18n/catalog/en/copy.js +1 -0
  54. package/src/i18n/catalog/en/core2.js +3 -0
  55. package/src/i18n/catalog/ko/cli.js +1 -0
  56. package/src/i18n/catalog/ko/commands.js +9 -0
  57. package/src/i18n/catalog/ko/copy.js +1 -0
  58. package/src/i18n/catalog/ko/core2.js +3 -0
  59. package/src/index.js +8 -4
  60. package/src/ui/env-plan.js +1 -1
  61. package/src/ui/prompts.js +7 -3
  62. package/src/ui/summary.js +1 -1
  63. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +0 -241
  64. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +0 -330
@@ -132,6 +132,7 @@ jobs:
132
132
  SSH_KEY: ${{ secrets.SSH_KEY }}
133
133
  DOCKERFILE: ${{ env.DOCKERFILE_PATH }}
134
134
  run: |
135
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
135
136
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
136
137
  MISSING=""
137
138
  for NAME in DOCKERHUB_USERNAME DOCKERHUB_TOKEN SERVER_HOST SERVER_USER; do
@@ -172,6 +173,7 @@ jobs:
172
173
  # write it to a file in the resources directory
173
174
  - name: Generate application-prod.yml
174
175
  run: |
176
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
175
177
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
176
178
  mkdir -p ${{ env.APPLICATION_YML_DIR }}
177
179
 
@@ -187,10 +189,10 @@ jobs:
187
189
  run: ./gradlew clean build -x test ${{ env.GRADLE_OPTS }}
188
190
 
189
191
  - name: Set up Docker Buildx
190
- uses: docker/setup-buildx-action@v3
192
+ uses: docker/setup-buildx-action@v4
191
193
 
192
194
  - name: Log in to DockerHub
193
- uses: docker/login-action@v3
195
+ uses: docker/login-action@v4
194
196
  with:
195
197
  username: ${{ secrets.DOCKERHUB_USERNAME }}
196
198
  password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -205,7 +207,7 @@ jobs:
205
207
 
206
208
  # Use the branch name as the image tag to manage images per environment
207
209
  - name: Build and push Docker image
208
- uses: docker/build-push-action@v5
210
+ uses: docker/build-push-action@v7
209
211
  with:
210
212
  context: ${{ env.PROJECT_PATH }}
211
213
  file: ${{ env.PROJECT_PATH }}/${{ env.DOCKERFILE_PATH }}
@@ -238,6 +240,7 @@ jobs:
238
240
 
239
241
  - name: Load messages
240
242
  run: |
243
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
241
244
  MSGS="$(python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" dump wf_spring_deploy. \
242
245
  | python3 -c 'import json, shlex, sys; d = json.load(sys.stdin); print("\n".join("M_%s=%s" % (k.replace(".", "_"), shlex.quote(v)) for k, v in d.items()))' \
243
246
  | base64 | tr -d '\n')"
@@ -429,7 +432,7 @@ jobs:
429
432
  m simple_summary_branch branch="$BRANCH"
430
433
  m simple_summary_container name="$CONTAINER_NAME"
431
434
  m simple_summary_port port="$PORT"
432
- m simple_summary_time time="$(date '+%Y-%m-%d %H:%M:%S')"
435
+ m simple_summary_time time="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
433
436
  echo ""
434
437
  m simple_access_url url="http://${{ secrets.SERVER_HOST }}:${PORT}"
435
438
 
package/src/cli/args.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { VALID_TYPES, VALID_MODES } from "../context.js";
3
3
  import { DEPLOY_STYLES, isDeployStyle, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
4
4
  import { isValidBranchName } from "../core/branches.js";
5
- import { TYPES } from "../core/types.js";
5
+ import { TYPES, canonicalTypeId } from "../core/types.js";
6
6
  import { CliError } from "../core/errors.js";
7
7
  import { normalizePath, isRepoRelativePath } from "../core/paths.js";
8
8
  import { t, DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, isSupportedLanguage, normalizeLanguage } from "../i18n/index.js";
@@ -108,12 +108,15 @@ export function parseArgs(argv) {
108
108
  for (let name of csv.split(",")) {
109
109
  name = name.replace(/\s/g, "");
110
110
  if (name === "") continue;
111
- if (seen.has(name)) continue; // dedup
112
- if (!VALID_TYPES.includes(name)) {
111
+ // The alias is resolved here, before validation and dedup (--type react,next is just react).
112
+ // Only registry ids are valid, so an unsupported name is reported as typed.
113
+ const id = canonicalTypeId(name);
114
+ if (seen.has(id)) continue; // dedup
115
+ if (!VALID_TYPES.includes(id)) {
113
116
  throw new CliError(t("cli.args.typeUnsupported", { type: name, valid: VALID_TYPES.join(" ") }));
114
117
  }
115
- seen.add(name);
116
- types.push(name);
118
+ seen.add(id);
119
+ types.push(id);
117
120
  }
118
121
  if (types.length === 0) throw new CliError(t("cli.args.typeEmpty"));
119
122
  result.types = types;
@@ -193,20 +196,27 @@ export function parseArgs(argv) {
193
196
  // "flutter=app,react=client" -> Map<type, normalizedPath>. Validates types (invalid -> throw).
194
197
  export function parsePathsCsv(csv) {
195
198
  const map = new Map();
199
+ const spelled = new Map(); // type -> name as typed, to tell an alias clash from a plain repeat
196
200
  if (!csv) return map;
197
201
  for (const pair of csv.split(",")) {
198
202
  if (pair.trim() === "") continue;
199
203
  const eq = pair.indexOf("=");
200
- const type = (eq >= 0 ? pair.slice(0, eq) : pair).replace(/\s/g, "");
204
+ const typeRaw = (eq >= 0 ? pair.slice(0, eq) : pair).replace(/\s/g, "");
205
+ const type = canonicalTypeId(typeRaw);
201
206
  const rawPath = eq >= 0 ? pair.slice(eq + 1) : "";
202
207
  if (!VALID_TYPES.includes(type)) {
203
- throw new CliError(t("cli.args.pathsTypeUnsupported", { type }));
208
+ throw new CliError(t("cli.args.pathsTypeUnsupported", { type: typeRaw }));
204
209
  }
205
210
  const path = normalizePath(rawPath);
206
211
  if (!isRepoRelativePath(path)) {
207
212
  throw new CliError(t("cli.args.pathsRelativeOnly", { pair: `${type}=${rawPath.trim()}` }));
208
213
  }
214
+ // An alias and its canonical name in one list (react=a,next=b) would silently drop a folder; refuse it instead.
215
+ if (map.has(type) && spelled.get(type) !== typeRaw && map.get(type) !== path) {
216
+ throw new CliError(t("cli.args.pathsDuplicateType", { type }));
217
+ }
209
218
  map.set(type, path);
219
+ spelled.set(type, typeRaw);
210
220
  }
211
221
  return map;
212
222
  }
@@ -14,6 +14,8 @@ import { existsSync, readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { A, paint, colorEnabled, visualWidth } from "../ui/ansi.js";
16
16
  import { PATHS } from "../core/paths.js";
17
+ import { findMissingScripts } from "../core/copy/simple.js";
18
+ import { resolvePayloadRoot } from "../core/assets.js";
17
19
  import { parseExisting } from "../core/version-yml.js";
18
20
  import { hooksFor } from "../core/types.js";
19
21
  import { t } from "../i18n/index.js";
@@ -44,6 +46,15 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
44
46
  // Per-type local file checks (e.g. Flutter store deploy files): only local files are read, so this runs regardless of the gh lookup results.
45
47
  if (installed) {
46
48
  const existing = parseExisting(readFileSync(join(cwd, PATHS.versionFile), "utf8"));
49
+ const missing = findMissingScripts(resolvePayloadRoot(), cwd);
50
+ if (missing.length) {
51
+ add({
52
+ name: t("cmd.doctor.scripts.name"), label: t("cmd.doctor.scripts.label"), purpose: t("cmd.doctor.scripts.purpose"), status: "WARN",
53
+ value: t("cmd.doctor.scripts.missing", { files: missing.join(", ") }),
54
+ impact: [t("cmd.doctor.scripts.impact")],
55
+ actions: [t("cmd.doctor.scripts.action")],
56
+ });
57
+ }
47
58
  for (const { hook } of hooksFor(existing.types, "doctorChecks")) {
48
59
  for (const item of hook(cwd, existing, { docs: DOC })) add(item);
49
60
  }
@@ -179,6 +190,11 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
179
190
  return results;
180
191
  }
181
192
 
193
+ // Exit code for the CLI: 0 when every item is OK/INFO, 1 when any WARN or FAIL item was found.
194
+ // Warnings and errors are not split: both mean "something the workflows rely on needs attention",
195
+ // and a script that wants to tolerate warnings can ignore the code (`|| true`).
196
+ export const doctorExitCode = (results) => (results.some((r) => r.status === "WARN" || r.status === "FAIL") ? 1 : 0);
197
+
182
198
  const asLines = (v) => (Array.isArray(v) ? v : v ? [String(v)] : []);
183
199
  const headOf = (r) => `${r.label || r.name}${r.purpose ? ` — ${r.purpose}` : ""}`;
184
200
 
@@ -6,7 +6,7 @@ import { PATHS } from "../core/paths.js";
6
6
  import { planWorkflows } from "../core/copy/workflows.js";
7
7
  import { planTypeAppFiles } from "../core/copy/app-files.js";
8
8
  import { planUninstall } from "./uninstall.js";
9
- import { renderVersionYml, parseExisting, sameIgnoringTimestamps } from "../core/version-yml.js";
9
+ import { renderVersionYml, parseExisting, sameIgnoringTimestamps, droppedPathLines } from "../core/version-yml.js";
10
10
  import { readVersionYmlTemplate } from "../core/assets.js";
11
11
  import { existingMarkerInDir } from "../core/paths-resolve.js";
12
12
  import { planScripts } from "../core/copy/simple.js";
@@ -33,6 +33,11 @@ function versionYmlPreview(context, payloadRoot, targetRoot) {
33
33
  return { existed: existingRaw !== null, changed: existingRaw === null || !sameIgnoringTimestamps(existingRaw, wouldBe) };
34
34
  }
35
35
 
36
+ function existingDroppedLines(targetRoot, paths) {
37
+ const vyPath = join(targetRoot, PATHS.versionFile);
38
+ return existsSync(vyPath) ? droppedPathLines(parseExisting(readFileSync(vyPath, "utf8")).droppedPaths, paths) : [];
39
+ }
40
+
36
41
  // mode: "full" | "uninstall". Read-only: writes no file.
37
42
  export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
38
43
  if (mode === "uninstall") {
@@ -54,6 +59,8 @@ export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
54
59
  // Flutter store deploy files (Fastfile, ExportOptions.plist): existing files are kept, not overwritten.
55
60
  flutterApp: planTypeAppFiles(context, payloadRoot, targetRoot),
56
61
  versionYml: versionYmlPreview(context, payloadRoot, targetRoot),
62
+ // Folders the rewrite would drop from version.yml (same type listed under two names with different folders).
63
+ droppedPathLines: existingDroppedLines(targetRoot, context.paths),
57
64
  // Files the real install also changes; scripts that overwrite existing files in particular must be shown in advance.
58
65
  scripts: planScripts(payloadRoot, targetRoot),
59
66
  readme: planVersionSection(targetRoot),
@@ -130,6 +137,7 @@ export function printDryRun(plan) {
130
137
  // For types with a deploy block (spring etc.) say the preview may differ from the real install.
131
138
  lines.push(t("cmd.dryRun.versionYml.note"));
132
139
  }
140
+ if (plan.droppedPathLines?.length) lines.push(...plan.droppedPathLines.map((l) => `⚠️ ${l}`));
133
141
  if (plan.scripts) {
134
142
  const mark = { create: "+", overwrite: "~", unchanged: "=" };
135
143
  const note = {
@@ -144,6 +152,7 @@ export function printDryRun(plan) {
144
152
  "skip-no-readme": t("cmd.dryRun.readme.skipNoReadme"),
145
153
  "skip-marker": t("cmd.dryRun.readme.skipMarker"),
146
154
  "skip-version-line": t("cmd.dryRun.readme.skipVersionLine"),
155
+ "heading-updated": t("cmd.dryRun.readme.headingUpdated"),
147
156
  }[plan.readme] || t("cmd.dryRun.readme.other", { status: plan.readme }));
148
157
  }
149
158
  if (plan.baselineExists !== undefined) {
@@ -6,7 +6,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
6
6
  import { writeText, findUnwritable } from "../core/fsutil.js";
7
7
  import { CliError } from "../core/errors.js";
8
8
  import { PATHS } from "../core/paths.js";
9
- import { renderVersionYml, parseExisting, sameIgnoringTimestamps } from "../core/version-yml.js";
9
+ import { renderVersionYml, parseExisting, sameIgnoringTimestamps, droppedPathLines } from "../core/version-yml.js";
10
10
  import { readVersionYmlTemplate } from "../core/assets.js";
11
11
  import { existingMarkerInDir } from "../core/paths-resolve.js";
12
12
  import { addVersionSectionToReadme, README_STATUS_LABEL } from "../core/copy/readme.js";
@@ -70,7 +70,10 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
70
70
  // Preserve unknown top-level fields of an existing version.yml when regenerating it.
71
71
  const vyPath = join(targetRoot, PATHS.versionFile);
72
72
  const prevVy = existsSync(vyPath) ? readFileSync(vyPath, "utf8") : null;
73
- const extraTopLevel = prevVy != null ? parseExisting(prevVy).extraTopLevel : [];
73
+ const prevParsed = prevVy != null ? parseExisting(prevVy) : null;
74
+ const extraTopLevel = prevParsed ? prevParsed.extraTopLevel : [];
75
+ // Folders folded into one type entry are not managed after this rewrite; leave a trace in the install log.
76
+ for (const line of droppedPathLines(prevParsed?.droppedPaths, paths)) log.warn("version", "paths", line);
74
77
 
75
78
  // 2. Generate version.yml (render payload/version.yml.template; full regeneration)
76
79
  // A re-run where only the date lines differ does not rewrite it (idempotent).
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/assets.js";
8
8
  import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "../core/detect-fs.js";
9
- import { parseExisting } from "../core/version-yml.js";
9
+ import { parseExisting, droppedPathLines } from "../core/version-yml.js";
10
10
  import { pickReleaseOptions, resolveReleaseOptions } from "../core/release-options.js";
11
11
  import { runBreakingCheck } from "../core/breaking-check.js";
12
12
  import { resolveProjectPaths } from "../core/paths-resolve.js";
@@ -294,6 +294,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
294
294
  for (const ty of types) if (ty !== "basic" && !paths.has(ty)) paths.set(ty, existing?.paths.get(ty) || ".");
295
295
  }
296
296
 
297
+ // Announce merged folders only now: the message must follow the folders actually chosen, not the saved ones.
298
+ if (existing?.droppedPaths?.length) io.note?.(droppedPathLines(existing.droppedPaths, paths).join("\n"), t("core.versionYml.pathMergedTitle"));
299
+
297
300
  // In a monorepo the version/build-number files live inside the type folder - detect there now that paths are settled.
298
301
  if (versionAutoDetected && [...paths.values()].some((p) => p && p !== ".")) {
299
302
  version = detectVersion(cwd, { types, paths, warn: () => {} });
@@ -1,11 +1,12 @@
1
1
  // status command: read-only install state check. No network access (local file comparison only).
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { parseExisting } from "../core/version-yml.js";
4
+ import { parseExisting, droppedPathLines } from "../core/version-yml.js";
5
5
  import { planWorkflows } from "../core/copy/workflows.js";
6
6
  import { makeResolvers, detectRepoName, detectDefaultBranch } from "../core/detect-fs.js";
7
7
  import { PATHS } from "../core/paths.js";
8
8
  import { isDeployStyle, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
9
+ import { findMissingScripts } from "../core/copy/simple.js";
9
10
  import { findStaleWorkflows } from "../core/removal-plan.js";
10
11
  import { readBaseline } from "../core/baseline.js";
11
12
  import { hooksFor, mergeHookResults } from "../core/types.js";
@@ -56,6 +57,9 @@ export function runStatus(payloadRoot, targetRoot = ".") {
56
57
  removed: plan.removed.map((f) => f.filename), // deleted by the user and not restored
57
58
  },
58
59
  staleFiles: stale,
60
+ // Installed scripts the workflows need but the repo no longer has; the workflow comparison above cannot see them.
61
+ missingScripts: findMissingScripts(payloadRoot, targetRoot),
62
+ droppedPaths: existing.droppedPaths,
59
63
  };
60
64
  }
61
65
 
@@ -90,6 +94,15 @@ export function printStatus(status) {
90
94
  for (const f of status.staleFiles) lines.push(` - ${f}`);
91
95
  }
92
96
 
97
+ if (status.missingScripts?.length) {
98
+ lines.push("", t("cmd.status.missingScripts", { n: status.missingScripts.length, dir: PATHS.scriptsDir }));
99
+ for (const f of status.missingScripts) lines.push(` - ${f}`);
100
+ lines.push(t("cmd.status.missingScriptsFix"));
101
+ }
102
+ if (status.droppedPaths?.length) {
103
+ lines.push("", ...droppedPathLines(status.droppedPaths).map((l) => `⚠️ ${l}`));
104
+ }
105
+
93
106
  // What an update would do. Installs without a baseline are all zeros, so nothing is printed.
94
107
  const b = status.buckets || { autoUpdatable: [], localKept: [], conflicts: [], removed: [] };
95
108
  if (b.autoUpdatable.length || b.localKept.length || b.conflicts.length || b.removed.length) {
@@ -15,15 +15,66 @@ export const README_STATUS_LABEL = {
15
15
  get "skip-no-readme"() { return t("copy.readme.status.skipNoReadme"); },
16
16
  get "skip-marker"() { return t("copy.readme.status.skipMarker"); },
17
17
  get "skip-version-line"() { return t("copy.readme.status.skipVersionLine"); },
18
+ get "heading-updated"() { return t("copy.readme.status.headingUpdated"); },
18
19
  };
19
20
 
21
+ // Bundled default heading text ("Latest Version", ...) without the "## " prefix and version part, per language.
22
+ const defaultHeading = (lang) => t("copy.readme.versionHeading", { version: "0" }, lang).replace(/^##\s*/, "").replace(/\s*:\s*v0$/, "");
23
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
+
25
+ // Text of the history link line in a given language (without the trailing newline).
26
+ const historyLink = (lang) => t("copy.readme.historyLink", {}, lang);
27
+
28
+ // Rewrites the wizard-made block (marker line, heading line, blank line, history link line) that was written
29
+ // in another language so it matches the current one. Only text that exactly equals another language's bundled
30
+ // default is replaced: a heading or link the user edited is left alone, and so is anything not directly
31
+ // under the marker (code-block examples, a README without the marker).
32
+ // Returns the new content, or null when nothing needs to change.
33
+ function refreshBlockLanguage(content) {
34
+ const currentHeading = defaultHeading();
35
+ const currentLink = historyLink();
36
+ const markerRe = new RegExp("^" + escapeRe(MARKER_LINE.trimEnd()) + "[ \\t]*\\r?\\n", "gm");
37
+ let out = "";
38
+ let last = 0;
39
+ let changed = false;
40
+ for (let m = markerRe.exec(content); m; m = markerRe.exec(content)) {
41
+ // An odd number of fence lines before the marker means it sits inside a code block (an example).
42
+ if ((content.slice(0, m.index).match(/^[ \t]*(```|~~~)/gm) || []).length % 2 === 1) continue;
43
+ const start = m.index + m[0].length;
44
+ let rest = content.slice(start);
45
+ let head = "";
46
+ // Heading line directly under the marker.
47
+ for (const lang of SUPPORTED_LANGUAGES) {
48
+ const heading = defaultHeading(lang);
49
+ if (heading === currentHeading) continue;
50
+ const hm = new RegExp("^## " + escapeRe(heading) + " : (?=v[0-9])").exec(rest);
51
+ if (hm) { head = "## " + currentHeading + " : "; rest = rest.slice(hm[0].length); changed = true; break; }
52
+ }
53
+ out += content.slice(last, start) + head;
54
+ last = start + (content.slice(start).length - rest.length);
55
+ // History link line: heading line, one blank line, then the link.
56
+ const lm = /^([^\r\n]*\r?\n\r?\n)([^\r\n]*)/.exec(rest);
57
+ if (lm) {
58
+ const linkStart = last + lm[1].length;
59
+ const isStale = SUPPORTED_LANGUAGES.some((lang) => historyLink(lang) !== currentLink && lm[2] === historyLink(lang));
60
+ if (isStale) {
61
+ out += content.slice(last, linkStart) + currentLink;
62
+ last = linkStart + lm[2].length;
63
+ changed = true;
64
+ }
65
+ }
66
+ }
67
+ return changed ? out + content.slice(last) : null;
68
+ }
69
+
20
70
  // Skip when README.md is missing, or when a marker or version line exists. Otherwise append to the end.
21
- // Returns: 'skip-no-readme' | 'skip-marker' | 'skip-version-line' | 'added'
71
+ // Returns: 'skip-no-readme' | 'heading-updated' | 'skip-marker' | 'skip-version-line' | 'added'
22
72
  // Only decides without writing, so the real append and the --dry-run preview share one decision.
23
73
  export function planVersionSection(targetRoot = ".") {
24
74
  const p = join(targetRoot, "README.md");
25
75
  if (!existsSync(p)) return "skip-no-readme";
26
76
  const content = readFileSync(p, "utf8");
77
+ if (refreshBlockLanguage(content) !== null) return "heading-updated";
27
78
  if (content.includes(MARKER)) return "skip-marker";
28
79
  if (VERSION_LINE_RE.test(content)) return "skip-version-line";
29
80
  return "added";
@@ -31,8 +82,13 @@ export function planVersionSection(targetRoot = ".") {
31
82
 
32
83
  export function addVersionSectionToReadme(version, targetRoot = ".") {
33
84
  const status = planVersionSection(targetRoot);
34
- if (status !== "added") return status;
35
85
  const p = join(targetRoot, "README.md");
86
+ if (status === "heading-updated") {
87
+ // Swap only the bundled default wording; version text after the colon stays as the workflow wrote it.
88
+ writeFileSync(p, refreshBlockLanguage(readFileSync(p, "utf8")));
89
+ return status;
90
+ }
91
+ if (status !== "added") return status;
36
92
  const content = readFileSync(p, "utf8");
37
93
 
38
94
  // The appended body starts with "\n---\n..." to leave a blank line between the existing text and the rule.
@@ -24,6 +24,37 @@ export function planScripts(payloadRoot, targetRoot = ".") {
24
24
  return out;
25
25
  }
26
26
 
27
+ // Script -> bundled scripts it imports (`import x` / `from x import ...`), read from the payload so the
28
+ // dependency table cannot drift from the code. Only names in SCRIPT_NAMES count; stdlib imports are ignored.
29
+ function scriptImports(payloadRoot, name) {
30
+ let src = "";
31
+ try { src = readFileSync(join(payloadRoot, PAYLOAD.scriptsDir, name), "utf8"); } catch { return []; }
32
+ const mods = new Set();
33
+ for (const m of src.matchAll(/^[ \t]*(?:from[ \t]+(\w+)[ \t]+import\b|import[ \t]+([\w, \t]+))/gm)) {
34
+ if (m[1]) mods.add(m[1]);
35
+ else for (const x of m[2].split(",")) mods.add(x.trim().split(/\s+/)[0]);
36
+ }
37
+ return SCRIPT_NAMES.filter((n) => n !== name && mods.has(n.replace(/\.py$/, "")));
38
+ }
39
+
40
+ // Bundled scripts the installed workflows call but the repo no longer has (deleted by hand, never copied).
41
+ // Only scripts an installed workflow actually references count: some (truncate_release_notes.py) belong to
42
+ // workflows of one type only. Scripts those import (messages.py, issue_helper.py, ...) are required too,
43
+ // transitively, since the called script fails at import time without them.
44
+ export function findMissingScripts(payloadRoot, targetRoot = ".") {
45
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
46
+ let text = "";
47
+ try {
48
+ for (const f of readdirSync(wfDir)) if (/\.ya?ml$/.test(f)) text += `${readFileSync(join(wfDir, f), "utf8")}\n`;
49
+ } catch { return []; }
50
+ const required = new Set(SCRIPT_NAMES.filter((n) => text.includes(n)));
51
+ const queue = [...required];
52
+ while (queue.length) {
53
+ for (const dep of scriptImports(payloadRoot, queue.pop())) if (!required.has(dep)) { required.add(dep); queue.push(dep); }
54
+ }
55
+ return SCRIPT_NAMES.filter((n) => required.has(n) && exists(join(payloadRoot, PAYLOAD.scriptsDir, n)) && !exists(join(targetRoot, PATHS.scriptsDir, n)));
56
+ }
57
+
27
58
  // Scripts are always overwritten with the payload version (+chmod, meaningless but harmless on Windows).
28
59
  // User-edited scripts are overwritten too, so per-file results are returned for the caller to log.
29
60
  export function copyScripts(payloadRoot, targetRoot = ".") {
@@ -48,7 +48,7 @@ export function readSavedDeployValues(targetRoot = ".") {
48
48
  // and status/dry-run never see different file sets. When flutterStore is not an array (null = undecided,
49
49
  // non-interactive default) no store filter is applied. It always returns a function even without filters,
50
50
  // because processDir cannot take null.
51
- // Some types (go/python, react/next) keep server deploy workflows directly in the type root, so the deploy style
51
+ // Some types (go, python, react) keep server deploy workflows directly in the type root, so the deploy style
52
52
  // filter is always applied. `available` (set of payload file names) lets a type without the chosen style fall back
53
53
  // to the single server deploy.
54
54
  export function buildTypeRootFilter(type, deployStyle, flutterStore, available = null) {
@@ -64,7 +64,7 @@ export function buildTypeRootFilter(type, deployStyle, flutterStore, available =
64
64
  export function typeWorkflowSources(type, payloadRoot, { deployStyle, flutterStore = null, available = null }) {
65
65
  const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
66
66
  const sources = [];
67
- // Some types (go/python, react/next) keep server deploy workflows directly in the type root, so filter the root by deploy style too.
67
+ // Some types (go, python, react) keep server deploy workflows directly in the type root, so filter the root by deploy style too.
68
68
  if (exists(typeDir)) sources.push({ srcDir: typeDir, type, filter: buildTypeRootFilter(type, deployStyle, flutterStore, available) });
69
69
  // "No deploy" excludes the whole folder
70
70
  if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
@@ -31,7 +31,7 @@ export const isDeployWorkflow = (filename) => DEPLOY_STYLES.some((s) => filename
31
31
 
32
32
  const PREVIEW_SUFFIX = "-PR-PREVIEW.yaml";
33
33
 
34
- // All workflows that deploy to a server: CD bodies, single CDs (react, next) and PR preview.
34
+ // All workflows that deploy to a server: CD bodies, the single CD (react) and PR preview.
35
35
  // "No deploy" removes all of them identically for every type. Removing only some would leave server Secret requirements behind.
36
36
  export const isServerDeployWorkflow = (filename) =>
37
37
  isDeployWorkflow(filename) || SINGLE_SERVER_CD_FILES.has(filename) || filename.endsWith(PREVIEW_SUFFIX);
@@ -5,7 +5,7 @@ import {
5
5
 
6
6
  // package.json classification: compares dependency "keys" exactly. A raw substring match would misdetect an
7
7
  // export script or exponential-backoff as expo, a web app using react-native-web as react-native, and
8
- // "next" in keywords as next. Input is the raw package.json string. Evaluation order is the registry's detectOrder.
8
+ // "react" in keywords as react. Input is the raw package.json string. Evaluation order is the registry's detectOrder.
9
9
  export function classifyPackageText(raw) {
10
10
  let pkg;
11
11
  try { pkg = JSON.parse(String(raw || "")); } catch { return PACKAGE_FALLBACK_TYPE; }
@@ -15,7 +15,8 @@ export function classifyPackageText(raw) {
15
15
  const d = pkg[field];
16
16
  if (d && typeof d === "object") for (const k of Object.keys(d)) deps.add(k);
17
17
  }
18
- return PACKAGE_DETECTED_TYPES.find((t) => deps.has(t.packageDep))?.id ?? PACKAGE_FALLBACK_TYPE;
18
+ const hasDep = (t) => [t.packageDep, ...(t.extraPackageDeps || [])].some((k) => deps.has(k));
19
+ return PACKAGE_DETECTED_TYPES.find(hasDep)?.id ?? PACKAGE_FALLBACK_TYPE;
19
20
  }
20
21
 
21
22
  // Convenience: when given a parsed object, re-serialize it and apply the rule above.
package/src/core/types.js CHANGED
@@ -11,6 +11,8 @@ import { flutterHooks } from "./flutter-hooks.js";
11
11
  // "package": detected by a package.json dependency key (packageDep)
12
12
  // "package-fallback": package.json exists but none of the above matched
13
13
  // absent: not auto-detected (basic is the result when nothing was detected).
14
+ // extraPackageDeps Other dependency keys that also classify a project as this type (e.g. next -> react).
15
+ // displayName Human-readable name shown next to the id in interactive choices (optional).
14
16
  // detectOrder Evaluation order within the same detectBy. The earlier rule wins (an expo app also has a react-native dependency).
15
17
  // versionSources File keys to read the version from at install (sources in detect.js); read first in this order for the primary type.
16
18
  // buildNumberSource File key to read the mobile build number from (detectBuildNumberFromFiles in detect.js).
@@ -51,16 +53,11 @@ export const TYPES = [
51
53
  hooks: flutterHooks,
52
54
  },
53
55
  {
54
- id: "next",
55
- markers: ["package.json"],
56
- detectBy: "package", packageDep: "next", detectOrder: 3,
57
- versionSources: ["packageJson"],
58
- singleServerCd: "PROJECT-NEXT-CICD.yaml",
59
- },
60
- {
56
+ // Next.js projects are React projects: same package.json version handling and the same CI/CD workflows.
61
57
  id: "react",
58
+ displayName: "React / Next.js",
62
59
  markers: ["package.json"],
63
- detectBy: "package", packageDep: "react", detectOrder: 4,
60
+ detectBy: "package", packageDep: "react", extraPackageDeps: ["next"], detectOrder: 3,
64
61
  versionSources: ["packageJson"],
65
62
  singleServerCd: "PROJECT-REACT-CICD.yaml",
66
63
  },
@@ -109,6 +106,16 @@ export const TYPES = [
109
106
  // Type used when nothing was detected
110
107
  export const FALLBACK_TYPE = "basic";
111
108
 
109
+ // Names that were once separate types and are now accepted as an alias of another one.
110
+ // Every input that can carry a type name (--type, --paths, version.yml) goes through canonicalTypeId(),
111
+ // so the rest of the code only ever sees registry ids and version.yml is rewritten with the canonical name.
112
+ export const TYPE_ALIASES = Object.freeze({ next: "react" });
113
+
114
+ export const canonicalTypeId = (name) => TYPE_ALIASES[name] ?? name;
115
+
116
+ // Canonical ids of a type list with duplicates removed (react,next -> react), keeping the first occurrence's position.
117
+ export const canonicalTypeIds = (names) => [...new Set(names.map(canonicalTypeId))];
118
+
112
119
  const BY_ID = new Map(TYPES.map((t) => [t.id, t]));
113
120
 
114
121
  // Unknown types give undefined; callers decide their own defaults.
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_DEPLOY_STYLE } from "./deploy-style.js";
2
2
  import { escapeYamlDoubleQuoted } from "./wizard-env.js";
3
- import { hooksFor, mergeHookResults, allHookValues } from "./types.js";
3
+ import { hooksFor, mergeHookResults, allHookValues, canonicalTypeId, canonicalTypeIds } from "./types.js";
4
+ import { normalizePath } from "./paths.js";
4
5
  import { DEFAULT_LANGUAGE, isSupportedLanguage, normalizeLanguage } from "../i18n/languages.js";
5
6
  // Aliased: `t` is used as a local variable name (type, trimmed line) throughout this file.
6
7
  import { t as tr } from "../i18n/index.js";
@@ -88,6 +89,21 @@ export function parseTemplateOptions(content) {
88
89
  return out;
89
90
  }
90
91
 
92
+ // User-facing lines for entries parseExisting() folded away: what is kept, what drops out, and how to keep the other folder.
93
+ // finalPaths (Map<type, folder>) is the folder set the run will actually write; without it the saved winner is assumed.
94
+ // The message follows the real outcome, so an explicit --paths choice is never told the opposite.
95
+ export function droppedPathLines(droppedPaths = [], finalPaths = null) {
96
+ return droppedPaths.flatMap((d) => {
97
+ const final = finalPaths?.get(d.type) ?? d.kept;
98
+ const same = (x) => normalizePath(x) === normalizePath(final);
99
+ const lost = same(d.kept) ? d.path : same(d.path) ? d.kept : `${d.kept}, ${d.path}`;
100
+ const lines = [tr("core.versionYml.pathMerged", { keptName: d.keptName, kept: d.kept, name: d.name, path: d.path, type: d.type, final, lost })];
101
+ // The re-run hint only makes sense when the saved winner is what stays.
102
+ if (same(d.kept)) lines.push(tr("core.versionYml.pathMergedHint", { type: d.type, path: d.path }));
103
+ return lines;
104
+ });
105
+ }
106
+
91
107
  // Extract values from an existing version.yml (line-based, avoids false hits on comment lines).
92
108
  export function parseExisting(content) {
93
109
  const text = String(content || "");
@@ -107,21 +123,45 @@ export function parseExisting(content) {
107
123
  // project_types: ["a","b"]
108
124
  const typesRaw = line(/^project_types:\s*(\[[^\]]*\])/);
109
125
  let types = [];
110
- if (typesRaw) types = [...typesRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
126
+ // Old names (next) are read as their current type so every later step, including the rewrite, only sees canonical ids.
127
+ if (typesRaw) types = canonicalTypeIds([...typesRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]));
111
128
  // language: "en" - only supported values count; a hand-edited unknown value is treated as unset
112
129
  const langRaw = normalizeLanguage((line(/^language:\s*(.+)/) || "").replace(/\s+#.*$/, "").replace(/["']/g, ""));
113
130
  const language = isSupportedLanguage(langRaw) ? langRaw : null;
114
131
  // project_paths block: ` type: "path"`
115
132
  const paths = new Map();
133
+ // Entries folded into another entry of the same canonical type with a different folder (react: client + next: web).
134
+ // A type holds one folder, so these are lost on rewrite; callers surface them instead of dropping them silently.
135
+ const droppedPaths = [];
136
+ const pathNames = new Map(); // canonical id -> the name the kept folder was written under
116
137
  let inPaths = false;
117
138
  for (const l of text.split("\n")) {
118
139
  if (/^project_paths:/.test(l)) { inPaths = true; continue; }
119
140
  if (inPaths) {
120
141
  const m = l.match(/^\s+([a-z-]+):\s*"([^"]*)"/);
121
- if (m) paths.set(m[1], m[2]);
142
+ // The first entry wins when an old name and its current name are both present.
143
+ if (m) {
144
+ const id = canonicalTypeId(m[1]);
145
+ if (!paths.has(id)) { paths.set(id, m[2]); pathNames.set(id, m[1]); }
146
+ else if (normalizePath(paths.get(id)) !== normalizePath(m[2])) droppedPaths.push({ type: id, keptName: pathNames.get(id), kept: paths.get(id), name: m[1], path: m[2] });
147
+ }
122
148
  else if (/^\S/.test(l)) inPaths = false; // end of indentation -> end of block
123
149
  }
124
150
  }
151
+ // A name listed in project_types without a project_paths entry lives at the repo root, so when it shares a
152
+ // canonical type with a name that has a folder, the root is the folder that drops out.
153
+ if (typesRaw) {
154
+ const names = [...new Set([...typesRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]))];
155
+ const written = new Set(text.split("\n").map((l) => l.match(/^\s+([a-z-]+):\s*"/)?.[1]).filter(Boolean));
156
+ for (const id of new Set(names.map(canonicalTypeId))) {
157
+ const group = names.filter((n) => canonicalTypeId(n) === id);
158
+ if (group.length < 2 || !paths.has(id)) continue;
159
+ for (const n of group) {
160
+ if (written.has(n) || normalizePath(paths.get(id)) === ".") continue;
161
+ droppedPaths.push({ type: id, keptName: pathNames.get(id), kept: paths.get(id), name: n, path: "." });
162
+ }
163
+ }
164
+ }
125
165
  // version inside the template: block
126
166
  let templateVersion = "";
127
167
  let inTemplate = false;
@@ -138,7 +178,7 @@ export function parseExisting(content) {
138
178
  // metadata.template.branches - main/develop/mode (to skip re-asking in update mode)
139
179
  const branches = parseTemplateBranches(text);
140
180
  return {
141
- version, versionCode, types, language, paths, templateVersion, options, branches,
181
+ version, versionCode, types, language, paths, droppedPaths, templateVersion, options, branches,
142
182
  deploy: parseDeployBlock(text), extraTopLevel: parseExtraTopLevel(text),
143
183
  };
144
184
  }
@@ -157,9 +197,15 @@ export function parseDeployBlock(content) {
157
197
  if (!inDeploy) continue;
158
198
  if (/^\S/.test(l)) { inDeploy = false; continue; } // next top-level key -> end of block
159
199
  const t = l.match(/^ {2}([a-z][a-z-]*):\s*(?:#.*)?$/);
160
- if (t) { current = new Map(); out.set(t[1], current); continue; }
200
+ if (t) {
201
+ // An old type name shares the block of its current name; keys already present there win.
202
+ const id = canonicalTypeId(t[1]);
203
+ current = out.get(id) ?? new Map();
204
+ out.set(id, current);
205
+ continue;
206
+ }
161
207
  const kv = l.match(/^ {4}([A-Za-z_][A-Za-z0-9_]*):\s*"((?:[^"\\]|\\.)*)"/);
162
- if (kv && current) current.set(kv[1], kv[2].replace(/\\(["\\])/g, "$1"));
208
+ if (kv && current && !current.has(kv[1])) current.set(kv[1], kv[2].replace(/\\(["\\])/g, "$1"));
163
209
  }
164
210
  return out;
165
211
  }
@@ -20,6 +20,7 @@ export default {
20
20
  "cli.args.unknownOption": "Unknown option: {option}",
21
21
  "cli.args.modeUnsupported": "Unsupported mode: '{mode}'\nSupported modes: interactive full uninstall status doctor",
22
22
  "cli.args.pathsTypeUnsupported": "Unsupported type in --paths: '{type}'",
23
+ "cli.args.pathsDuplicateType": "--paths lists '{type}' twice with different folders (next is the old name of react); keep one entry",
23
24
  "cli.args.pathsRelativeOnly": "--paths accepts only paths relative to the repo: '{pair}'",
24
25
  "cli.index.purgePrompt": "To run purge, type this repo name exactly: {repoName}\n> ",
25
26
  "cli.index.dryRunNeedsMode": "Use --dry-run together with --mode <full|uninstall> (it is not supported in interactive mode).",