redeploy 0.2.7__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.7 → redeploy-0.2.8}/PKG-INFO +6 -6
  2. {redeploy-0.2.7 → redeploy-0.2.8}/README.md +5 -5
  3. {redeploy-0.2.7 → redeploy-0.2.8}/pyproject.toml +1 -1
  4. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/cli.py +181 -20
  6. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_version_cli.py +146 -0
  7. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy.egg-info/PKG-INFO +6 -6
  8. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/apply/__init__.py +0 -0
  9. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/apply/executor.py +0 -0
  10. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/data_sync.py +0 -0
  11. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/detect/__init__.py +0 -0
  12. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/detect/detector.py +0 -0
  13. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/detect/probes.py +0 -0
  14. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/detect/remote.py +0 -0
  15. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/detect/templates.py +0 -0
  16. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/detect/workflow.py +0 -0
  17. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/discovery.py +0 -0
  18. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl/__init__.py +0 -0
  19. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl/loader.py +0 -0
  20. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl/parser.py +0 -0
  21. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/__init__.py +0 -0
  22. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/context.py +0 -0
  23. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/decorators.py +0 -0
  24. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/docker_steps.py +0 -0
  25. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/exceptions.py +0 -0
  26. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/runner.py +0 -0
  27. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/dsl_python/steps.py +0 -0
  28. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/fleet.py +0 -0
  29. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/iac/__init__.py +0 -0
  30. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/iac/base.py +0 -0
  31. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/iac/docker_compose.py +0 -0
  32. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/iac/parsers/__init__.py +0 -0
  33. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/iac/parsers/compose.py +0 -0
  34. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/iac/registry.py +0 -0
  35. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/models.py +0 -0
  36. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/observe.py +0 -0
  37. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/parse.py +0 -0
  38. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/patterns.py +0 -0
  39. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plan/__init__.py +0 -0
  40. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plan/planner.py +0 -0
  41. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plugins/__init__.py +0 -0
  42. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plugins/builtin/__init__.py +0 -0
  43. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plugins/builtin/browser_reload.py +0 -0
  44. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plugins/builtin/notify.py +0 -0
  45. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  46. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/ssh.py +0 -0
  47. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/steps.py +0 -0
  48. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/__init__.py +0 -0
  49. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_cli.py +0 -0
  50. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_data_sync.py +0 -0
  51. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_discovery.py +0 -0
  52. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_dsl.py +0 -0
  53. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_examples.py +0 -0
  54. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_executor.py +0 -0
  55. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_fleet.py +0 -0
  56. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_git_integration.py +0 -0
  57. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_iac.py +0 -0
  58. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_manifest.py +0 -0
  59. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_models.py +0 -0
  60. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_observe.py +0 -0
  61. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_parse.py +0 -0
  62. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_patterns.py +0 -0
  63. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_planner_edge.py +0 -0
  64. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_plugins.py +0 -0
  65. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_probes.py +0 -0
  66. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_public_api.py +0 -0
  67. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_ssh.py +0 -0
  68. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_templates.py +0 -0
  69. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_verify.py +0 -0
  70. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_version.py +0 -0
  71. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_version_changelog.py +0 -0
  72. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_version_manifest.py +0 -0
  73. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_version_sources.py +0 -0
  74. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/tests/test_workflow.py +0 -0
  75. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/verify.py +0 -0
  76. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/__init__.py +0 -0
  77. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/bump.py +0 -0
  78. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/changelog.py +0 -0
  79. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/commits.py +0 -0
  80. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/diff.py +0 -0
  81. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/git_integration.py +0 -0
  82. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/git_transaction.py +0 -0
  83. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/manifest.py +0 -0
  84. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/__init__.py +0 -0
  85. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/base.py +0 -0
  86. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/json_.py +0 -0
  87. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/plain.py +0 -0
  88. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/regex.py +0 -0
  89. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/toml_.py +0 -0
  90. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/sources/yaml_.py +0 -0
  91. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version/transaction.py +0 -0
  92. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy/version.py +0 -0
  93. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy.egg-info/SOURCES.txt +0 -0
  94. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy.egg-info/dependency_links.txt +0 -0
  95. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy.egg-info/entry_points.txt +0 -0
  96. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy.egg-info/requires.txt +0 -0
  97. {redeploy-0.2.7 → redeploy-0.2.8}/redeploy.egg-info/top_level.txt +0 -0
  98. {redeploy-0.2.7 → redeploy-0.2.8}/setup.cfg +0 -0
  99. {redeploy-0.2.7 → redeploy-0.2.8}/tests/test_iac.py +0 -0
  100. {redeploy-0.2.7 → redeploy-0.2.8}/tests/test_parse.py +0 -0
  101. {redeploy-0.2.7 → 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.7
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.7-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.90-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.6h-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.9000 (26 commits)
33
- - 👤 **Human dev:** ~$759 (7.6h @ $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.7-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.7-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.90-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.6h-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.9000 (26 commits)
10
- - 👤 **Human dev:** ~$759 (7.6h @ $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.7-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.7"
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.7"
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
@@ -2972,10 +2977,36 @@ def version_init(scan, force):
2972
2977
  root = Path.cwd()
2973
2978
  root_sources = []
2974
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)
2975
2985
 
2976
2986
  if scan:
2977
- root_sources = _detect_version_sources_in_dir(root, root)
2978
- packages = _scan_package_version_manifests(root)
2987
+ root_sources = _detect_version_sources_in_dir(root, root, excluded_paths=excluded)
2988
+ packages = _scan_package_version_manifests(root, excluded_paths=excluded)
2989
+
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
2979
3010
 
2980
3011
  if packages:
2981
3012
  current = _detect_source_version(root_sources, default="0.0.0") if root_sources else "0.0.0"
@@ -3017,6 +3048,14 @@ def version_init(scan, force):
3017
3048
  package = m.get_package(package_name)
3018
3049
  console.print(f" - {package_name}: {package.version} ({len(package.sources)} sources)")
3019
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
+
3020
3059
 
3021
3060
  def _version_scan_specs():
3022
3061
  return [
@@ -3026,14 +3065,35 @@ def _version_scan_specs():
3026
3065
  ]
3027
3066
 
3028
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
+
3029
3080
  def _is_scannable_version_path(path: Path) -> bool:
3030
3081
  ignored = {".git", ".venv", ".redeploy", "node_modules", "__pycache__", "dist", "build"}
3031
3082
  return not any(part in ignored for part in path.parts)
3032
3083
 
3033
3084
 
3034
- def _detect_version_sources_in_dir(directory: Path, workspace_root: Path):
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):
3035
3094
  from .version.manifest import SourceConfig
3036
3095
 
3096
+ excluded_paths = excluded_paths or set()
3037
3097
  sources = []
3038
3098
  for filename, format_name, key in _version_scan_specs():
3039
3099
  candidate = directory / filename
@@ -3043,6 +3103,8 @@ def _detect_version_sources_in_dir(directory: Path, workspace_root: Path):
3043
3103
  relative_path = candidate.relative_to(workspace_root)
3044
3104
  if not _is_scannable_version_path(relative_path):
3045
3105
  continue
3106
+ if relative_path.as_posix() in excluded_paths:
3107
+ continue
3046
3108
 
3047
3109
  source_kwargs = {
3048
3110
  "path": relative_path,
@@ -3052,18 +3114,114 @@ def _detect_version_sources_in_dir(directory: Path, workspace_root: Path):
3052
3114
  source_kwargs["key"] = key
3053
3115
  sources.append(SourceConfig(**source_kwargs))
3054
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
+
3055
3134
  return sources
3056
3135
 
3057
3136
 
3058
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):
3059
3143
  from .version.sources import get_adapter
3060
3144
 
3145
+ detected = []
3061
3146
  for source in sources:
3062
3147
  try:
3063
- return get_adapter(source.format).read(source.path, source)
3148
+ detected.append((source, get_adapter(source.format).read(source.path, source)))
3064
3149
  except Exception:
3065
3150
  continue
3066
- return default
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
3067
3225
 
3068
3226
 
3069
3227
  def _derive_scanned_package_name(package_dir: Path, workspace_root: Path, used_names: set[str]) -> str:
@@ -3079,25 +3237,28 @@ def _derive_scanned_package_name(package_dir: Path, workspace_root: Path, used_n
3079
3237
  return candidate
3080
3238
 
3081
3239
 
3082
- def _scan_package_version_manifests(workspace_root: Path):
3240
+ def _scan_package_version_manifests(workspace_root: Path, *, excluded_paths: set[str] | None = None):
3083
3241
  from .version.manifest import PackageConfig
3084
3242
 
3085
- package_dirs = set()
3086
- for filename, _, _ in _version_scan_specs():
3087
- for candidate in workspace_root.rglob(filename):
3088
- if not candidate.is_file():
3089
- continue
3090
- relative_path = candidate.relative_to(workspace_root)
3091
- if not _is_scannable_version_path(relative_path):
3092
- continue
3093
- if candidate.parent == workspace_root:
3094
- continue
3095
- package_dirs.add(candidate.parent)
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)
3096
3257
 
3097
3258
  packages = {}
3098
3259
  used_names = set()
3099
- for package_dir in sorted(package_dirs, key=lambda item: item.as_posix()):
3100
- sources = _detect_version_sources_in_dir(package_dir, workspace_root)
3260
+ for package_dir in package_dirs:
3261
+ sources = _detect_version_sources_in_dir(package_dir, workspace_root, excluded_paths=excluded_paths)
3101
3262
  if not sources:
3102
3263
  continue
3103
3264
 
@@ -291,6 +291,152 @@ class TestVersionInit:
291
291
  assert "Policy: independent" in result.output
292
292
  assert "Packages: 2" in result.output
293
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
+
294
440
 
295
441
  class TestVersionVerify:
296
442
  def test_verify_all_packages_succeeds_when_in_sync(self, tmp_path):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.7
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.7-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.90-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.6h-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.9000 (26 commits)
33
- - 👤 **Human dev:** ~$759 (7.6h @ $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.7-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