project-auto-wizard 0.1.5

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/bin/project-auto-wizard.js +19 -0
  4. package/package.json +36 -0
  5. package/payload/coderabbit.yaml +19 -0
  6. package/payload/config/breaking-changes.json +1 -0
  7. package/payload/config/wizard-prompts.yml +62 -0
  8. package/payload/scripts/changelog_manager.py +726 -0
  9. package/payload/scripts/version_manager.py +617 -0
  10. package/payload/version.yml.template +48 -0
  11. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +303 -0
  12. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +293 -0
  13. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +289 -0
  14. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +192 -0
  15. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +209 -0
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +591 -0
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +700 -0
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +308 -0
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +992 -0
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +689 -0
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +987 -0
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +471 -0
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +500 -0
  24. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +185 -0
  25. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +271 -0
  26. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +81 -0
  27. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +2191 -0
  28. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +386 -0
  29. package/payload/workflows/react/PROJECT-REACT-CI.yaml +194 -0
  30. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +255 -0
  31. package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +84 -0
  32. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +316 -0
  33. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +58 -0
  34. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +535 -0
  35. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +437 -0
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +2277 -0
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +425 -0
  38. package/src/cli/args.js +95 -0
  39. package/src/cli/help.js +27 -0
  40. package/src/commands/full.js +53 -0
  41. package/src/commands/interactive.js +276 -0
  42. package/src/commands/revert.js +60 -0
  43. package/src/commands/version.js +31 -0
  44. package/src/commands/workflows.js +49 -0
  45. package/src/context.js +26 -0
  46. package/src/core/assets.js +47 -0
  47. package/src/core/branches.js +60 -0
  48. package/src/core/branding.js +15 -0
  49. package/src/core/breaking-check.js +65 -0
  50. package/src/core/breaking.js +26 -0
  51. package/src/core/copy/coderabbit.js +26 -0
  52. package/src/core/copy/gitignore.js +55 -0
  53. package/src/core/copy/readme.js +30 -0
  54. package/src/core/copy/simple.js +23 -0
  55. package/src/core/copy/workflows.js +233 -0
  56. package/src/core/detect-fs.js +106 -0
  57. package/src/core/detect.js +62 -0
  58. package/src/core/fsutil.js +46 -0
  59. package/src/core/options-ask.js +99 -0
  60. package/src/core/paths-resolve.js +261 -0
  61. package/src/core/paths.js +16 -0
  62. package/src/core/version-yml.js +190 -0
  63. package/src/core/wizard-env.js +101 -0
  64. package/src/core/wizard-labels.js +107 -0
  65. package/src/index.js +162 -0
  66. package/src/ui/ansi.js +26 -0
  67. package/src/ui/banner.js +29 -0
  68. package/src/ui/env-plan.js +207 -0
  69. package/src/ui/prompts.js +99 -0
  70. package/src/ui/readline-engine.js +257 -0
  71. package/src/ui/status-cards.js +56 -0
  72. package/src/ui/summary.js +127 -0
@@ -0,0 +1,617 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ version_manager.py — general-purpose version management script (stdlib only).
4
+
5
+ This script is copied into user repos (.github/scripts/) by project-auto-wizard
6
+ and runs standalone on GitHub Actions ubuntu runners (python3, no third-party deps).
7
+
8
+ It is a Python rewrite of the battle-tested bash version_manager.sh from
9
+ SUH-DEVOPS-TEMPLATE. Behavioral equivalence with that script is the design goal:
10
+ - version.yml is the single source of truth for `version` and `version_code`.
11
+ - version.yml is edited via line-based regex replacements that preserve all
12
+ comments and formatting (never rewritten wholesale, never parsed with a YAML lib).
13
+ - Versions are synced out to type-specific project files (build.gradle,
14
+ pubspec.yaml, package.json, pyproject.toml, Info.plist, app.json, ...).
15
+
16
+ Usage:
17
+ version_manager.py get # current version (synced)
18
+ version_manager.py get-code # current version_code
19
+ version_manager.py increment # patch+1, sync, bump version_code
20
+ version_manager.py increment-code # version_code+1 only
21
+ version_manager.py set X.Y.Z # set version explicitly, sync
22
+ version_manager.py sync # sync version.yml <-> project files
23
+
24
+ Contract:
25
+ - The LAST line printed to stdout is always the value (callers do `| tail -n 1`).
26
+ - Exit 0 on success; exit 1 on validation failure or missing version.yml.
27
+ """
28
+
29
+ import argparse
30
+ import datetime
31
+ import json
32
+ import os
33
+ import re
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ VERSION_YML = "version.yml"
38
+ SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
39
+
40
+
41
+ def log(message):
42
+ """Non-value logging output, written to stderr (mirroring the bash
43
+ script's log_* helpers). stdout is reserved for the value contract:
44
+ its last line is always the command's result."""
45
+ print(message, file=sys.stderr)
46
+
47
+
48
+ # ===================================================================
49
+ # Newline-preserving file I/O
50
+ # ===================================================================
51
+
52
+ def _detect_eol(raw):
53
+ """Return the dominant line ending of raw text ("\r\n" or "\n")."""
54
+ crlf = raw.count("\r\n")
55
+ lf = raw.count("\n") - crlf
56
+ return "\r\n" if crlf > lf else "\n"
57
+
58
+
59
+ def read_file(path):
60
+ """Read a text file with newlines normalized to \\n for regex processing.
61
+ The original dominant line ending is re-applied by write_file()."""
62
+ with open(path, "r", encoding="utf-8", newline="") as f:
63
+ return f.read().replace("\r\n", "\n")
64
+
65
+
66
+ def write_file(path, text):
67
+ """Write text preserving the dominant line ending of the existing file
68
+ on disk (LF stays LF, CRLF stays CRLF — never platform-dependent)."""
69
+ p = Path(path)
70
+ eol = "\n"
71
+ if p.is_file():
72
+ with open(p, "r", encoding="utf-8", newline="") as f:
73
+ eol = _detect_eol(f.read())
74
+ if eol != "\n":
75
+ text = text.replace("\n", eol)
76
+ with open(p, "w", encoding="utf-8", newline="") as f:
77
+ f.write(text)
78
+
79
+
80
+ # ===================================================================
81
+ # version.yml line-based read/write helpers
82
+ # ===================================================================
83
+
84
+ def _version_yml_path():
85
+ return Path(VERSION_YML)
86
+
87
+
88
+ def require_version_yml():
89
+ if not _version_yml_path().is_file():
90
+ log("ERROR: version.yml not found")
91
+ sys.exit(1)
92
+
93
+
94
+ def read_text():
95
+ return read_file(_version_yml_path())
96
+
97
+
98
+ def write_text(text):
99
+ write_file(_version_yml_path(), text)
100
+
101
+
102
+ def read_scalar_key(key, default=None):
103
+ """Read a simple top-level `key: "value"` or `key: value` line.
104
+ Trailing `# comment` (unquoted values only) is stripped."""
105
+ text = read_text()
106
+ m = re.search(
107
+ r'^' + re.escape(key) + r':[ \t]*(.*)$',
108
+ text,
109
+ re.MULTILINE,
110
+ )
111
+ if not m:
112
+ return default
113
+ raw = m.group(1).strip()
114
+ if raw.startswith('"'):
115
+ qm = re.match(r'"([^"]*)"', raw)
116
+ val = qm.group(1) if qm else raw.strip('"')
117
+ else:
118
+ # unquoted scalar: strip trailing comment
119
+ val = raw.split("#", 1)[0].strip()
120
+ return val if val != "" else default
121
+
122
+
123
+ def write_scalar_key(key, value, quote=True):
124
+ """Replace a top-level `key: ...` line's value, preserving everything else.
125
+ If the key doesn't exist, does nothing (mirrors bash's yq behavior of only
126
+ updating existing keys for metadata fields)."""
127
+ text = read_text()
128
+ pattern = re.compile(r'^(' + re.escape(key) + r':)[ \t]*.*$', re.MULTILINE)
129
+ if not pattern.search(text):
130
+ return False
131
+ if quote:
132
+ replacement = r'\1 "' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
133
+ else:
134
+ replacement = r'\1 ' + str(value)
135
+ new_text = pattern.sub(replacement, text, count=1)
136
+ write_text(new_text)
137
+ return True
138
+
139
+
140
+ def key_exists(key):
141
+ text = read_text()
142
+ return re.search(r'^' + re.escape(key) + r':', text, re.MULTILINE) is not None
143
+
144
+
145
+ def get_current_version():
146
+ return read_scalar_key("version", "0.0.0")
147
+
148
+
149
+ def get_project_type():
150
+ return read_scalar_key("project_type", "basic")
151
+
152
+
153
+ def get_project_types_csv():
154
+ """Return project_types as a list. Supports both:
155
+ project_types: ["a", "b"]
156
+ project_types:
157
+ - "a"
158
+ - "b"
159
+ Returns [] if key absent (legacy single-type mode)."""
160
+ text = read_text()
161
+
162
+ # Inline array form: project_types: ["a", "b"]
163
+ m = re.search(r'^project_types:[ \t]*\[(.*?)\][ \t]*$', text, re.MULTILINE)
164
+ if m:
165
+ inner = m.group(1)
166
+ items = re.findall(r'"([^"]*)"|\'([^\']*)\'', inner)
167
+ types = [a or b for a, b in items]
168
+ return [t for t in types if t]
169
+
170
+ # Block list form:
171
+ # project_types:
172
+ # - "a"
173
+ # - "b"
174
+ m = re.search(r'^project_types:[ \t]*\n((?:[ \t]+-[ \t]*.*\n?)+)', text, re.MULTILINE)
175
+ if m:
176
+ block = m.group(1)
177
+ types = re.findall(r'-\s*["\']?([^"\'\n]+?)["\']?\s*$', block, re.MULTILINE)
178
+ return [t.strip() for t in types if t.strip()]
179
+
180
+ return []
181
+
182
+
183
+ def get_type_path(project_type, project_types_list=None):
184
+ """Return project_paths.<type> if set, else '.' (repo root)."""
185
+ text = read_text()
186
+ m = re.search(r'^project_paths:[ \t]*\n((?:[ \t]+.+\n?)+)', text, re.MULTILINE)
187
+ if not m:
188
+ return "."
189
+ block = m.group(1)
190
+ km = re.search(
191
+ r'^[ \t]+["\']?' + re.escape(project_type) + r'["\']?:[ \t]*["\']?([^"\'\n]+?)["\']?[ \t]*$',
192
+ block,
193
+ re.MULTILINE,
194
+ )
195
+ if km:
196
+ val = km.group(1).strip()
197
+ if val and val != "null":
198
+ return val
199
+ return "."
200
+
201
+
202
+ def get_version_code():
203
+ require_version_yml()
204
+ code = read_scalar_key("version_code", None)
205
+ if code is None or code == "" or code == "null":
206
+ log("WARNING: version_code field missing, adding default value 1")
207
+ text = read_text()
208
+ if re.search(r'^version:', text, re.MULTILINE):
209
+ new_text = re.sub(
210
+ r'^(version:[^\n]*\n)',
211
+ r'\1version_code: 1 # app build number\n',
212
+ text,
213
+ count=1,
214
+ flags=re.MULTILINE,
215
+ )
216
+ else:
217
+ new_text = text.rstrip("\n") + '\nversion_code: 1 # app build number\n'
218
+ write_text(new_text)
219
+ return "1"
220
+ return code.strip()
221
+
222
+
223
+ def set_version_code(new_code):
224
+ text = read_text()
225
+ pattern = re.compile(r'^version_code:[ \t]*.*$', re.MULTILINE)
226
+ replacement = f'version_code: {new_code} # app build number'
227
+ if pattern.search(text):
228
+ new_text = pattern.sub(replacement, text, count=1)
229
+ else:
230
+ new_text = re.sub(
231
+ r'^(version:[^\n]*\n)',
232
+ r'\1' + replacement + '\n',
233
+ text,
234
+ count=1,
235
+ flags=re.MULTILINE,
236
+ )
237
+ write_text(new_text)
238
+
239
+
240
+ def validate_version(version):
241
+ return bool(version) and SEMVER_RE.match(version) is not None
242
+
243
+
244
+ def increment_patch(version):
245
+ major, minor, patch = version.split(".")
246
+ return f"{major}.{minor}.{int(patch) + 1}"
247
+
248
+
249
+ def compare_versions(v1, v2):
250
+ """Return 1 if v1>v2, -1 if v1<v2, 0 if equal."""
251
+ p1 = [int(x) for x in v1.split(".")]
252
+ p2 = [int(x) for x in v2.split(".")]
253
+ for a, b in zip(p1, p2):
254
+ if a > b:
255
+ return 1
256
+ if a < b:
257
+ return -1
258
+ return 0
259
+
260
+
261
+ def get_higher_version(v1, v2):
262
+ return v1 if compare_versions(v1, v2) >= 0 else v2
263
+
264
+
265
+ def update_version_yml(new_version):
266
+ write_scalar_key("version", new_version)
267
+ today = datetime.date.today().isoformat()
268
+ user = os.environ.get("GITHUB_ACTOR", "")
269
+ if not user:
270
+ try:
271
+ import getpass
272
+ user = getpass.getuser()
273
+ except Exception:
274
+ user = "unknown"
275
+ if re.search(r'^\s+last_updated:', read_text(), re.MULTILINE):
276
+ _write_nested_scalar("last_updated", today)
277
+ if re.search(r'^\s+last_updated_by:', read_text(), re.MULTILINE):
278
+ _write_nested_scalar("last_updated_by", user)
279
+
280
+
281
+ def _write_nested_scalar(key, value):
282
+ """Replace an indented ` key: "value"` line anywhere in the file
283
+ (used for metadata.* fields), preserving indentation and comments."""
284
+ text = read_text()
285
+ pattern = re.compile(r'^([ \t]+' + re.escape(key) + r':)[ \t]*.*$', re.MULTILINE)
286
+ if not pattern.search(text):
287
+ return False
288
+ escaped = value.replace('\\', '\\\\').replace('"', '\\"')
289
+ replacement = r'\1 "' + escaped + '"'
290
+ new_text = pattern.sub(replacement, text, count=1)
291
+ write_text(new_text)
292
+ return True
293
+
294
+
295
+ # ===================================================================
296
+ # Project file sync (type-specific)
297
+ # ===================================================================
298
+
299
+ def sync_spring(path_dir, new_version):
300
+ """Look for build.gradle or build.gradle.kts under path_dir (root of that dir, like bash's maxdepth 2)."""
301
+ candidates = []
302
+ for name in ("build.gradle", "build.gradle.kts"):
303
+ for p in [Path(path_dir) / name] + list(Path(path_dir).glob("*/" + name)):
304
+ if p.is_file():
305
+ candidates.append(p)
306
+ if not candidates:
307
+ log(f"WARNING: spring: no build.gradle(.kts) found under {path_dir} — skipping")
308
+ return
309
+ for gradle_file in candidates:
310
+ text = read_file(gradle_file)
311
+ new_text = re.sub(r"version\s*=\s*'[^']*'", f"version = '{new_version}'", text)
312
+ new_text = re.sub(r'version\s*=\s*"[^"]*"', f'version = "{new_version}"', new_text)
313
+ write_file(gradle_file, new_text)
314
+ log(f"updated: {gradle_file}")
315
+
316
+
317
+ def sync_flutter(path_dir, new_version, version_code):
318
+ target = Path(path_dir) / "pubspec.yaml"
319
+ if not target.is_file():
320
+ log(f"WARNING: flutter: {target} not found — skipping")
321
+ return
322
+ text = read_file(target)
323
+ full_version = f"{new_version}+{version_code}"
324
+ pattern = re.compile(r'^(version:)[ \t]*.*$', re.MULTILINE)
325
+ if pattern.search(text):
326
+ new_text = pattern.sub(r'\1 ' + full_version, text, count=1)
327
+ else:
328
+ new_text = text.rstrip("\n") + f"\nversion: {full_version}\n"
329
+ write_file(target, new_text)
330
+ log(f"updated: {target}")
331
+
332
+
333
+ def sync_json_version(target, new_version, key_path):
334
+ if not target.is_file():
335
+ log(f"WARNING: {target} not found — skipping")
336
+ return
337
+ try:
338
+ data = json.loads(read_file(target))
339
+ except json.JSONDecodeError as e:
340
+ log(f"WARNING: {target} invalid JSON ({e}) — skipping")
341
+ return
342
+ node = data
343
+ for k in key_path[:-1]:
344
+ node = node.setdefault(k, {})
345
+ node[key_path[-1]] = new_version
346
+ write_file(target, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
347
+ log(f"updated: {target}")
348
+
349
+
350
+ def sync_python(path_dir, new_version):
351
+ target = Path(path_dir) / "pyproject.toml"
352
+ if not target.is_file():
353
+ log(f"WARNING: python: {target} not found — skipping")
354
+ return
355
+ text = read_file(target)
356
+ new_text = re.sub(r'^version\s*=\s*"[^"]*"', f'version = "{new_version}"', text, count=1, flags=re.MULTILINE)
357
+ write_file(target, new_text)
358
+ log(f"updated: {target}")
359
+
360
+
361
+ def sync_react_native(path_dir, new_version):
362
+ ios_dir = Path(path_dir) / "ios"
363
+ found_plist = False
364
+ if ios_dir.is_dir():
365
+ for plist_file in ios_dir.rglob("Info.plist"):
366
+ text = read_file(plist_file)
367
+ if "CFBundleShortVersionString" in text:
368
+ new_text = re.sub(
369
+ r'(<key>CFBundleShortVersionString</key>\s*<string>)[^<]*(</string>)',
370
+ r'\g<1>' + new_version + r'\g<2>',
371
+ text,
372
+ )
373
+ write_file(plist_file, new_text)
374
+ log(f"updated: {plist_file}")
375
+ found_plist = True
376
+ else:
377
+ log(f"WARNING: react-native: {ios_dir} not found — skipping")
378
+
379
+ gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
380
+ if gradle_file.is_file():
381
+ text = read_file(gradle_file)
382
+ new_text = re.sub(r'versionName\s+"[^"]*"', f'versionName "{new_version}"', text)
383
+ write_file(gradle_file, new_text)
384
+ log(f"updated: {gradle_file}")
385
+ else:
386
+ log(f"WARNING: react-native: {gradle_file} not found — skipping")
387
+
388
+ if not found_plist and not gradle_file.is_file():
389
+ log(f"WARNING: react-native: no target files found under {path_dir}")
390
+
391
+
392
+ def sync_for_type(project_type, new_version, version_code_getter):
393
+ path_dir = get_type_path(project_type)
394
+ if project_type == "spring":
395
+ sync_spring(path_dir, new_version)
396
+ elif project_type == "flutter":
397
+ sync_flutter(path_dir, new_version, version_code_getter())
398
+ elif project_type in ("react", "next", "node"):
399
+ sync_json_version(Path(path_dir) / "package.json", new_version, ["version"])
400
+ elif project_type == "python":
401
+ sync_python(path_dir, new_version)
402
+ elif project_type == "react-native":
403
+ sync_react_native(path_dir, new_version)
404
+ elif project_type == "react-native-expo":
405
+ sync_json_version(Path(path_dir) / "app.json", new_version, ["expo", "version"])
406
+ elif project_type == "basic":
407
+ pass
408
+ else:
409
+ log(f"WARNING: unknown project type: {project_type} — skipping")
410
+
411
+
412
+ def sync_all_project_files(new_version):
413
+ types = get_project_types_csv()
414
+ if not types:
415
+ types = [get_project_type()]
416
+ for t in types:
417
+ sync_for_type(t, new_version, get_version_code)
418
+
419
+
420
+ def update_all_versions(new_version):
421
+ update_version_yml(new_version)
422
+ sync_all_project_files(new_version)
423
+
424
+
425
+ # ===================================================================
426
+ # Project file -> version read-back (for sync comparison)
427
+ # ===================================================================
428
+
429
+ def get_project_file_version(project_type):
430
+ path_dir = get_type_path(project_type)
431
+ version = None
432
+ try:
433
+ if project_type == "spring":
434
+ for name in ("build.gradle", "build.gradle.kts"):
435
+ p = Path(path_dir) / name
436
+ if p.is_file():
437
+ text = p.read_text(encoding="utf-8")
438
+ m = re.search(r"^\s*version\s*=\s*['\"](\d+\.\d+\.\d+)['\"]", text, re.MULTILINE)
439
+ if m:
440
+ version = m.group(1)
441
+ break
442
+ elif project_type == "flutter":
443
+ p = Path(path_dir) / "pubspec.yaml"
444
+ if p.is_file():
445
+ text = p.read_text(encoding="utf-8")
446
+ m = re.search(r'^version:\s*([^\s#]+)', text, re.MULTILINE)
447
+ if m:
448
+ version = m.group(1).split("+")[0]
449
+ elif project_type in ("react", "next", "node"):
450
+ p = Path(path_dir) / "package.json"
451
+ if p.is_file():
452
+ data = json.loads(p.read_text(encoding="utf-8"))
453
+ version = data.get("version")
454
+ elif project_type == "react-native":
455
+ ios_dir = Path(path_dir) / "ios"
456
+ plist = None
457
+ if ios_dir.is_dir():
458
+ plists = list(ios_dir.rglob("Info.plist"))
459
+ plist = plists[0] if plists else None
460
+ if plist is not None:
461
+ text = plist.read_text(encoding="utf-8")
462
+ m = re.search(r'<key>CFBundleShortVersionString</key>\s*<string>([^<]*)</string>', text)
463
+ if m:
464
+ version = m.group(1)
465
+ else:
466
+ gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
467
+ if gradle_file.is_file():
468
+ text = gradle_file.read_text(encoding="utf-8")
469
+ m = re.search(r'versionName\s+"([^"]+)"', text)
470
+ if m:
471
+ version = m.group(1)
472
+ elif project_type == "react-native-expo":
473
+ p = Path(path_dir) / "app.json"
474
+ if p.is_file():
475
+ data = json.loads(p.read_text(encoding="utf-8"))
476
+ version = (data.get("expo") or {}).get("version")
477
+ elif project_type == "python":
478
+ p = Path(path_dir) / "pyproject.toml"
479
+ if p.is_file():
480
+ text = p.read_text(encoding="utf-8")
481
+ m = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE)
482
+ if m:
483
+ version = m.group(1)
484
+ except Exception as e:
485
+ log(f"WARNING: failed reading project file for {project_type}: {e}")
486
+ version = None
487
+
488
+ if not version:
489
+ version = get_current_version()
490
+ return version
491
+
492
+
493
+ def sync_versions():
494
+ yml_version = get_current_version()
495
+ primary_type = get_project_type()
496
+ project_version = get_project_file_version(primary_type)
497
+
498
+ log("Version sync check")
499
+ log(f" version.yml: {yml_version}")
500
+ log(f" project file: {project_version}")
501
+
502
+ if yml_version != project_version:
503
+ if validate_version(yml_version) and validate_version(project_version):
504
+ higher = get_higher_version(yml_version, project_version)
505
+ log(f"Version mismatch detected, syncing to higher version: {higher}")
506
+ if higher != yml_version:
507
+ update_version_yml(higher)
508
+ if higher != project_version:
509
+ sync_all_project_files(higher)
510
+ return higher
511
+ else:
512
+ log("WARNING: version format invalid, cannot sync")
513
+ return yml_version
514
+ else:
515
+ types = get_project_types_csv()
516
+ if types:
517
+ log(f"Multi-type — reconciling all type files to version.yml version: {yml_version}")
518
+ sync_all_project_files(yml_version)
519
+ log(f"Version already in sync: {yml_version}")
520
+ return yml_version
521
+
522
+
523
+ # ===================================================================
524
+ # Commands
525
+ # ===================================================================
526
+
527
+ def cmd_get(args):
528
+ require_version_yml()
529
+ version = sync_versions()
530
+ print(version)
531
+ return 0
532
+
533
+
534
+ def cmd_get_code(args):
535
+ require_version_yml()
536
+ code = get_version_code()
537
+ print(code)
538
+ return 0
539
+
540
+
541
+ def cmd_increment_code(args):
542
+ require_version_yml()
543
+ current = int(get_version_code())
544
+ new_code = current + 1
545
+ set_version_code(new_code)
546
+ print(new_code)
547
+ return 0
548
+
549
+
550
+ def cmd_increment(args):
551
+ require_version_yml()
552
+ current_version = sync_versions()
553
+ if not validate_version(current_version):
554
+ log(f"ERROR: invalid version format: {current_version}")
555
+ return 1
556
+ new_version = increment_patch(current_version)
557
+ update_all_versions(new_version)
558
+
559
+ current_code = int(get_version_code())
560
+ set_version_code(current_code + 1)
561
+
562
+ print(new_version)
563
+ return 0
564
+
565
+
566
+ def cmd_set(args):
567
+ require_version_yml()
568
+ new_version = args.version
569
+ if not validate_version(new_version):
570
+ log(f"ERROR: invalid version format: {new_version} (must be x.y.z)")
571
+ return 1
572
+ update_all_versions(new_version)
573
+ print(new_version)
574
+ return 0
575
+
576
+
577
+ def cmd_sync(args):
578
+ require_version_yml()
579
+ synced = sync_versions()
580
+ print(synced)
581
+ return 0
582
+
583
+
584
+ def build_parser():
585
+ parser = argparse.ArgumentParser(prog="version_manager.py")
586
+ sub = parser.add_subparsers(dest="command", required=True)
587
+
588
+ sub.add_parser("get")
589
+ sub.add_parser("get-code")
590
+ sub.add_parser("increment")
591
+ sub.add_parser("increment-code")
592
+ sub.add_parser("sync")
593
+
594
+ p_set = sub.add_parser("set")
595
+ p_set.add_argument("version")
596
+
597
+ return parser
598
+
599
+
600
+ def main(argv=None):
601
+ parser = build_parser()
602
+ args = parser.parse_args(argv)
603
+
604
+ handlers = {
605
+ "get": cmd_get,
606
+ "get-code": cmd_get_code,
607
+ "increment": cmd_increment,
608
+ "increment-code": cmd_increment_code,
609
+ "set": cmd_set,
610
+ "sync": cmd_sync,
611
+ }
612
+ handler = handlers[args.command]
613
+ return handler(args)
614
+
615
+
616
+ if __name__ == "__main__":
617
+ sys.exit(main())
@@ -0,0 +1,48 @@
1
+ # ===
2
+ # Version format: x.y.z (semantic versioning)
3
+ # - patch: auto-incremented by the release workflows
4
+ # - version_code: monotonically increasing build number, bumped alongside version
5
+ # - major/minor: NOT bumped automatically — edit this file manually
6
+ # (e.g., 1.0.5 -> 1.1.0 or 2.0.0)
7
+ # ===
8
+ # Supported project types: spring, flutter, next, react, react-native,
9
+ # react-native-expo, node, python, basic
10
+ # Multi-type projects: list every type in the project_types array
11
+ # -> project_type (singular) always mirrors project_types[0] — do not edit it directly
12
+ # Monorepo paths: project_paths maps each type to its subdir relative to the repo root
13
+ # (e.g., flutter: "app", react: "client"). Omitted -> repo root.
14
+ #
15
+ # Synced files per type:
16
+ # - spring: build.gradle / build.gradle.kts
17
+ # - flutter: pubspec.yaml
18
+ # - next/react/node: package.json
19
+ # - react-native: Info.plist + build.gradle
20
+ # - react-native-expo: app.json
21
+ # - python: pyproject.toml
22
+ # - basic: version.yml only
23
+ # ===
24
+
25
+ version: "{{VERSION}}"
26
+ version_code: {{VERSION_CODE}} # app build number
27
+ project_types: {{PROJECT_TYPES}} # first entry is primary
28
+ project_type: "{{PROJECT_TYPE}}" # auto-mirror of project_types[0] — do not edit directly
29
+ {{PROJECT_PATHS}}
30
+ metadata:
31
+ last_updated: "{{NOW}}"
32
+ last_updated_by: "project-auto-wizard"
33
+ default_branch: "{{DEFAULT_BRANCH}}"
34
+ integration_date: "{{TODAY}}"
35
+ template:
36
+ source: "project-auto-wizard"
37
+ version: "{{TEMPLATE_VERSION}}"
38
+ integrated_date: "{{TODAY}}"
39
+ last_update_date: "{{TODAY}}"
40
+ branches:
41
+ main: "{{MAIN_BRANCH}}"
42
+ develop: "{{DEVELOP_BRANCH}}"
43
+ mode: "{{BRANCH_MODE}}" # pr-flow | trunk-based
44
+ options:
45
+ nexus: {{OPT_NEXUS}}
46
+ secret_backup: {{OPT_SECRET_BACKUP}}
47
+ coderabbit: {{OPT_CODERABBIT}}
48
+ {{DEPLOY}}