redeploy 0.2.17__tar.gz → 0.2.18__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 (115) hide show
  1. {redeploy-0.2.17 → redeploy-0.2.18}/PKG-INFO +108 -6
  2. {redeploy-0.2.17 → redeploy-0.2.18}/README.md +107 -5
  3. {redeploy-0.2.17 → redeploy-0.2.18}/pyproject.toml +1 -1
  4. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/apply/executor.py +18 -5
  6. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/cli.py +323 -0
  7. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/markpact/__init__.py +11 -1
  8. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/markpact/compiler.py +7 -2
  9. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/markpact/models.py +3 -1
  10. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/markpact/parser.py +111 -4
  11. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/models.py +1 -0
  12. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_executor.py +56 -0
  13. redeploy-0.2.18/redeploy/tests/test_markpact_parser.py +93 -0
  14. redeploy-0.2.18/redeploy/tests/test_resume.py +271 -0
  15. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy.egg-info/PKG-INFO +108 -6
  16. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy.egg-info/SOURCES.txt +1 -1
  17. redeploy-0.2.17/redeploy/checkpoint.py +0 -234
  18. redeploy-0.2.17/redeploy/tests/test_markpact_parser.py +0 -41
  19. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/apply/__init__.py +0 -0
  20. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/apply/state.py +0 -0
  21. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/audit.py +0 -0
  22. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/data_sync.py +0 -0
  23. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/detect/__init__.py +0 -0
  24. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/detect/detector.py +0 -0
  25. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/detect/probes.py +0 -0
  26. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/detect/remote.py +0 -0
  27. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/detect/templates.py +0 -0
  28. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/detect/workflow.py +0 -0
  29. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/discovery.py +0 -0
  30. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl/__init__.py +0 -0
  31. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl/loader.py +0 -0
  32. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl/parser.py +0 -0
  33. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/__init__.py +0 -0
  34. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/context.py +0 -0
  35. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/decorators.py +0 -0
  36. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/docker_steps.py +0 -0
  37. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/exceptions.py +0 -0
  38. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/runner.py +0 -0
  39. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/dsl_python/steps.py +0 -0
  40. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/fleet.py +0 -0
  41. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/iac/__init__.py +0 -0
  42. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/iac/base.py +0 -0
  43. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/iac/docker_compose.py +0 -0
  44. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/iac/parsers/__init__.py +0 -0
  45. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/iac/parsers/compose.py +0 -0
  46. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/iac/registry.py +0 -0
  47. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/observe.py +0 -0
  48. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/parse.py +0 -0
  49. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/patterns.py +0 -0
  50. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plan/__init__.py +0 -0
  51. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plan/planner.py +0 -0
  52. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plugins/__init__.py +0 -0
  53. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plugins/builtin/__init__.py +0 -0
  54. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plugins/builtin/browser_reload.py +0 -0
  55. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plugins/builtin/notify.py +0 -0
  56. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  57. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/spec_loader.py +0 -0
  58. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/ssh.py +0 -0
  59. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/steps.py +0 -0
  60. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/__init__.py +0 -0
  61. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_audit.py +0 -0
  62. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_cli.py +0 -0
  63. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_data_sync.py +0 -0
  64. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_discovery.py +0 -0
  65. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_dsl.py +0 -0
  66. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_examples.py +0 -0
  67. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_fleet.py +0 -0
  68. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_git_integration.py +0 -0
  69. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_iac.py +0 -0
  70. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_manifest.py +0 -0
  71. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_markpact_compiler.py +0 -0
  72. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_models.py +0 -0
  73. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_observe.py +0 -0
  74. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_parse.py +0 -0
  75. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_patterns.py +0 -0
  76. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_planner_edge.py +0 -0
  77. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_plugins.py +0 -0
  78. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_probes.py +0 -0
  79. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_public_api.py +0 -0
  80. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_spec_loader.py +0 -0
  81. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_ssh.py +0 -0
  82. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_templates.py +0 -0
  83. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_verify.py +0 -0
  84. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_version.py +0 -0
  85. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_version_changelog.py +0 -0
  86. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_version_cli.py +0 -0
  87. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_version_manifest.py +0 -0
  88. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_version_sources.py +0 -0
  89. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/tests/test_workflow.py +0 -0
  90. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/verify.py +0 -0
  91. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/__init__.py +0 -0
  92. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/bump.py +0 -0
  93. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/changelog.py +0 -0
  94. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/commits.py +0 -0
  95. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/diff.py +0 -0
  96. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/git_integration.py +0 -0
  97. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/git_transaction.py +0 -0
  98. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/manifest.py +0 -0
  99. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/__init__.py +0 -0
  100. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/base.py +0 -0
  101. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/json_.py +0 -0
  102. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/plain.py +0 -0
  103. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/regex.py +0 -0
  104. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/toml_.py +0 -0
  105. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/sources/yaml_.py +0 -0
  106. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version/transaction.py +0 -0
  107. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy/version.py +0 -0
  108. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy.egg-info/dependency_links.txt +0 -0
  109. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy.egg-info/entry_points.txt +0 -0
  110. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy.egg-info/requires.txt +0 -0
  111. {redeploy-0.2.17 → redeploy-0.2.18}/redeploy.egg-info/top_level.txt +0 -0
  112. {redeploy-0.2.17 → redeploy-0.2.18}/setup.cfg +0 -0
  113. {redeploy-0.2.17 → redeploy-0.2.18}/tests/test_iac.py +0 -0
  114. {redeploy-0.2.17 → redeploy-0.2.18}/tests/test_parse.py +0 -0
  115. {redeploy-0.2.17 → redeploy-0.2.18}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.17
3
+ Version: 0.2.18
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -27,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
27
27
 
28
28
  ## AI Cost Tracking
29
29
 
30
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.17-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-11.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.18-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-12.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $5.4000 (36 commits)
34
- - 👤 **Human dev:** ~$1130 (11.3h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $5.5500 (37 commits)
34
+ - 👤 **Human dev:** ~$1255 (12.6h @ $100/h, 30min dedup)
35
35
 
36
36
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
37
37
 
38
38
  ---
39
39
 
40
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.17-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.18-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
41
 
42
42
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
43
43
 
@@ -428,6 +428,108 @@ extra_steps:
428
428
 
429
429
  The script is base64-encoded and executed via SSH with automatic temp file cleanup. Use `command` field for multiline script content (YAML `|` preserves newlines).
430
430
 
431
+ ### Script References (`command_ref`)
432
+
433
+ Instead of duplicating scripts in YAML, reference a script defined in a markdown codeblock:
434
+
435
+ ```yaml
436
+ extra_steps:
437
+ - id: configure_kiosk
438
+ action: inline_script
439
+ description: "Execute kiosk script from markdown"
440
+ command_ref: "#kiosk-browser-configuration-script"
441
+ risk: medium
442
+ ```
443
+
444
+ In your migration markdown file, define the script in a section:
445
+
446
+ ```markdown
447
+ ## Kiosk Browser Configuration Script
448
+
449
+ ```bash
450
+ #!/bin/bash
451
+ # Auto-detect browser...
452
+ if command -v chromium-browser >/dev/null 2>&1; then
453
+ chromium-browser --kiosk http://localhost:8100
454
+ fi
455
+ ```
456
+ ```
457
+
458
+ **Benefits:**
459
+ - Single source of truth — script lives in one place (markdown codeblock)
460
+ - No duplication between markdown documentation and YAML
461
+ - Easy to read and maintain
462
+ - Changes to the codeblock automatically apply to the deployment
463
+
464
+ **Reference formats:**
465
+ - `"#section-id"` — script from section in current spec file
466
+ - `"./file.md#section-id"` — script from section in specific file
467
+
468
+ The section ID is derived from the heading: spaces become hyphens, lowercase.
469
+ Example: `## Kiosk Browser Configuration Script` → `#kiosk-browser-configuration-script`
470
+
471
+ ### Execute Script by Reference (`redeploy exec`)
472
+
473
+ Run a single script from markdown without running the full migration:
474
+
475
+ ```bash
476
+ # Execute script from codeblock on remote host
477
+ redeploy exec '#kiosk-browser-configuration-script' \
478
+ --host pi@192.168.188.108 \
479
+ --file migration.podman-rpi5-resume.md
480
+
481
+ # With file in reference
482
+ redeploy exec './migration.md#install-deps' --host root@server.com
483
+
484
+ # Using markpact:ref (more explicit)
485
+ redeploy exec 'kiosk-script-id' --host pi@192.168.188.108 --file migration.md
486
+
487
+ # Dry-run to preview script
488
+ redeploy exec '#backup-script' --host pi@192.168.188.108 --file ops.md --dry-run
489
+ ```
490
+
491
+ This is useful for:
492
+ - One-off operations defined in markdown docs
493
+ - Testing individual scripts before full migration
494
+ - Running maintenance tasks
495
+
496
+ ### Execute Multiple Scripts (`redeploy exec-multi`)
497
+
498
+ Test multiple scripts at once:
499
+
500
+ ```bash
501
+ # Execute multiple scripts by ref
502
+ redeploy exec-multi 'kiosk-script,install-deps,cleanup' \
503
+ --host pi@192.168.188.108 \
504
+ --file migration.md
505
+
506
+ # Mix of markpact:ref and section headings
507
+ redeploy exec-multi 'script1,#section2,script3' \
508
+ --host root@server.com \
509
+ --file deploy.md \
510
+ --dry-run
511
+ ```
512
+
513
+ ### Marking Codeblocks with `markpact:ref`
514
+
515
+ For more explicit script identification, use `markpact:ref <id>` in codeblock:
516
+
517
+ ```markdown
518
+ ```bash markpact:ref kiosk-browser-configuration-script
519
+ #!/bin/bash
520
+ # Auto-detect browser...
521
+ if command -v chromium-browser >/dev/null 2>&1; then
522
+ chromium-browser --kiosk http://localhost:8100
523
+ fi
524
+ ```
525
+ ```
526
+
527
+ Benefits of `markpact:ref`:
528
+ - Explicit ID assignment (not derived from heading)
529
+ - Multiple scripts per section
530
+ - Can reference by simple ID instead of full heading
531
+ - Self-documenting in markdown
532
+
431
533
 
432
534
 
433
535
  Place in project root — `redeploy run` (no args) uses it automatically.
@@ -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.17-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-$5.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-11.3h-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.18-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-$5.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-12.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $5.4000 (36 commits)
10
- - 👤 **Human dev:** ~$1130 (11.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $5.5500 (37 commits)
10
+ - 👤 **Human dev:** ~$1255 (12.6h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-21 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.17-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.18-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
 
@@ -404,6 +404,108 @@ extra_steps:
404
404
 
405
405
  The script is base64-encoded and executed via SSH with automatic temp file cleanup. Use `command` field for multiline script content (YAML `|` preserves newlines).
406
406
 
407
+ ### Script References (`command_ref`)
408
+
409
+ Instead of duplicating scripts in YAML, reference a script defined in a markdown codeblock:
410
+
411
+ ```yaml
412
+ extra_steps:
413
+ - id: configure_kiosk
414
+ action: inline_script
415
+ description: "Execute kiosk script from markdown"
416
+ command_ref: "#kiosk-browser-configuration-script"
417
+ risk: medium
418
+ ```
419
+
420
+ In your migration markdown file, define the script in a section:
421
+
422
+ ```markdown
423
+ ## Kiosk Browser Configuration Script
424
+
425
+ ```bash
426
+ #!/bin/bash
427
+ # Auto-detect browser...
428
+ if command -v chromium-browser >/dev/null 2>&1; then
429
+ chromium-browser --kiosk http://localhost:8100
430
+ fi
431
+ ```
432
+ ```
433
+
434
+ **Benefits:**
435
+ - Single source of truth — script lives in one place (markdown codeblock)
436
+ - No duplication between markdown documentation and YAML
437
+ - Easy to read and maintain
438
+ - Changes to the codeblock automatically apply to the deployment
439
+
440
+ **Reference formats:**
441
+ - `"#section-id"` — script from section in current spec file
442
+ - `"./file.md#section-id"` — script from section in specific file
443
+
444
+ The section ID is derived from the heading: spaces become hyphens, lowercase.
445
+ Example: `## Kiosk Browser Configuration Script` → `#kiosk-browser-configuration-script`
446
+
447
+ ### Execute Script by Reference (`redeploy exec`)
448
+
449
+ Run a single script from markdown without running the full migration:
450
+
451
+ ```bash
452
+ # Execute script from codeblock on remote host
453
+ redeploy exec '#kiosk-browser-configuration-script' \
454
+ --host pi@192.168.188.108 \
455
+ --file migration.podman-rpi5-resume.md
456
+
457
+ # With file in reference
458
+ redeploy exec './migration.md#install-deps' --host root@server.com
459
+
460
+ # Using markpact:ref (more explicit)
461
+ redeploy exec 'kiosk-script-id' --host pi@192.168.188.108 --file migration.md
462
+
463
+ # Dry-run to preview script
464
+ redeploy exec '#backup-script' --host pi@192.168.188.108 --file ops.md --dry-run
465
+ ```
466
+
467
+ This is useful for:
468
+ - One-off operations defined in markdown docs
469
+ - Testing individual scripts before full migration
470
+ - Running maintenance tasks
471
+
472
+ ### Execute Multiple Scripts (`redeploy exec-multi`)
473
+
474
+ Test multiple scripts at once:
475
+
476
+ ```bash
477
+ # Execute multiple scripts by ref
478
+ redeploy exec-multi 'kiosk-script,install-deps,cleanup' \
479
+ --host pi@192.168.188.108 \
480
+ --file migration.md
481
+
482
+ # Mix of markpact:ref and section headings
483
+ redeploy exec-multi 'script1,#section2,script3' \
484
+ --host root@server.com \
485
+ --file deploy.md \
486
+ --dry-run
487
+ ```
488
+
489
+ ### Marking Codeblocks with `markpact:ref`
490
+
491
+ For more explicit script identification, use `markpact:ref <id>` in codeblock:
492
+
493
+ ```markdown
494
+ ```bash markpact:ref kiosk-browser-configuration-script
495
+ #!/bin/bash
496
+ # Auto-detect browser...
497
+ if command -v chromium-browser >/dev/null 2>&1; then
498
+ chromium-browser --kiosk http://localhost:8100
499
+ fi
500
+ ```
501
+ ```
502
+
503
+ Benefits of `markpact:ref`:
504
+ - Explicit ID assignment (not derived from heading)
505
+ - Multiple scripts per section
506
+ - Can reference by simple ID instead of full heading
507
+ - Self-documenting in markdown
508
+
407
509
 
408
510
 
409
511
  Place in project root — `redeploy run` (no args) uses it automatically.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.17"
7
+ version = "0.2.18"
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.17"
10
+ __version__ = "0.2.18"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -143,10 +143,14 @@ class Executor:
143
143
  self._audit_path = audit_path
144
144
  self._t0: float = 0.0
145
145
 
146
+ # ── spec path for command_ref resolution ─────────────────────────────
147
+ if spec_path and not plan.spec_path:
148
+ plan.spec_path = spec_path
149
+
146
150
  # ── resume / checkpoint ──────────────────────────────────────────────
147
151
  self._resume = resume
148
152
  self._from_step = from_step
149
- spec_id = spec_path or plan.target_file or plan.infra_file or plan.app
153
+ spec_id = spec_path or plan.spec_path or plan.target_file or plan.infra_file or plan.app
150
154
  if state_path is None and not dry_run:
151
155
  state_path = default_state_path(spec_id, plan.host)
152
156
  self._state_path: Optional[Path] = (
@@ -578,9 +582,10 @@ class Executor:
578
582
  command_ref formats:
579
583
  - "./file.md#section-id" - script from section in specific file
580
584
  - "#section-id" - script from section in current spec file
585
+ - "#kiosk-browser-configuration-script" - markpact:ref block
581
586
  """
582
587
  from pathlib import Path
583
- from ..markpact.parser import extract_script_from_markdown
588
+ from ..markpact.parser import extract_script_from_markdown, extract_script_by_ref
584
589
 
585
590
  # Parse command_ref
586
591
  if "#" in command_ref:
@@ -597,12 +602,20 @@ class Executor:
597
602
  if not file_path.exists():
598
603
  raise StepError(step, f"Command ref file not found: {file_path}")
599
604
 
600
- # Extract script from markdown
605
+ # Extract script from markdown - try both methods:
606
+ # 1. First try markpact:ref codeblock (new format)
607
+ # 2. Then try section heading (old format)
601
608
  markdown_content = file_path.read_text(encoding="utf-8")
602
- script = extract_script_from_markdown(markdown_content, section_id, language="bash")
609
+
610
+ # Try markpact:ref format first
611
+ script = extract_script_by_ref(markdown_content, section_id, language="bash")
612
+
613
+ # Fallback to section heading format
614
+ if script is None:
615
+ script = extract_script_from_markdown(markdown_content, section_id, language="bash")
603
616
 
604
617
  if script is None:
605
- raise StepError(step, f"Could not find bash script in section '{section_id}' of {file_path}")
618
+ raise StepError(step, f"Could not find bash script for ref '{section_id}' in {file_path} (tried markpact:ref and section heading)")
606
619
 
607
620
  return script
608
621
 
@@ -743,6 +743,237 @@ def plugin_cmd(ctx, subcommand, name):
743
743
  console.print(f"\n{doc}")
744
744
 
745
745
 
746
+ # ── exec (execute script from markdown by reference) ───────────────────────────
747
+
748
+ @cli.command("exec")
749
+ @click.argument("ref", required=True)
750
+ @click.option("--host", required=True, help="SSH host (user@ip) or 'local'")
751
+ @click.option("--file", "markdown_file", default=None, type=click.Path(),
752
+ help="Markdown file (required if ref doesn't specify file)")
753
+ @click.option("--dry-run", is_flag=True, help="Show script without executing")
754
+ @click.option("--timeout", default=300, help="Step timeout in seconds")
755
+ @click.pass_context
756
+ def exec_cmd(ctx, ref, host, markdown_file, dry_run, timeout):
757
+ """Execute a script from a markdown codeblock by reference.
758
+
759
+ REF format: #section-id or ./file.md#section-id or just ref-id (for markpact:ref)
760
+
761
+ Extracts the script from a ```bash codeblock in the specified section
762
+ or from a codeblock marked with markpact:ref <id>.
763
+
764
+ \b
765
+ Examples:
766
+ redeploy exec '#kiosk-browser-configuration-script' --host pi@192.168.188.108 --file migration.md
767
+ redeploy exec './migration.md#kiosk-script' --host pi@192.168.188.108
768
+ redeploy exec '#install-deps' --host root@server.com --file deploy.md --dry-run
769
+ redeploy exec 'kiosk-browser-configuration-script' --host pi@192.168.188.108 --file migration.md
770
+ """
771
+ from rich.console import Console
772
+ from pathlib import Path
773
+ from .markpact.parser import extract_script_from_markdown, extract_script_by_ref
774
+ from .detect.remote import RemoteProbe
775
+ from .apply.executor import Executor, MigrationPlan, MigrationStep, StepAction, StepStatus
776
+
777
+ console = Console()
778
+
779
+ # Parse reference
780
+ if "#" in ref:
781
+ file_part, section_id = ref.split("#", 1)
782
+ if file_part:
783
+ md_path = Path(file_part)
784
+ elif markdown_file:
785
+ md_path = Path(markdown_file)
786
+ else:
787
+ console.print("[red]✗ No file specified in ref and no --file provided[/red]")
788
+ sys.exit(1)
789
+ ref_id = section_id
790
+ use_section_lookup = True
791
+ else:
792
+ ref_id = ref
793
+ if not markdown_file:
794
+ console.print("[red]✗ --file required when ref doesn't specify file[/red]")
795
+ sys.exit(1)
796
+ md_path = Path(markdown_file)
797
+ use_section_lookup = False
798
+
799
+ if not md_path.exists():
800
+ console.print(f"[red]✗ File not found: {md_path}[/red]")
801
+ sys.exit(1)
802
+
803
+ # Extract script from markdown
804
+ console.print(f"[dim]Reading {md_path}...[/dim]")
805
+ md_content = md_path.read_text(encoding="utf-8")
806
+
807
+ # Try markpact:ref lookup first (more specific), then fallback to section lookup
808
+ script = extract_script_by_ref(md_content, ref_id, language="bash")
809
+ lookup_method = "markpact:ref"
810
+
811
+ if script is None and use_section_lookup:
812
+ script = extract_script_from_markdown(md_content, ref_id, language="bash")
813
+ lookup_method = "section heading"
814
+
815
+ if script is None:
816
+ console.print(f"[red]✗ Could not find bash script with ref '#{ref_id}'[/red]")
817
+ console.print("[dim] Make sure there's either:[/dim]")
818
+ console.print("[dim] - A codeblock: ```bash markpact:ref {ref_id}[/dim]")
819
+ console.print("[dim] - Or a ## section heading and ```bash codeblock[/dim]")
820
+ sys.exit(1)
821
+
822
+ console.print(f"[green]✓[/green] Found script ({len(script)} chars) via {lookup_method} '#{ref_id}'")
823
+
824
+ if dry_run:
825
+ console.print(f"\n[bold]Script content (dry-run):[/bold]")
826
+ console.print("```bash")
827
+ console.print(script)
828
+ console.print("```")
829
+ return
830
+
831
+ # Execute script on remote host
832
+ console.print(f"\n[bold]Executing on {host}...[/bold]")
833
+
834
+ # Create minimal plan with single step
835
+ step = MigrationStep(
836
+ id=f"exec_{ref_id}",
837
+ action=StepAction.INLINE_SCRIPT,
838
+ description=f"Execute script from #{ref_id}",
839
+ command=script,
840
+ timeout=timeout,
841
+ )
842
+ plan = MigrationPlan(
843
+ host=host,
844
+ app="exec",
845
+ from_strategy="unknown",
846
+ to_strategy="unknown",
847
+ steps=[step],
848
+ )
849
+
850
+ executor = Executor(plan, dry_run=False)
851
+ ok = executor.run()
852
+
853
+ if not ok:
854
+ sys.exit(1)
855
+
856
+ console.print(f"[green]✓ Script executed successfully[/green]")
857
+
858
+
859
+ # ── exec-multi (execute multiple scripts by ref) ───────────────────────────────
860
+
861
+ @cli.command("exec-multi")
862
+ @click.argument("refs")
863
+ @click.option("--host", required=True, help="SSH host (user@ip) or 'local'")
864
+ @click.option("--file", "markdown_file", required=True, type=click.Path(),
865
+ help="Markdown file containing scripts")
866
+ @click.option("--dry-run", is_flag=True, help="Show scripts without executing")
867
+ @click.option("--timeout", default=300, help="Step timeout in seconds")
868
+ @click.option("--parallel", is_flag=True, help="Execute scripts in parallel (default: sequential)")
869
+ @click.pass_context
870
+ def exec_multi_cmd(ctx, refs, host, markdown_file, dry_run, timeout, parallel):
871
+ """Execute multiple scripts from markdown codeblocks by reference.
872
+
873
+ REFS format: comma-separated list of ref ids (markpact:ref or section headings)
874
+
875
+ \b
876
+ Examples:
877
+ redeploy exec-multi 'kiosk-script,install-deps,cleanup' \
878
+ --host pi@192.168.188.108 --file migration.md
879
+
880
+ redeploy exec-multi '#section1,#section2' \
881
+ --host root@server.com --file deploy.md --dry-run
882
+ """
883
+ from rich.console import Console
884
+ from rich.table import Table
885
+ from pathlib import Path
886
+ from .markpact.parser import extract_script_from_markdown, extract_script_by_ref
887
+ from .apply.executor import Executor, MigrationPlan, MigrationStep, StepAction, StepStatus
888
+
889
+ console = Console()
890
+ md_path = Path(markdown_file)
891
+
892
+ if not md_path.exists():
893
+ console.print(f"[red]✗ File not found: {md_path}[/red]")
894
+ sys.exit(1)
895
+
896
+ # Parse refs
897
+ ref_list = [r.strip() for r in refs.split(",")]
898
+ console.print(f"[bold]Loading {len(ref_list)} scripts from {md_path}...[/bold]")
899
+
900
+ md_content = md_path.read_text(encoding="utf-8")
901
+
902
+ # Extract all scripts
903
+ scripts: list[tuple[str, str, str]] = [] # (ref_id, script, lookup_method)
904
+ for ref_id in ref_list:
905
+ # Try markpact:ref first
906
+ script = extract_script_by_ref(md_content, ref_id, language="bash")
907
+ lookup_method = "markpact:ref"
908
+
909
+ if script is None and ref_id.startswith("#"):
910
+ # Try section lookup
911
+ script = extract_script_from_markdown(md_content, ref_id[1:], language="bash")
912
+ lookup_method = "section"
913
+
914
+ if script is None:
915
+ console.print(f"[red]✗ Could not find script: {ref_id}[/red]")
916
+ sys.exit(1)
917
+
918
+ scripts.append((ref_id, script, lookup_method))
919
+ console.print(f" [green]✓[/green] {ref_id} ({len(script)} chars, {lookup_method})")
920
+
921
+ if dry_run:
922
+ console.print(f"\n[bold]Scripts (dry-run):[/bold]")
923
+ for ref_id, script, _ in scripts:
924
+ console.print(f"\n--- {ref_id} ---")
925
+ console.print("```bash")
926
+ console.print(script[:500] + ("..." if len(script) > 500 else ""))
927
+ console.print("```")
928
+ return
929
+
930
+ # Execute scripts
931
+ console.print(f"\n[bold]Executing on {host}...[/bold]")
932
+
933
+ results = []
934
+ for ref_id, script, _ in scripts:
935
+ console.print(f" Running {ref_id}...", end=" ")
936
+
937
+ step = MigrationStep(
938
+ id=f"exec_{ref_id}",
939
+ action=StepAction.INLINE_SCRIPT,
940
+ description=f"Execute script from {ref_id}",
941
+ command=script,
942
+ timeout=timeout,
943
+ )
944
+ plan = MigrationPlan(
945
+ host=host,
946
+ app="exec-multi",
947
+ from_strategy="unknown",
948
+ to_strategy="unknown",
949
+ steps=[step],
950
+ )
951
+
952
+ executor = Executor(plan, dry_run=False)
953
+ ok = executor.run()
954
+ results.append((ref_id, ok))
955
+
956
+ if ok:
957
+ console.print("[green]✓[/green]")
958
+ else:
959
+ console.print("[red]✗[/red]")
960
+
961
+ # Summary table
962
+ console.print(f"\n[bold]Results:[/bold]")
963
+ t = Table(show_header=True, box=None)
964
+ t.add_column("Script")
965
+ t.add_column("Status")
966
+ for ref_id, ok in results:
967
+ status = "[green]✓ OK[/green]" if ok else "[red]✗ Failed[/red]"
968
+ t.add_row(ref_id, status)
969
+ console.print(t)
970
+
971
+ if not all(ok for _, ok in results):
972
+ sys.exit(1)
973
+
974
+ console.print(f"\n[green]✓ All {len(scripts)} scripts executed successfully[/green]")
975
+
976
+
746
977
  # ── plan ──────────────────────────────────────────────────────────────────────
747
978
 
748
979
  @cli.command()
@@ -1047,6 +1278,98 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
1047
1278
  sys.exit(1)
1048
1279
 
1049
1280
 
1281
+ # ── state (resume checkpoints) ───────────────────────────────────────────────
1282
+
1283
+ @cli.command("state")
1284
+ @click.argument("action", type=click.Choice(["show", "clear", "ls"]),
1285
+ default="show")
1286
+ @click.argument("spec_file", default=None, required=False, type=click.Path())
1287
+ @click.option("--host", default=None, help="Override host (default: from spec)")
1288
+ @click.option("--state-file", default=None, type=click.Path(),
1289
+ help="Explicit checkpoint path")
1290
+ @click.pass_context
1291
+ def state_cmd(ctx, action, spec_file, host, state_file):
1292
+ """Inspect or clear resume checkpoints.
1293
+
1294
+ \b
1295
+ Examples:
1296
+ redeploy state show migration.yaml # current checkpoint
1297
+ redeploy state clear migration.yaml # delete checkpoint
1298
+ redeploy state ls # list all checkpoints in CWD
1299
+ """
1300
+ from rich.console import Console
1301
+ from rich.table import Table
1302
+ from .apply.state import DEFAULT_STATE_DIR, ResumeState, default_state_path
1303
+
1304
+ console = Console()
1305
+
1306
+ if action == "ls":
1307
+ base = DEFAULT_STATE_DIR
1308
+ if not base.exists():
1309
+ console.print(f"[dim]no checkpoints under {base}/[/dim]")
1310
+ return
1311
+ files = sorted(base.glob("*.yaml"))
1312
+ if not files:
1313
+ console.print(f"[dim]no checkpoints under {base}/[/dim]")
1314
+ return
1315
+ t = Table(show_header=True, box=None, padding=(0, 2))
1316
+ t.add_column("File", style="bold")
1317
+ t.add_column("Spec")
1318
+ t.add_column("Host", style="cyan")
1319
+ t.add_column("Done")
1320
+ t.add_column("Failed", style="red")
1321
+ t.add_column("Updated", style="dim")
1322
+ for f in files:
1323
+ try:
1324
+ st = ResumeState.load(f)
1325
+ t.add_row(
1326
+ f.name, st.spec_path or "?", st.host,
1327
+ f"{st.completed_count}/{st.total_steps}",
1328
+ st.failed_step_id or "—",
1329
+ st.updated_at,
1330
+ )
1331
+ except Exception as e:
1332
+ t.add_row(f.name, "[red]parse error[/red]", "—", "—", "—", str(e)[:40])
1333
+ console.print(t)
1334
+ return
1335
+
1336
+ # show / clear need a path
1337
+ if state_file:
1338
+ path = Path(state_file)
1339
+ else:
1340
+ if not spec_file:
1341
+ console.print("[red]✗ provide SPEC or --state-file[/red]")
1342
+ sys.exit(2)
1343
+ spec = _load_spec_or_exit(console, spec_file)
1344
+ target_host = host or spec.source.host or "local"
1345
+ path = default_state_path(spec_file, target_host)
1346
+
1347
+ if not path.exists():
1348
+ console.print(f"[dim]no checkpoint at {path}[/dim]")
1349
+ return
1350
+
1351
+ if action == "clear":
1352
+ path.unlink()
1353
+ console.print(f"[green]✓ removed {path}[/green]")
1354
+ return
1355
+
1356
+ # show
1357
+ st = ResumeState.load(path)
1358
+ console.print(f"[bold]checkpoint[/bold] {path}")
1359
+ console.print(f" spec: {st.spec_path}")
1360
+ console.print(f" host: {st.host}")
1361
+ console.print(f" progress: {st.completed_count}/{st.total_steps} "
1362
+ f"({st.remaining} pending)")
1363
+ console.print(f" started: {st.started_at}")
1364
+ console.print(f" updated: {st.updated_at}")
1365
+ if st.failed_step_id:
1366
+ console.print(f" [red]failed: {st.failed_step_id}[/red]")
1367
+ if st.failed_error:
1368
+ console.print(f" [red]error: {st.failed_error[:200]}[/red]")
1369
+ if st.completed_step_ids:
1370
+ console.print(f" done: {', '.join(st.completed_step_ids)}")
1371
+
1372
+
1050
1373
  # ── helper ────────────────────────────────────────────────────────────────────
1051
1374
 
1052
1375
  def _find_manifest_path() -> str: