redeploy 0.2.6__tar.gz → 0.2.8__tar.gz

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 (101) hide show
  1. {redeploy-0.2.6 → redeploy-0.2.8}/PKG-INFO +6 -6
  2. {redeploy-0.2.6 → redeploy-0.2.8}/README.md +5 -5
  3. {redeploy-0.2.6 → redeploy-0.2.8}/pyproject.toml +1 -1
  4. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/cli.py +296 -35
  6. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_version_cli.py +200 -0
  7. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy.egg-info/PKG-INFO +6 -6
  8. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/apply/__init__.py +0 -0
  9. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/apply/executor.py +0 -0
  10. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/data_sync.py +0 -0
  11. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/detect/__init__.py +0 -0
  12. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/detect/detector.py +0 -0
  13. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/detect/probes.py +0 -0
  14. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/detect/remote.py +0 -0
  15. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/detect/templates.py +0 -0
  16. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/detect/workflow.py +0 -0
  17. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/discovery.py +0 -0
  18. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl/__init__.py +0 -0
  19. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl/loader.py +0 -0
  20. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl/parser.py +0 -0
  21. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/__init__.py +0 -0
  22. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/context.py +0 -0
  23. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/decorators.py +0 -0
  24. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/docker_steps.py +0 -0
  25. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/exceptions.py +0 -0
  26. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/runner.py +0 -0
  27. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/dsl_python/steps.py +0 -0
  28. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/fleet.py +0 -0
  29. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/iac/__init__.py +0 -0
  30. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/iac/base.py +0 -0
  31. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/iac/docker_compose.py +0 -0
  32. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/iac/parsers/__init__.py +0 -0
  33. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/iac/parsers/compose.py +0 -0
  34. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/iac/registry.py +0 -0
  35. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/models.py +0 -0
  36. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/observe.py +0 -0
  37. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/parse.py +0 -0
  38. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/patterns.py +0 -0
  39. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plan/__init__.py +0 -0
  40. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plan/planner.py +0 -0
  41. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plugins/__init__.py +0 -0
  42. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plugins/builtin/__init__.py +0 -0
  43. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plugins/builtin/browser_reload.py +0 -0
  44. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plugins/builtin/notify.py +0 -0
  45. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  46. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/ssh.py +0 -0
  47. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/steps.py +0 -0
  48. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/__init__.py +0 -0
  49. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_cli.py +0 -0
  50. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_data_sync.py +0 -0
  51. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_discovery.py +0 -0
  52. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_dsl.py +0 -0
  53. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_examples.py +0 -0
  54. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_executor.py +0 -0
  55. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_fleet.py +0 -0
  56. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_git_integration.py +0 -0
  57. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_iac.py +0 -0
  58. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_manifest.py +0 -0
  59. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_models.py +0 -0
  60. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_observe.py +0 -0
  61. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_parse.py +0 -0
  62. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_patterns.py +0 -0
  63. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_planner_edge.py +0 -0
  64. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_plugins.py +0 -0
  65. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_probes.py +0 -0
  66. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_public_api.py +0 -0
  67. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_ssh.py +0 -0
  68. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_templates.py +0 -0
  69. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_verify.py +0 -0
  70. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_version.py +0 -0
  71. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_version_changelog.py +0 -0
  72. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_version_manifest.py +0 -0
  73. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_version_sources.py +0 -0
  74. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/tests/test_workflow.py +0 -0
  75. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/verify.py +0 -0
  76. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/__init__.py +0 -0
  77. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/bump.py +0 -0
  78. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/changelog.py +0 -0
  79. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/commits.py +0 -0
  80. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/diff.py +0 -0
  81. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/git_integration.py +0 -0
  82. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/git_transaction.py +0 -0
  83. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/manifest.py +0 -0
  84. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/__init__.py +0 -0
  85. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/base.py +0 -0
  86. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/json_.py +0 -0
  87. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/plain.py +0 -0
  88. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/regex.py +0 -0
  89. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/toml_.py +0 -0
  90. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/sources/yaml_.py +0 -0
  91. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version/transaction.py +0 -0
  92. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy/version.py +0 -0
  93. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy.egg-info/SOURCES.txt +0 -0
  94. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy.egg-info/dependency_links.txt +0 -0
  95. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy.egg-info/entry_points.txt +0 -0
  96. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy.egg-info/requires.txt +0 -0
  97. {redeploy-0.2.6 → redeploy-0.2.8}/redeploy.egg-info/top_level.txt +0 -0
  98. {redeploy-0.2.6 → redeploy-0.2.8}/setup.cfg +0 -0
  99. {redeploy-0.2.6 → redeploy-0.2.8}/tests/test_iac.py +0 -0
  100. {redeploy-0.2.6 → redeploy-0.2.8}/tests/test_parse.py +0 -0
  101. {redeploy-0.2.6 → redeploy-0.2.8}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -26,17 +26,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
26
26
 
27
27
  ## AI Cost Tracking
28
28
 
29
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.6-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.75-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
29
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.05-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
31
 
32
- - 🤖 **LLM usage:** $3.7500 (25 commits)
33
- - 👤 **Human dev:** ~$738 (7.4h @ $100/h, 30min dedup)
32
+ - 🤖 **LLM usage:** $4.0500 (27 commits)
33
+ - 👤 **Human dev:** ~$773 (7.7h @ $100/h, 30min dedup)
34
34
 
35
35
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
36
 
37
37
  ---
38
38
 
39
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.6-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
39
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
40
 
41
41
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
42
42
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.6-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.75-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.05-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $3.7500 (25 commits)
10
- - 👤 **Human dev:** ~$738 (7.4h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $4.0500 (27 commits)
10
+ - 👤 **Human dev:** ~$773 (7.7h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.6-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.6"
7
+ version = "0.2.8"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
7
7
 
8
8
  Everything not listed in ``__all__`` is internal and may change without notice.
9
9
  """
10
- __version__ = "0.2.6"
10
+ __version__ = "0.2.8"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -2953,8 +2953,13 @@ def version_set(version, manifest_path_str, package_name, all_packages, dry_run,
2953
2953
 
2954
2954
  @version_cmd.command(name="init")
2955
2955
  @click.option("--scan", is_flag=True, help="Auto-detect version sources")
2956
+ @click.option("--review", is_flag=True, help="Review detected sources without writing manifest")
2957
+ @click.option("--interactive", is_flag=True,
2958
+ help="Interactively accept or reject detected sources before writing manifest")
2959
+ @click.option("--exclude", "excluded_paths", multiple=True,
2960
+ help="Exclude a detected source path from scan results (repeatable)")
2956
2961
  @click.option("--force", is_flag=True, help="Overwrite existing manifest")
2957
- def version_init(scan, force):
2962
+ def version_init(scan, review, interactive, excluded_paths, force):
2958
2963
  """Initialize .redeploy/version.yaml manifest."""
2959
2964
  from rich.console import Console
2960
2965
  from .version.manifest import VersionManifest, SourceConfig, GitConfig
@@ -2969,46 +2974,302 @@ def version_init(scan, force):
2969
2974
 
2970
2975
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
2971
2976
 
2972
- # Detect sources if --scan
2973
- sources = []
2977
+ root = Path.cwd()
2978
+ root_sources = []
2979
+ packages = None
2980
+ excluded = _normalize_scan_exclusions(excluded_paths)
2981
+
2982
+ if (review or interactive or excluded) and not scan:
2983
+ console.print("[red]✗ --review, --interactive and --exclude require --scan[/red]")
2984
+ sys.exit(1)
2985
+
2974
2986
  if scan:
2975
- # Check common locations
2976
- if Path("VERSION").exists():
2977
- sources.append(SourceConfig(path=Path("VERSION"), format="plain"))
2978
- if Path("pyproject.toml").exists():
2979
- sources.append(SourceConfig(path=Path("pyproject.toml"), format="toml", key="project.version"))
2980
- if Path("package.json").exists():
2981
- sources.append(SourceConfig(path=Path("package.json"), format="json", key="version"))
2982
-
2983
- if not sources:
2984
- # Default minimal manifest
2985
- sources = [SourceConfig(path=Path("VERSION"), format="plain")]
2986
-
2987
- # Detect current version
2988
- current = "0.1.0"
2989
- for s in sources:
2990
- if s.path.exists():
2991
- try:
2992
- from .version.sources import get_adapter
2993
- current = get_adapter(s.format).read(s.path, s)
2994
- break
2995
- except Exception:
2996
- pass
2987
+ root_sources = _detect_version_sources_in_dir(root, root, excluded_paths=excluded)
2988
+ packages = _scan_package_version_manifests(root, excluded_paths=excluded)
2997
2989
 
2998
- m = VersionManifest(
2999
- version=current,
3000
- scheme="semver",
3001
- policy="synced",
3002
- sources=sources,
3003
- git=GitConfig(),
3004
- )
2990
+ if interactive:
2991
+ excluded.update(
2992
+ _review_detected_sources_interactively(console, root_sources, packages)
2993
+ )
2994
+ root_sources = _detect_version_sources_in_dir(root, root, excluded_paths=excluded)
2995
+ packages = _scan_package_version_manifests(root, excluded_paths=excluded)
2996
+
2997
+ if not root_sources and not packages:
2998
+ console.print("[yellow]⚠ No sources selected - manifest not written[/yellow]")
2999
+ return
3000
+
3001
+ _print_version_scan_review(console, root_sources, packages)
3002
+ if not click.confirm("Write manifest to .redeploy/version.yaml?", default=True):
3003
+ console.print("[yellow]Review complete - manifest not written[/yellow]")
3004
+ return
3005
+
3006
+ if review and not interactive:
3007
+ _print_version_scan_review(console, root_sources, packages)
3008
+ console.print("[yellow]Review only - manifest not written[/yellow]")
3009
+ return
3010
+
3011
+ if packages:
3012
+ current = _detect_source_version(root_sources, default="0.0.0") if root_sources else "0.0.0"
3013
+ m = VersionManifest(
3014
+ version=current,
3015
+ scheme="semver",
3016
+ policy="independent",
3017
+ sources=root_sources,
3018
+ git=GitConfig(),
3019
+ packages=packages,
3020
+ )
3021
+ else:
3022
+ sources = root_sources
3023
+ if not sources:
3024
+ sources = [SourceConfig(path=Path("VERSION"), format="plain")]
3025
+
3026
+ current = _detect_source_version(sources, default="0.1.0")
3027
+ m = VersionManifest(
3028
+ version=current,
3029
+ scheme="semver",
3030
+ policy="synced",
3031
+ sources=sources,
3032
+ git=GitConfig(),
3033
+ )
3005
3034
 
3006
3035
  m.save(manifest_path)
3007
3036
  console.print(f"[green]✓ Created {manifest_path}[/green]")
3008
3037
  console.print(f" Current version: {current}")
3009
- console.print(f" Sources: {len(sources)}")
3010
- for s in sources:
3011
- console.print(f" - {s.path} ({s.format})")
3038
+ console.print(f" Policy: {m.policy}")
3039
+
3040
+ if m.sources:
3041
+ console.print(f" Sources: {len(m.sources)}")
3042
+ for source in m.sources:
3043
+ console.print(f" - {source.path} ({source.format})")
3044
+
3045
+ if m.is_monorepo():
3046
+ console.print(f" Packages: {len(m.packages or {})}")
3047
+ for package_name in m.list_packages():
3048
+ package = m.get_package(package_name)
3049
+ console.print(f" - {package_name}: {package.version} ({len(package.sources)} sources)")
3050
+
3051
+ if scan:
3052
+ if m.sources:
3053
+ _report_version_scan_conflict(console, "root", m.sources, m.version)
3054
+ if m.is_monorepo():
3055
+ for package_name in m.list_packages():
3056
+ package = m.get_package(package_name)
3057
+ _report_version_scan_conflict(console, package_name, package.sources, package.version)
3058
+
3059
+
3060
+ def _version_scan_specs():
3061
+ return [
3062
+ ("VERSION", "plain", None),
3063
+ ("pyproject.toml", "toml", "project.version"),
3064
+ ("package.json", "json", "version"),
3065
+ ]
3066
+
3067
+
3068
+ def _regex_version_scan_specs():
3069
+ return [
3070
+ ("__init__.py", r'__version__\s*=\s*["\']([^"\']+)["\']'),
3071
+ ("src/__init__.py", r'__version__\s*=\s*["\']([^"\']+)["\']'),
3072
+ ("version.ts", r'(?:export\s+)?(?:const|let|var)\s+VERSION\s*=\s*["\']([^"\']+)["\']'),
3073
+ ("src/version.ts", r'(?:export\s+)?(?:const|let|var)\s+VERSION\s*=\s*["\']([^"\']+)["\']'),
3074
+ ("version.h", r'#define\s+[A-Z0-9_]*VERSION\s+"([^"]+)"'),
3075
+ ("src/version.h", r'#define\s+[A-Z0-9_]*VERSION\s+"([^"]+)"'),
3076
+ ("include/version.h", r'#define\s+[A-Z0-9_]*VERSION\s+"([^"]+)"'),
3077
+ ]
3078
+
3079
+
3080
+ def _is_scannable_version_path(path: Path) -> bool:
3081
+ ignored = {".git", ".venv", ".redeploy", "node_modules", "__pycache__", "dist", "build"}
3082
+ return not any(part in ignored for part in path.parts)
3083
+
3084
+
3085
+ def _normalize_scan_exclusions(excluded_paths) -> set[str]:
3086
+ return {
3087
+ str(Path(path)).replace("\\", "/")
3088
+ for path in excluded_paths
3089
+ if str(path).strip()
3090
+ }
3091
+
3092
+
3093
+ def _detect_version_sources_in_dir(directory: Path, workspace_root: Path, *, excluded_paths: set[str] | None = None):
3094
+ from .version.manifest import SourceConfig
3095
+
3096
+ excluded_paths = excluded_paths or set()
3097
+ sources = []
3098
+ for filename, format_name, key in _version_scan_specs():
3099
+ candidate = directory / filename
3100
+ if not candidate.exists() or not candidate.is_file():
3101
+ continue
3102
+
3103
+ relative_path = candidate.relative_to(workspace_root)
3104
+ if not _is_scannable_version_path(relative_path):
3105
+ continue
3106
+ if relative_path.as_posix() in excluded_paths:
3107
+ continue
3108
+
3109
+ source_kwargs = {
3110
+ "path": relative_path,
3111
+ "format": format_name,
3112
+ }
3113
+ if key is not None:
3114
+ source_kwargs["key"] = key
3115
+ sources.append(SourceConfig(**source_kwargs))
3116
+
3117
+ for relative_filename, pattern in _regex_version_scan_specs():
3118
+ candidate = directory / relative_filename
3119
+ if not candidate.exists() or not candidate.is_file():
3120
+ continue
3121
+
3122
+ relative_path = candidate.relative_to(workspace_root)
3123
+ if not _is_scannable_version_path(relative_path):
3124
+ continue
3125
+ if relative_path.as_posix() in excluded_paths:
3126
+ continue
3127
+
3128
+ sources.append(SourceConfig(
3129
+ path=relative_path,
3130
+ format="regex",
3131
+ pattern=pattern,
3132
+ ))
3133
+
3134
+ return sources
3135
+
3136
+
3137
+ def _detect_source_version(sources, *, default: str):
3138
+ detected = _read_detected_source_versions(sources)
3139
+ return detected[0][1] if detected else default
3140
+
3141
+
3142
+ def _read_detected_source_versions(sources):
3143
+ from .version.sources import get_adapter
3144
+
3145
+ detected = []
3146
+ for source in sources:
3147
+ try:
3148
+ detected.append((source, get_adapter(source.format).read(source.path, source)))
3149
+ except Exception:
3150
+ continue
3151
+ return detected
3152
+
3153
+
3154
+ def _report_version_scan_conflict(console, label: str, sources, chosen_version: str):
3155
+ detected = _read_detected_source_versions(sources)
3156
+ unique_versions = {version for _, version in detected}
3157
+ if len(unique_versions) <= 1:
3158
+ return
3159
+
3160
+ details = ", ".join(f"{source.path}={version}" for source, version in detected)
3161
+ console.print(
3162
+ f"[yellow]⚠ Version conflict in {label}: {details}; using {chosen_version}[/yellow]"
3163
+ )
3164
+
3165
+
3166
+ def _print_version_scan_review(console, root_sources, packages):
3167
+ console.print("[bold]Scan review[/bold]")
3168
+
3169
+ if root_sources:
3170
+ _print_version_scan_group(console, "root", root_sources, default_version="0.0.0")
3171
+
3172
+ if packages:
3173
+ for package_name, package in packages.items():
3174
+ _print_version_scan_group(console, package_name, package.sources, default_version=package.version)
3175
+
3176
+ if not root_sources and not packages:
3177
+ console.print(" No version sources detected")
3178
+
3179
+
3180
+ def _print_version_scan_group(console, label: str, sources, *, default_version: str):
3181
+ detected = _read_detected_source_versions(sources)
3182
+ detected_map = {str(source.path): version for source, version in detected}
3183
+ unique_versions = {version for _, version in detected}
3184
+ chosen_version = detected[0][1] if detected else default_version
3185
+ suffix = " (conflict)" if len(unique_versions) > 1 else ""
3186
+
3187
+ console.print(f" {label}: chosen version {chosen_version}{suffix}")
3188
+ for source in sources:
3189
+ actual = detected_map.get(str(source.path), "(unreadable)")
3190
+ marker = " (conflict)" if len(unique_versions) > 1 and actual != chosen_version else ""
3191
+ console.print(f" - {source.path} ({source.format}) current: {actual}{marker}")
3192
+
3193
+
3194
+ def _iter_version_scan_groups(root_sources, packages):
3195
+ if root_sources:
3196
+ yield "root", root_sources, "0.0.0"
3197
+
3198
+ if packages:
3199
+ for package_name, package in packages.items():
3200
+ yield package_name, package.sources, package.version
3201
+
3202
+
3203
+ def _review_detected_sources_interactively(console, root_sources, packages) -> set[str]:
3204
+ rejected = set()
3205
+ console.print("[bold]Interactive scan review[/bold]")
3206
+
3207
+ for label, sources, default_version in _iter_version_scan_groups(root_sources, packages):
3208
+ detected = _read_detected_source_versions(sources)
3209
+ detected_map = {str(source.path): version for source, version in detected}
3210
+ unique_versions = {version for _, version in detected}
3211
+ chosen_version = detected[0][1] if detected else default_version
3212
+ suffix = " (conflict)" if len(unique_versions) > 1 else ""
3213
+
3214
+ console.print(f"\n[bold]{label}[/bold]: chosen version {chosen_version}{suffix}")
3215
+ for source in sources:
3216
+ actual = detected_map.get(str(source.path), "(unreadable)")
3217
+ keep = click.confirm(
3218
+ f"Keep {source.path} ({source.format}) current={actual}?",
3219
+ default=True,
3220
+ )
3221
+ if not keep:
3222
+ rejected.add(source.path.as_posix())
3223
+
3224
+ return rejected
3225
+
3226
+
3227
+ def _derive_scanned_package_name(package_dir: Path, workspace_root: Path, used_names: set[str]) -> str:
3228
+ relative_dir = package_dir.relative_to(workspace_root)
3229
+ if len(relative_dir.parts) > 1 and relative_dir.parts[0] in {"packages", "apps", "services", "modules"}:
3230
+ candidate = relative_dir.parts[-1]
3231
+ else:
3232
+ candidate = relative_dir.as_posix()
3233
+
3234
+ if candidate in used_names:
3235
+ candidate = relative_dir.as_posix().replace("/", "-")
3236
+
3237
+ return candidate
3238
+
3239
+
3240
+ def _scan_package_version_manifests(workspace_root: Path, *, excluded_paths: set[str] | None = None):
3241
+ from .version.manifest import PackageConfig
3242
+
3243
+ excluded_paths = excluded_paths or set()
3244
+ package_dirs = []
3245
+ for candidate in sorted(workspace_root.rglob("*")):
3246
+ if not candidate.is_dir() or candidate == workspace_root:
3247
+ continue
3248
+
3249
+ relative_path = candidate.relative_to(workspace_root)
3250
+ if not _is_scannable_version_path(relative_path):
3251
+ continue
3252
+ if candidate.name in {"src", "include"}:
3253
+ continue
3254
+
3255
+ if _detect_version_sources_in_dir(candidate, workspace_root, excluded_paths=excluded_paths):
3256
+ package_dirs.append(candidate)
3257
+
3258
+ packages = {}
3259
+ used_names = set()
3260
+ for package_dir in package_dirs:
3261
+ sources = _detect_version_sources_in_dir(package_dir, workspace_root, excluded_paths=excluded_paths)
3262
+ if not sources:
3263
+ continue
3264
+
3265
+ package_name = _derive_scanned_package_name(package_dir, workspace_root, used_names)
3266
+ used_names.add(package_name)
3267
+ packages[package_name] = PackageConfig(
3268
+ version=_detect_source_version(sources, default="0.1.0"),
3269
+ sources=sources,
3270
+ )
3271
+
3272
+ return packages or None
3012
3273
 
3013
3274
 
3014
3275
  @version_cmd.command(name="diff")
@@ -238,6 +238,206 @@ class TestVersionCurrent:
238
238
  assert "2.0.0" in result.output
239
239
 
240
240
 
241
+ class TestVersionInit:
242
+ def test_init_scan_creates_synced_manifest_from_root_sources(self, tmp_path):
243
+ runner = _runner()
244
+ with runner.isolated_filesystem(temp_dir=tmp_path):
245
+ Path("VERSION").write_text("2.3.4\n", encoding="utf-8")
246
+
247
+ result = runner.invoke(
248
+ cli,
249
+ ["version", "init", "--scan"],
250
+ catch_exceptions=False,
251
+ )
252
+
253
+ assert result.exit_code == 0, result.output
254
+ manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
255
+ assert manifest["version"]["version"] == "2.3.4"
256
+ assert manifest["version"]["policy"] == "synced"
257
+ assert manifest["version"]["sources"] == [
258
+ {"path": "VERSION", "format": "plain", "optional": False}
259
+ ]
260
+
261
+ def test_init_scan_creates_independent_manifest_from_package_dirs(self, tmp_path):
262
+ runner = _runner()
263
+ with runner.isolated_filesystem(temp_dir=tmp_path):
264
+ Path("backend").mkdir(parents=True, exist_ok=True)
265
+ Path("frontend").mkdir(parents=True, exist_ok=True)
266
+ Path("backend/VERSION").write_text("1.0.0\n", encoding="utf-8")
267
+ Path("frontend/package.json").write_text(
268
+ '{"name": "frontend", "version": "2.0.0"}\n',
269
+ encoding="utf-8",
270
+ )
271
+
272
+ result = runner.invoke(
273
+ cli,
274
+ ["version", "init", "--scan"],
275
+ catch_exceptions=False,
276
+ )
277
+
278
+ assert result.exit_code == 0, result.output
279
+ manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
280
+ assert manifest["version"]["policy"] == "independent"
281
+ assert manifest["version"]["version"] == "0.0.0"
282
+ assert manifest["version"].get("sources", []) == []
283
+ assert manifest["version"]["packages"]["backend"]["version"] == "1.0.0"
284
+ assert manifest["version"]["packages"]["backend"]["sources"] == [
285
+ {"path": "backend/VERSION", "format": "plain", "optional": False}
286
+ ]
287
+ assert manifest["version"]["packages"]["frontend"]["version"] == "2.0.0"
288
+ assert manifest["version"]["packages"]["frontend"]["sources"] == [
289
+ {"path": "frontend/package.json", "format": "json", "key": "version", "optional": False}
290
+ ]
291
+ assert "Policy: independent" in result.output
292
+ assert "Packages: 2" in result.output
293
+
294
+ def test_init_scan_detects_regex_source_in_nested_package(self, tmp_path):
295
+ runner = _runner()
296
+ with runner.isolated_filesystem(temp_dir=tmp_path):
297
+ Path("packages/frontend/src").mkdir(parents=True, exist_ok=True)
298
+ Path("packages/frontend/src/version.ts").write_text(
299
+ 'export const VERSION = "2.1.0"\n',
300
+ encoding="utf-8",
301
+ )
302
+
303
+ result = runner.invoke(
304
+ cli,
305
+ ["version", "init", "--scan"],
306
+ catch_exceptions=False,
307
+ )
308
+
309
+ assert result.exit_code == 0, result.output
310
+ manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
311
+ assert manifest["version"]["policy"] == "independent"
312
+ assert manifest["version"]["packages"]["frontend"]["version"] == "2.1.0"
313
+ source = manifest["version"]["packages"]["frontend"]["sources"][0]
314
+ assert source["path"] == "packages/frontend/src/version.ts"
315
+ assert source["format"] == "regex"
316
+ assert source["optional"] is False
317
+ assert "VERSION" in source["pattern"]
318
+ assert "const|let|var" in source["pattern"]
319
+
320
+ def test_init_scan_reports_conflict_between_scanned_sources(self, tmp_path):
321
+ runner = _runner()
322
+ with runner.isolated_filesystem(temp_dir=tmp_path):
323
+ Path("backend/src").mkdir(parents=True, exist_ok=True)
324
+ Path("backend/VERSION").write_text("1.0.0\n", encoding="utf-8")
325
+ Path("backend/src/version.ts").write_text(
326
+ 'export const VERSION = "1.1.0"\n',
327
+ encoding="utf-8",
328
+ )
329
+
330
+ result = runner.invoke(
331
+ cli,
332
+ ["version", "init", "--scan"],
333
+ catch_exceptions=False,
334
+ )
335
+
336
+ assert result.exit_code == 0, result.output
337
+ assert "Version conflict in backend" in result.output
338
+ assert "backend/VERSION=1.0.0" in result.output
339
+ assert "backend/src/version.ts=1.1.0" in result.output
340
+ assert "using 1.0.0" in result.output
341
+
342
+ manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
343
+ assert manifest["version"]["packages"]["backend"]["version"] == "1.0.0"
344
+
345
+ def test_init_scan_review_lists_detected_sources_without_writing_manifest(self, tmp_path):
346
+ runner = _runner()
347
+ with runner.isolated_filesystem(temp_dir=tmp_path):
348
+ Path("backend/src").mkdir(parents=True, exist_ok=True)
349
+ Path("backend/VERSION").write_text("1.0.0\n", encoding="utf-8")
350
+ Path("backend/src/version.ts").write_text(
351
+ 'export const VERSION = "1.1.0"\n',
352
+ encoding="utf-8",
353
+ )
354
+
355
+ result = runner.invoke(
356
+ cli,
357
+ ["version", "init", "--scan", "--review"],
358
+ catch_exceptions=False,
359
+ )
360
+
361
+ assert result.exit_code == 0, result.output
362
+ assert "Scan review" in result.output
363
+ assert "backend: chosen version 1.0.0 (conflict)" in result.output
364
+ assert "backend/VERSION (plain) current: 1.0.0" in result.output
365
+ assert "backend/src/version.ts (regex) current: 1.1.0 (conflict)" in result.output
366
+ assert "Review only - manifest not written" in result.output
367
+ assert not Path(".redeploy/version.yaml").exists()
368
+
369
+ def test_init_scan_interactive_can_reject_source_and_write_manifest(self, tmp_path):
370
+ runner = _runner()
371
+ with runner.isolated_filesystem(temp_dir=tmp_path):
372
+ Path("backend/src").mkdir(parents=True, exist_ok=True)
373
+ Path("backend/VERSION").write_text("1.0.0\n", encoding="utf-8")
374
+ Path("backend/src/version.ts").write_text(
375
+ 'export const VERSION = "1.1.0"\n',
376
+ encoding="utf-8",
377
+ )
378
+
379
+ result = runner.invoke(
380
+ cli,
381
+ ["version", "init", "--scan", "--interactive"],
382
+ input="\nn\n\n",
383
+ catch_exceptions=False,
384
+ )
385
+
386
+ assert result.exit_code == 0, result.output
387
+ assert "Interactive scan review" in result.output
388
+ assert "Keep backend/VERSION (plain) current=1.0.0?" in result.output
389
+ assert "Keep backend/src/version.ts (regex) current=1.1.0?" in result.output
390
+ assert "Write manifest to .redeploy/version.yaml?" in result.output
391
+
392
+ manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
393
+ assert manifest["version"]["packages"]["backend"]["version"] == "1.0.0"
394
+ assert manifest["version"]["packages"]["backend"]["sources"] == [
395
+ {"path": "backend/VERSION", "format": "plain", "optional": False}
396
+ ]
397
+
398
+ def test_init_interactive_requires_scan(self, tmp_path):
399
+ runner = _runner()
400
+ with runner.isolated_filesystem(temp_dir=tmp_path):
401
+ result = runner.invoke(
402
+ cli,
403
+ ["version", "init", "--interactive"],
404
+ catch_exceptions=False,
405
+ )
406
+
407
+ assert result.exit_code == 1, result.output
408
+ assert "--review, --interactive and --exclude require --scan" in result.output
409
+
410
+ def test_init_scan_exclude_omits_detected_source_from_manifest(self, tmp_path):
411
+ runner = _runner()
412
+ with runner.isolated_filesystem(temp_dir=tmp_path):
413
+ Path("backend/src").mkdir(parents=True, exist_ok=True)
414
+ Path("backend/VERSION").write_text("1.0.0\n", encoding="utf-8")
415
+ Path("backend/src/version.ts").write_text(
416
+ 'export const VERSION = "1.1.0"\n',
417
+ encoding="utf-8",
418
+ )
419
+
420
+ result = runner.invoke(
421
+ cli,
422
+ [
423
+ "version",
424
+ "init",
425
+ "--scan",
426
+ "--exclude",
427
+ "backend/src/version.ts",
428
+ ],
429
+ catch_exceptions=False,
430
+ )
431
+
432
+ assert result.exit_code == 0, result.output
433
+ manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
434
+ assert manifest["version"]["packages"]["backend"]["version"] == "1.0.0"
435
+ assert manifest["version"]["packages"]["backend"]["sources"] == [
436
+ {"path": "backend/VERSION", "format": "plain", "optional": False}
437
+ ]
438
+ assert "Version conflict in backend" not in result.output
439
+
440
+
241
441
  class TestVersionVerify:
242
442
  def test_verify_all_packages_succeeds_when_in_sync(self, tmp_path):
243
443
  runner = _runner()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -26,17 +26,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
26
26
 
27
27
  ## AI Cost Tracking
28
28
 
29
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.6-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.75-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
29
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.05-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
31
 
32
- - 🤖 **LLM usage:** $3.7500 (25 commits)
33
- - 👤 **Human dev:** ~$738 (7.4h @ $100/h, 30min dedup)
32
+ - 🤖 **LLM usage:** $4.0500 (27 commits)
33
+ - 👤 **Human dev:** ~$773 (7.7h @ $100/h, 30min dedup)
34
34
 
35
35
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
36
 
37
37
  ---
38
38
 
39
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.6-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
39
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
40
 
41
41
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
42
42
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes