spawnio 1.0.8__tar.gz → 1.0.9__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 (146) hide show
  1. {spawnio-1.0.8 → spawnio-1.0.9}/CONTRIBUTING.md +10 -1
  2. {spawnio-1.0.8 → spawnio-1.0.9}/PKG-INFO +5 -4
  3. {spawnio-1.0.8 → spawnio-1.0.9}/README.md +4 -3
  4. {spawnio-1.0.8 → spawnio-1.0.9}/docs/architecture.md +88 -30
  5. {spawnio-1.0.8 → spawnio-1.0.9}/docs/changelog.md +20 -0
  6. {spawnio-1.0.8 → spawnio-1.0.9}/docs/commands.md +41 -22
  7. {spawnio-1.0.8 → spawnio-1.0.9}/docs/getting-started.md +2 -2
  8. {spawnio-1.0.8 → spawnio-1.0.9}/pyproject.toml +12 -1
  9. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/__init__.py +1 -1
  10. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/cli/app.py +53 -65
  11. spawnio-1.0.9/src/spawn/cli/errors.py +29 -0
  12. spawnio-1.0.9/src/spawn/cli/noninteractive.py +125 -0
  13. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/cli/prompts.py +7 -11
  14. spawnio-1.0.9/src/spawn/core/exceptions.py +50 -0
  15. spawnio-1.0.9/src/spawn/core/models.py +58 -0
  16. spawnio-1.0.8/src/spawn/cli/noninteractive.py → spawnio-1.0.9/src/spawn/core/planning.py +33 -91
  17. spawnio-1.0.9/src/spawn/core/project.py +59 -0
  18. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/generators/custom_structure.py +38 -15
  19. spawnio-1.0.9/src/spawn/generators/destination.py +66 -0
  20. spawnio-1.0.9/src/spawn/generators/metadata.py +45 -0
  21. spawnio-1.0.9/src/spawn/generators/pipeline.py +32 -0
  22. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/generators/project_files.py +2 -2
  23. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/generators/project_generator.py +44 -43
  24. spawnio-1.0.9/src/spawn/github/exceptions.py +5 -0
  25. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/github/publisher.py +2 -1
  26. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/git.py +9 -6
  27. spawnio-1.0.9/src/spawn/utils/redact.py +8 -0
  28. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/success.py +8 -2
  29. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/uv.py +7 -5
  30. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/validators.py +6 -4
  31. spawnio-1.0.9/tests/conftest.py +46 -0
  32. spawnio-1.0.9/tests/fixtures/__init__.py +0 -0
  33. spawnio-1.0.9/tests/fixtures/archetypes.py +11 -0
  34. spawnio-1.0.9/tests/fixtures/static/broken_meta/.spawn/meta.json +1 -0
  35. spawnio-1.0.9/tests/fixtures/static/meta_unknown_intent/.spawn/meta.json +11 -0
  36. spawnio-1.0.9/tests/fixtures/static/non_spawn_python_repo/pyproject.toml +3 -0
  37. spawnio-1.0.9/tests/fixtures/static/non_spawn_python_repo/src/pkg/__init__.py +1 -0
  38. spawnio-1.0.9/tests/fixtures/static/non_spawn_python_repo/tests/test_x.py +2 -0
  39. spawnio-1.0.9/tests/fixtures/trees/agent.txt +20 -0
  40. spawnio-1.0.9/tests/fixtures/trees/automation.txt +20 -0
  41. spawnio-1.0.9/tests/fixtures/trees/backend-api.txt +16 -0
  42. spawnio-1.0.9/tests/fixtures/trees/chatbot.txt +22 -0
  43. spawnio-1.0.9/tests/fixtures/trees/cli.txt +12 -0
  44. spawnio-1.0.9/tests/fixtures/trees/custom.txt +4 -0
  45. spawnio-1.0.9/tests/fixtures/trees/data.txt +12 -0
  46. spawnio-1.0.9/tests/fixtures/trees/mcp.txt +11 -0
  47. spawnio-1.0.9/tests/fixtures/trees/rag.txt +21 -0
  48. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_app.py +20 -22
  49. spawnio-1.0.9/tests/test_boundaries.py +35 -0
  50. spawnio-1.0.9/tests/test_create_flags.py +203 -0
  51. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_custom_structure.py +5 -3
  52. spawnio-1.0.9/tests/test_destination.py +238 -0
  53. spawnio-1.0.9/tests/test_docs.py +42 -0
  54. spawnio-1.0.9/tests/test_errors.py +219 -0
  55. spawnio-1.0.9/tests/test_fixtures.py +158 -0
  56. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_generator.py +4 -2
  57. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_models.py +44 -0
  58. spawnio-1.0.9/tests/test_pipeline.py +135 -0
  59. spawnio-1.0.9/tests/test_planning.py +160 -0
  60. spawnio-1.0.9/tests/test_project.py +88 -0
  61. spawnio-1.0.8/.coderabbit.yaml +0 -105
  62. spawnio-1.0.8/.github/ISSUE_TEMPLATE/bug_report.yml +0 -77
  63. spawnio-1.0.8/.github/ISSUE_TEMPLATE/config.yml +0 -5
  64. spawnio-1.0.8/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
  65. spawnio-1.0.8/.github/PULL_REQUEST_TEMPLATE.md +0 -24
  66. spawnio-1.0.8/.github/labeler.yml +0 -25
  67. spawnio-1.0.8/.github/workflows/ci.yml +0 -29
  68. spawnio-1.0.8/.github/workflows/pr-check.yml +0 -60
  69. spawnio-1.0.8/.github/workflows/publish.yml +0 -24
  70. spawnio-1.0.8/.python-version +0 -1
  71. spawnio-1.0.8/assets/preview.png +0 -0
  72. spawnio-1.0.8/src/spawn/core/exceptions.py +0 -8
  73. spawnio-1.0.8/src/spawn/core/models.py +0 -22
  74. spawnio-1.0.8/src/spawn/github/exceptions.py +0 -5
  75. spawnio-1.0.8/uv.lock +0 -333
  76. {spawnio-1.0.8 → spawnio-1.0.9}/.gitignore +0 -0
  77. {spawnio-1.0.8 → spawnio-1.0.9}/LICENSE +0 -0
  78. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/cli/__init__.py +0 -0
  79. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/core/__init__.py +0 -0
  80. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/core/registry.py +0 -0
  81. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/generators/__init__.py +0 -0
  82. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/github/__init__.py +0 -0
  83. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/github/validators.py +0 -0
  84. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/__init__.py +0 -0
  85. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/agent/__init__.py +0 -0
  86. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/agent/content.py +0 -0
  87. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/automation/__init__.py +0 -0
  88. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/automation/content.py +0 -0
  89. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/backend_api/__init__.py +0 -0
  90. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/backend_api/content.py +0 -0
  91. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/base.py +0 -0
  92. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/chatbot/__init__.py +0 -0
  93. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/chatbot/content.py +0 -0
  94. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/cli_application/__init__.py +0 -0
  95. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/cli_application/content.py +0 -0
  96. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/__init__.py +0 -0
  97. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/analysis/__init__.py +0 -0
  98. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/analysis/content.py +0 -0
  99. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/dashboard/__init__.py +0 -0
  100. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/dashboard/content.py +0 -0
  101. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/etl/__init__.py +0 -0
  102. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/etl/content.py +0 -0
  103. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/ml/__init__.py +0 -0
  104. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/data_project/ml/content.py +0 -0
  105. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/mcp_server/__init__.py +0 -0
  106. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/mcp_server/content.py +0 -0
  107. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/rag/__init__.py +0 -0
  108. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/rag/content.py +0 -0
  109. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/templates/shared_content.py +0 -0
  110. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/__init__.py +0 -0
  111. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/banner.py +0 -0
  112. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/console.py +0 -0
  113. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/doctor.py +0 -0
  114. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/doctor_fix.py +0 -0
  115. {spawnio-1.0.8 → spawnio-1.0.9}/src/spawn/utils/theme.py +0 -0
  116. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_agent_generator.py +0 -0
  117. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_agent_template.py +0 -0
  118. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_automation_generator.py +0 -0
  119. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_automation_template.py +0 -0
  120. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_chatbot_generator.py +0 -0
  121. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_chatbot_template.py +0 -0
  122. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_cli_application_generator.py +0 -0
  123. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_cli_application_template.py +0 -0
  124. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_console.py +0 -0
  125. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_custom_structure_integration.py +0 -0
  126. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_data_project_analysis.py +0 -0
  127. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_data_project_dashboard.py +0 -0
  128. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_data_project_etl.py +0 -0
  129. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_data_project_gitignore.py +0 -0
  130. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_data_project_ml.py +0 -0
  131. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_doctor.py +0 -0
  132. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_doctor_fix.py +0 -0
  133. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_git.py +0 -0
  134. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_github_publisher.py +0 -0
  135. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_github_validators.py +0 -0
  136. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_mcp_server_generator.py +0 -0
  137. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_mcp_server_template.py +0 -0
  138. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_noninteractive.py +0 -0
  139. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_project_files.py +0 -0
  140. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_prompts.py +0 -0
  141. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_rag_generator.py +0 -0
  142. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_rag_template.py +0 -0
  143. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_registry.py +0 -0
  144. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_templates.py +0 -0
  145. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_uv.py +0 -0
  146. {spawnio-1.0.8 → spawnio-1.0.9}/tests/test_validators.py +0 -0
@@ -35,6 +35,15 @@ uv run pytest --cov=src/spawn --cov-report=term-missing
35
35
 
36
36
  ---
37
37
 
38
+ ## Testing and fixtures
39
+
40
+ - **Factory:** the `spawn_project` fixture in `tests/conftest.py` generates a project offline: `spawn_project("backend-api", framework="fastapi")` returns its `Path`. The `no_toolchain` fixture stubs git, uv and package installs.
41
+ - **Golden trees:** `tests/fixtures/trees/<archetype>.txt` lists every file of a generated archetype. After an intentional output change, regenerate with `SPAWN_UPDATE_TREES=1 uv run pytest tests/test_fixtures.py` and review the diff.
42
+ - **Offline rule:** tests must not use the network, real `git` or real `uv`; use `no_toolchain`.
43
+ - **Adding an archetype:** add an entry to `tests/fixtures/archetypes.py`, run once with `SPAWN_UPDATE_TREES=1` to create its golden tree, and review it.
44
+
45
+ ---
46
+
38
47
  ## Linting and formatting
39
48
 
40
49
  ```bash
@@ -126,7 +135,7 @@ Add two test files mirroring the existing pattern:
126
135
  | File | What to test |
127
136
  |---|---|
128
137
  | `tests/test_<slug>_template.py` | Template constants, `get_dependencies()`, `get_readme_content()`, `py_compile` on each Python starter file, no unescaped braces |
129
- | `tests/test_<slug>_generator.py` | `ProjectGenerator().generate()` with mocked uv/git/install, directory structure, README content, `.spawn/meta.json` fields |
138
+ | `tests/test_<slug>_generator.py` | `generate_project(config)` (or `ProjectGenerator().generate()`) with mocked uv/git/install, directory structure, README content, `.spawn/meta.json` fields |
130
139
 
131
140
  See `tests/test_mcp_server_template.py` and `tests/test_mcp_server_generator.py` for the exact mock patterns (`_mock_uv_and_install()` context manager, `_cfg()` helper, `patch.object(MyTemplate, "post_install")`).
132
141
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spawnio
3
- Version: 1.0.8
3
+ Version: 1.0.9
4
4
  Summary: Spawn a ready-to-use Python project in seconds
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -144,7 +144,7 @@ Running `spawn` with no arguments shows the banner, the installed version, and t
144
144
 
145
145
  ```
146
146
  SPAWN — scaffold your next project
147
- v1.0.8
147
+ v1.0.9
148
148
 
149
149
  Commands
150
150
  create Scaffold a new project
@@ -710,7 +710,7 @@ spawn create --name my-api --template backend-api --framework fastapi --extras r
710
710
  spawn create --config spawn.json
711
711
  ```
712
712
 
713
- `--config` takes precedence over individual flags. Add `--dry-run` to either form to validate the config and print it without creating any files.
713
+ `--config` takes precedence over individual flags (an explicit `--no-git`/`--no-uv` is ignored with a warning). Add `--dry-run` to either form to validate the config and print it without creating any files.
714
714
 
715
715
  > Custom Structure is interactive-only in this version.
716
716
 
@@ -791,7 +791,7 @@ spawn doctor --fix --license mit
791
791
 
792
792
  ```bash
793
793
  spawn version
794
- # → Spawn v1.0.8
794
+ # → Spawn v1.0.9
795
795
  ```
796
796
 
797
797
  ### Publish to GitHub
@@ -825,6 +825,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/
825
825
 
826
826
  | Version | Highlight |
827
827
  |---|---|
828
+ | **v1.0.9** | Core hardening: documented exit-code contract (generation, toolchain and publish failures exit non-zero), `--no-uv` honored for templates (file-only extras still written), path-traversal protection for custom structures, `read_project_metadata` extension boundary |
828
829
  | **v1.0.8** | `spawn doctor --fix` (`--dry-run`, `--yes`, `--license`), `--license` flag and prompt on `create`, `CHANGELOG.md` generated for every project, `pre-commit` and `mypy` extras across all templates, Custom Structure Mypy option |
829
830
  | **v1.0.7** | Fixed UTF-8 console crash on Windows when stdout is redirected/piped, added Windows reserved device name validation (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`9`, `LPT1`–`9`), clarified Python 3.12 requirement |
830
831
  | **v1.0.6** | Removed duplicate banner from `spawn create`; `spawn` alone now shows the banner and command overview |
@@ -133,7 +133,7 @@ Running `spawn` with no arguments shows the banner, the installed version, and t
133
133
 
134
134
  ```
135
135
  SPAWN — scaffold your next project
136
- v1.0.8
136
+ v1.0.9
137
137
 
138
138
  Commands
139
139
  create Scaffold a new project
@@ -699,7 +699,7 @@ spawn create --name my-api --template backend-api --framework fastapi --extras r
699
699
  spawn create --config spawn.json
700
700
  ```
701
701
 
702
- `--config` takes precedence over individual flags. Add `--dry-run` to either form to validate the config and print it without creating any files.
702
+ `--config` takes precedence over individual flags (an explicit `--no-git`/`--no-uv` is ignored with a warning). Add `--dry-run` to either form to validate the config and print it without creating any files.
703
703
 
704
704
  > Custom Structure is interactive-only in this version.
705
705
 
@@ -780,7 +780,7 @@ spawn doctor --fix --license mit
780
780
 
781
781
  ```bash
782
782
  spawn version
783
- # → Spawn v1.0.8
783
+ # → Spawn v1.0.9
784
784
  ```
785
785
 
786
786
  ### Publish to GitHub
@@ -814,6 +814,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/
814
814
 
815
815
  | Version | Highlight |
816
816
  |---|---|
817
+ | **v1.0.9** | Core hardening: documented exit-code contract (generation, toolchain and publish failures exit non-zero), `--no-uv` honored for templates (file-only extras still written), path-traversal protection for custom structures, `read_project_metadata` extension boundary |
817
818
  | **v1.0.8** | `spawn doctor --fix` (`--dry-run`, `--yes`, `--license`), `--license` flag and prompt on `create`, `CHANGELOG.md` generated for every project, `pre-commit` and `mypy` extras across all templates, Custom Structure Mypy option |
818
819
  | **v1.0.7** | Fixed UTF-8 console crash on Windows when stdout is redirected/piped, added Windows reserved device name validation (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`9`, `LPT1`–`9`), clarified Python 3.12 requirement |
819
820
  | **v1.0.6** | Removed duplicate banner from `spawn create`; `spawn` alone now shows the banner and command overview |
@@ -23,7 +23,7 @@ Entry point: `spawn.cli.app:main` (defined in `pyproject.toml` as `[project.scri
23
23
 
24
24
  ```
25
25
  src/spawn/
26
- ├── __init__.py # __version__ via importlib.metadata, fallback "1.0.8"
26
+ ├── __init__.py # __version__ via importlib.metadata, fallback "1.0.9"
27
27
  ├── cli/
28
28
  │ ├── app.py # Typer app: create, version, doctor commands
29
29
  │ ├── noninteractive.py # Build config from CLI flags or JSON config file
@@ -76,14 +76,18 @@ src/spawn/
76
76
  ### 4a. Project Creation Flow
77
77
 
78
78
  1. User runs `spawn create`
79
- 2. `app.create()` calls `show_banner()`
80
- 3. `get_project_config()` collects name, template, framework, extras, and Git preference
81
- 4. `ProjectGenerator.generate()` resolves the template via `instantiate_template(config)`,
82
- creates directories, writes starter files, writes README and `.gitignore`, runs
83
- `initialize_git()` (if enabled), `initialize_uv()`, `install_packages()`, `post_install()`,
84
- and writes `.spawn/meta.json`
85
- 5. `show_success()` renders the success panel using `template_obj.next_steps`
86
- 6. If Git was enabled, optionally `GitHubPublisher.publish()` stages, commits, and pushes
79
+ 2. `app.create()` builds a `ProjectConfig`:
80
+ - non-interactive (`--name` or `--config`): `cli/noninteractive.py` calls `core/planning.py::plan_project`, which validates the name, rejects an existing destination, and resolves template options, extras and license. Config-file mode ignores explicit `--no-git`/`--no-uv` with a warning.
81
+ - interactive: `cli/prompts.py::get_project_config()` collects the answers (menus derived from the registry) and builds the config directly
82
+ 3. `--dry-run` prints the resolved config and stops; nothing is written
83
+ 4. `generators/pipeline.py::generate_project(config)` dispatches on `config.template`:
84
+ `ProjectGenerator.generate()` for registry templates, `CustomStructureGenerator.generate()` for `"custom"`
85
+ 5. Both generators call `generators/destination.py`: `resolve_destination` (single-directory name check), `assert_available` (fails with `FilesystemError` if the path exists in any form, including symlinks) and, for custom structures, `safe_join` (rejects traversal and absolute paths)
86
+ 6. `ProjectGenerator` resolves the template via `instantiate_template(config)`, creates directories, writes starter files, README and `.gitignore`, runs `initialize_git()`, `initialize_uv()`, `install_packages()` and `post_install()` (the uv steps are skipped with `--no-uv`; file-only extras are still written and the rest are reported in one "Skipped without uv" warning), then `generators/metadata.py::write_project_meta`
87
+ 7. `show_success()` renders the success panel
88
+ 8. If Git was enabled and not in non-interactive/`--yes` mode, optionally `GitHubPublisher.publish()` stages, commits, and pushes
89
+
90
+ **Cleanup rule:** on any failure a generator removes the project directory only if that run created it (`destination.cleanup_created`, which also refuses symlinks, the cwd and its parents, the home directory and filesystem roots). A pre-existing directory is never touched.
87
91
 
88
92
  ```
89
93
  spawn create
@@ -92,26 +96,23 @@ spawn create
92
96
  show_banner() banner.py
93
97
  │
94
98
  ▼
95
- get_project_config() prompts.py
96
- │ → ProjectConfig (name, template, framework, extras, use_git)
99
+ plan_project() planning.py (flags / --config)
100
+ get_project_config() prompts.py (interactive)
101
+ │ → ProjectConfig
97
102
  ▼
98
- ProjectGenerator.generate() project_generator.py
99
- ├── instantiate_template() registry.py (forwards framework + extras)
100
- ├── mkdir + write files templates/
101
- ├── write README.md shared_content.py
102
- ├── write .gitignore shared_content.py
103
- ├── initialize_git() git.py (if use_git)
104
- ├── initialize_uv() uv.py
105
- ├── install_packages() uv.py (if deps non-empty)
106
- ├── post_install() template (ruff/pytest config, Docker, CI)
107
- └── write .spawn/meta.json
103
+ generate_project() pipeline.py
104
+ ├── ProjectGenerator / CustomStructureGenerator
105
+ │ ├── resolve_destination + assert_available destination.py
106
+ │ ├── instantiate_template() registry.py (forwards framework + extras)
107
+ │ ├── mkdir + write files, README, .gitignore
108
+ │ ├── initialize_git() git.py (if use_git)
109
+ │ ├── initialize_uv() / install_packages() (if use_uv)
110
+ │ └── post_install() template (ruff/pytest config, Docker, CI)
111
+ └── write_project_meta() metadata.py → .spawn/meta.json
112
+ (called by ProjectGenerator, or by generate_project for custom)
108
113
  │
109
114
  ▼
110
115
  show_success() success.py
111
- └── template_obj.next_steps (formatted with project_name)
112
- │
113
- ▼
114
- GitHubPublisher.publish() publisher.py (optional)
115
116
  ```
116
117
 
117
118
  ### 4b. Template System
@@ -173,13 +174,19 @@ and/or `.github/workflows/ci.yml` depending on `self.extras`.
173
174
  ### 4d. Error Handling
174
175
 
175
176
  ```
176
- SpawnError # core/exceptions.py
177
- └── GitHubPublishError # github/exceptions.py
177
+ SpawnError exit 1 core/exceptions.py
178
+ ├── InvalidInputError exit 1
179
+ │ └── StructureParseError exit 1
180
+ ├── ConfigError exit 1
181
+ ├── FilesystemError exit 3
182
+ ├── ToolchainError exit 4
183
+ ├── GenerationError exit 5
184
+ │ └── TemplateError exit 5
185
+ └── PublishError exit 6
186
+ └── GitHubPublishError exit 6 github/exceptions.py
178
187
  ```
179
188
 
180
- `ProjectGenerator.generate()` wraps its body in try/except. Any `OSError` is converted to
181
- `SpawnError`; any `BaseException` (including `SpawnError`) triggers `shutil.rmtree()` rollback
182
- before re-raising. The CLI catches both exception types and prints `❌ {message}` in red.
189
+ Generators convert `OSError` to `FilesystemError`, remove only the directory the current run created, and re-raise. `cli/errors.py` owns the CLI handlers: `fail(e)` prints `❌ message` in red and exits with `e.exit_code`; `report_unexpected(e)` handles any other exception, printing a short message and exiting 10 (`UNEXPECTED_EXIT_CODE`), or re-raising for a full traceback when `SPAWN_DEBUG` is set to anything other than empty/`0`/`false`/`no`. Ctrl+C, EOF and declined prompts exit 130; Click usage errors exit 2. See [Exit codes](commands.md#exit-codes).
183
190
 
184
191
  ## 5. Adding a New Template
185
192
 
@@ -197,3 +204,54 @@ uv run pytest -v
197
204
  uv run pytest tests/test_generator.py
198
205
  uv run ruff check .
199
206
  ```
207
+
208
+ ## 7. Pipeline
209
+
210
+ ```
211
+ input -> plan_project -> ProjectConfig -> generate_project -> filesystem -> meta -> publish (optional)
212
+ ```
213
+
214
+ | Step | Owner |
215
+ |---|---|
216
+ | Input (flags, `--config` JSON, prompts) | `cli/noninteractive.py`, `cli/prompts.py` |
217
+ | Plan: validate and resolve options | `core/planning.py::plan_project` |
218
+ | Canonical model | `core/models.py::ProjectConfig` |
219
+ | Generate: folders, files, git, uv, rollback | `generators/pipeline.py::generate_project` |
220
+ | Meta: `.spawn/meta.json` | `generators/metadata.py::write_project_meta` |
221
+ | Publish | `github/publisher.py::GitHubPublisher` (called from `cli/app.py`) |
222
+
223
+ ## 8. Canonical model
224
+
225
+ `ProjectConfig` is the single model passed between stages.
226
+
227
+ | Field | Meaning |
228
+ |---|---|
229
+ | `name`, `template` | Project name; template slug (`"custom"` for pasted structures) |
230
+ | `framework`, `provider`, `cli_type`, `data_type` | Template options |
231
+ | `extras`, `license`, `generate_claude_md` | Optional tooling, license (`mit`/`none`), also write `CLAUDE.md` |
232
+ | `use_git`, `use_uv` | Toolchain steps |
233
+ | `custom_*` | Custom-structure entries, dependencies, dev setup, gitignore extras, source format |
234
+ | `destination` | Target directory (`Path` or `None`); excluded from equality |
235
+
236
+ `to_dict()` returns a JSON-serializable dict of every field (`destination` as a string).
237
+
238
+ `.spawn/meta.json` is a **historical record, not authoritative**: it stores `intent`, `framework`, `provider`, `spawn_version`, `created_at`, `generator`, `git`, `uv` and `source` only. It does not record `name`, `cli_type`, `data_type`, `extras`, `license` or `generate_claude_md`, and the project may have changed since. `ProjectConfig` is generation intent only; it cannot be reconstructed from metadata.
239
+
240
+ ## 9. Extension boundary
241
+
242
+ Code that analyses existing projects must not depend on the CLI. It obtains state through `core/`, `generators/` and `templates/`:
243
+
244
+ | Need | Use |
245
+ |---|---|
246
+ | State of an existing project | `core.project.read_project_metadata(path)`: a `ProjectMetadata`, or `None` if there is no `.spawn/meta.json`; raises `ConfigError` if the file is unreadable or invalid. Read-only; does not validate the intent against the registry |
247
+ | Resolve options into a config | `core.planning.plan_project(...)` |
248
+ | Template catalogue | `core.registry.get_metadata()`, `list_templates()` |
249
+ | Create a project | `generators.pipeline.generate_project(config)` |
250
+
251
+ **Rule:** no module under `core/`, `generators/`, `templates/`, `utils/` or `github/` may import `spawn.cli`. `tests/test_boundaries.py` enforces this.
252
+
253
+ To add a template: subclass `BaseTemplate` in `templates/<slug>/` and add a `TemplateMetadata` entry to `TEMPLATES` (see section 5).
254
+
255
+ ## 10. Error model
256
+
257
+ All Spawn errors derive from `SpawnError` (`core/exceptions.py`); each class carries the CLI `exit_code`. The CLI prints `❌ message` and exits with that code. See [Exit codes](commands.md#exit-codes).
@@ -3,6 +3,26 @@
3
3
  All notable changes to Spawn are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
5
 
6
+ ## v1.0.9 — 2026
7
+
8
+ ### Added
9
+
10
+ - **Canonical `ProjectConfig`** — gains `destination` and `to_dict()`; `core/planning.py::plan_project` resolves options into a config and `generators/pipeline.py::generate_project` builds it.
11
+ - **`core/project.py::read_project_metadata`** — CLI-free, read-only `ProjectMetadata` from `.spawn/meta.json` (`None` if absent, `ConfigError` if invalid). Metadata is historical, not authoritative; `ProjectConfig` is generation intent only.
12
+ - **Fixtures and regression tests** — archetype factory, golden file trees (`SPAWN_UPDATE_TREES=1`), and an import-boundary test (nothing outside `cli/` imports `spawn.cli`).
13
+
14
+ ### Changed
15
+
16
+ - `--no-uv` is now honored for template projects: uv init, `uv add` and extras that need `pyproject.toml` are skipped (one yellow "Skipped without uv: ..." line lists them); file-only extras such as `pre-commit` are still written.
17
+ - Interactive `spawn create --dry-run` now validates and prints the config without writing anything.
18
+ - `--config` combined with an explicit `--no-git`/`--no-uv` prints a warning that the config file takes precedence.
19
+ - **Error and exit-code contract** — generation, toolchain (git/uv) and publish failures now exit non-zero (3–6) instead of 0; unexpected errors exit 10 with a short message (`SPAWN_DEBUG=1` shows the traceback). Declining the final custom-structure prompt now cancels cleanly (130) instead of raising a traceback. Git/GitHub error text no longer includes URL credentials. See the Exit codes table in `docs/commands.md`.
20
+
21
+ ### Security
22
+
23
+ - Custom-structure paths that escape the project directory (path traversal) are rejected.
24
+ - Failure cleanup only removes directories created by the current run.
25
+
6
26
  ## v1.0.8 — 2026
7
27
 
8
28
  ### Added
@@ -13,11 +13,12 @@
13
13
 
14
14
  ## `spawn` (no arguments)
15
15
 
16
- Running `spawn` with no arguments prints the banner, the installed version, and the command list.
16
+ Running `spawn` with no arguments prints the ASCII banner, the installed version, and the command list. It takes no arguments and no options other than `--help`, `--install-completion` and `--show-completion`, exits 0, and writes nothing.
17
17
 
18
18
  ```
19
- SPAWN — scaffold your next project
20
- v1.0.8
19
+ <ASCII SPAWN banner — "scaffold your next project">
20
+
21
+ v1.0.9
21
22
 
22
23
  Commands
23
24
  create Scaffold a new project
@@ -33,6 +34,13 @@ Run spawn COMMAND --help for details on a command.
33
34
 
34
35
  Creates a new project directory, writes starter files, installs dependencies, and optionally runs `git init` and `uv init`.
35
36
 
37
+ **Contract**
38
+
39
+ - **Arguments:** none. Options and defaults are in the flag table under *Non-interactive mode* (`--git`, `--uv`, `--no-claude-md`, `--license mit`).
40
+ - **Success:** the project is created in `./<name>`, a success panel is shown, exit code 0. `--dry-run` validates and prints the resolved config, writes nothing, exit code 0.
41
+ - **Failure:** `❌ message` is printed and the command exits non-zero (see [Exit codes](#exit-codes)). A partially written project directory is removed.
42
+ - **Filesystem:** writes only a new directory `./<name>`. It fails if that directory already exists (exit 3) and never modifies existing files.
43
+
36
44
  ### Interactive mode
37
45
 
38
46
  Run with no flags to enter the arrow-key prompt flow:
@@ -143,9 +151,9 @@ spawn create --config spawn.json
143
151
  | `--extras` | none | Comma-separated extras, e.g. `ruff,pytest` |
144
152
  | `--license` | `mit` | Project license (`mit` or `none`) |
145
153
  | `--git` / `--no-git` | `--git` | Initialize a Git repository |
146
- | `--uv` / `--no-uv` | `--uv` | Initialize uv and install dependencies |
154
+ | `--uv` / `--no-uv` | `--uv` | Initialize uv and install dependencies. With `--no-uv`, extras that need `pyproject.toml` are skipped (one yellow "Skipped without uv" line lists them); file-only extras such as `pre-commit` are still written |
147
155
  | `--claude-md` / `--no-claude-md` | `--no-claude-md` | Also write `CLAUDE.md` alongside `AGENTS.md` |
148
- | `--config` | — | Path to a JSON config file |
156
+ | `--config` | — | Path to a JSON config file. Takes precedence over other flags; an explicit `--no-git`/`--no-uv` is ignored with a yellow warning |
149
157
  | `--yes` / `-y` | false | Skip the GitHub publish prompt |
150
158
  | `--dry-run` | false | Validate and print the config without creating anything |
151
159
 
@@ -379,7 +387,7 @@ Every generated project includes:
379
387
  | `CHANGELOG.md` | Keep a Changelog starter documenting the initial release |
380
388
  | `LICENSE` | MIT license (when `--license mit`, the default) |
381
389
  | `.gitignore` | Python defaults (`.venv/`, `__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, etc.) |
382
- | `.spawn/meta.json` | `intent`, `framework`, `provider`, `spawn_version`, `created_at`, `git`, `uv` |
390
+ | `.spawn/meta.json` | `intent`, `framework`, `provider`, `spawn_version`, `created_at`, `generator`, `git`, `uv`, `source` |
383
391
 
384
392
  If `--claude-md` was passed (or `"claude_md": true` in the config file), a `CLAUDE.md` file identical to `AGENTS.md` is also written.
385
393
 
@@ -390,7 +398,7 @@ If `--claude-md` was passed (or `"claude_md": true` in the config file), a `CLAU
390
398
  "intent": "backend-api",
391
399
  "framework": "fastapi",
392
400
  "provider": null,
393
- "spawn_version": "1.0.8",
401
+ "spawn_version": "1.0.9",
394
402
  "created_at": "2026-01-01T00:00:00+00:00",
395
403
  "generator": "blueprint",
396
404
  "git": true,
@@ -422,14 +430,7 @@ In non-interactive mode or when `--yes` / `-y` is passed, the publish prompt is
422
430
 
423
431
  ### Error cases
424
432
 
425
- All errors print `❌ message` in red. Any partial directory is deleted on failure.
426
-
427
- | Situation | Exit code |
428
- |---|---|
429
- | Successful creation | 0 |
430
- | SpawnError (bad name, unknown template, etc.) | 0 (error printed) |
431
- | Non-interactive validation error | 1 |
432
- | Ctrl+C at any prompt | 130 |
433
+ All errors print `❌ message` in red and exit with the code for their error class; any partial directory is deleted on failure. See [Exit codes](#exit-codes) for the full table (for example: invalid input 1, usage error 2, existing destination 3, missing `git`/`uv` 4, generation error 5, Ctrl+C 130).
433
434
 
434
435
  ---
435
436
 
@@ -437,6 +438,14 @@ All errors print `❌ message` in red. Any partial directory is deleted on failu
437
438
 
438
439
  Scores the current directory (or a given path) for project health. All checks are filesystem-based — nothing is executed or sent over the network.
439
440
 
441
+ **Contract**
442
+
443
+ - **Arguments:** `[PATH]`, default `.`.
444
+ - **Options:** `--fix`, `--dry-run`, `--yes` / `-y`, `--license TEXT`; all off/unset by default, and the last three only apply with `--fix`.
445
+ - **Success:** prints the health report (score out of 135, rating, recommendations), exit code 0. A low score is not a failure.
446
+ - **Failure:** a path that does not exist or is not a directory prints `❌ message`, exit code 1. Declining the `--fix` confirmation exits 130.
447
+ - **Filesystem:** plain `spawn doctor` is read-only. `spawn doctor --fix` only creates missing files and config; it never overwrites and never installs packages. `--fix --dry-run` writes nothing.
448
+
440
449
  ```bash
441
450
  spawn doctor
442
451
  spawn doctor ./path/to/project
@@ -511,20 +520,30 @@ spawn doctor --fix --license mit
511
520
 
512
521
  ## `spawn version`
513
522
 
514
- Prints the installed version.
523
+ Prints the installed version. No arguments or options; exit code 0; writes nothing.
515
524
 
516
525
  ```bash
517
526
  spawn version
518
- # Spawn v1.0.8
527
+ # Spawn v1.0.9
519
528
  ```
520
529
 
521
530
  ---
522
531
 
523
532
  ## Exit codes
524
533
 
525
- | Situation | Exit code |
534
+ | Code | Meaning |
526
535
  |---|---|
527
- | Any command succeeds | 0 |
528
- | `spawn doctor` — path does not exist or is not a directory | 1 |
529
- | Non-interactive validation error / flag error | 1 |
530
- | Ctrl+C / EOF at any point | 130 |
536
+ | 0 | Success (including a valid `--dry-run`, and GitHub publishing skipped) |
537
+ | 1 | Invalid input or config (bad name, option value, config file, unknown template; `spawn doctor` bad path/flags) |
538
+ | 2 | CLI usage error (unknown option, reported by Click) |
539
+ | 3 | Filesystem error (directory already exists, OS error while writing) |
540
+ | 4 | Toolchain error (`git` or `uv` missing or failed) |
541
+ | 5 | Generation / template error |
542
+ | 6 | Publish error (GitHub publishing failed) |
543
+ | 10 | Unexpected error (re-run with `SPAWN_DEBUG=1` for a traceback) |
544
+ | 130 | Cancelled (Ctrl+C, EOF, or declining the final prompt) |
545
+
546
+ Failures that happen after the project was created (for example a failed
547
+ GitHub publish) exit non-zero while the generated project remains on disk.
548
+ `spawn doctor --fix` action failures are reported in the output and do not
549
+ change the exit code.
@@ -49,14 +49,14 @@ After installation, `spawn` is available anywhere on your machine.
49
49
 
50
50
  ```bash
51
51
  spawn version
52
- # Spawn v1.0.8
52
+ # Spawn v1.0.9
53
53
  ```
54
54
 
55
55
  Or just run `spawn` with no arguments to see the command overview:
56
56
 
57
57
  ```
58
58
  SPAWN — scaffold your next project
59
- v1.0.8
59
+ v1.0.9
60
60
 
61
61
  Commands
62
62
  create Scaffold a new project
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spawnio"
3
- version = "1.0.8"
3
+ version = "1.0.9"
4
4
  description = "Spawn a ready-to-use Python project in seconds"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -20,6 +20,17 @@ build-backend = "hatchling.build"
20
20
  [tool.hatch.build.targets.wheel]
21
21
  packages = ["src/spawn"]
22
22
 
23
+ [tool.hatch.build.targets.sdist]
24
+ include = [
25
+ "/src",
26
+ "/tests",
27
+ "/docs",
28
+ "/README.md",
29
+ "/LICENSE",
30
+ "/pyproject.toml",
31
+ "/CONTRIBUTING.md",
32
+ ]
33
+
23
34
 
24
35
  [dependency-groups]
25
36
  dev = [
@@ -3,4 +3,4 @@ from importlib.metadata import PackageNotFoundError, version
3
3
  try:
4
4
  __version__ = version("spawnio")
5
5
  except PackageNotFoundError:
6
- __version__ = "1.0.8"
6
+ __version__ = "1.0.9"
@@ -1,5 +1,3 @@
1
- import datetime
2
- import json
3
1
  from pathlib import Path
4
2
 
5
3
  import typer
@@ -8,9 +6,10 @@ from rich.prompt import Confirm, Prompt
8
6
  from spawn import __version__
9
7
  from spawn.cli.noninteractive import build_config_from_args, build_config_from_file
10
8
  from spawn.cli.prompts import get_project_config
11
- from spawn.core.exceptions import SpawnError
9
+ from spawn.cli.errors import fail, report_unexpected
10
+ from spawn.core.exceptions import InvalidInputError, SpawnError
12
11
  from spawn.core.registry import instantiate_template
13
- from spawn.generators.project_generator import ProjectGenerator
12
+ from spawn.generators.pipeline import generate_project
14
13
  from spawn.github.exceptions import GitHubPublishError
15
14
  from spawn.github.publisher import GitHubPublisher
16
15
  from spawn.utils.banner import show_banner
@@ -36,30 +35,23 @@ def main_callback(ctx: typer.Context) -> None:
36
35
  )
37
36
 
38
37
 
39
- def _write_custom_metadata(project_path, config) -> None:
40
- meta_dir = project_path / ".spawn"
41
- meta_dir.mkdir(exist_ok=True)
42
- (meta_dir / "meta.json").write_text(
43
- json.dumps(
44
- {
45
- "intent": "custom",
46
- "framework": None,
47
- "provider": None,
48
- "spawn_version": __version__,
49
- "created_at": datetime.datetime.now(datetime.UTC).isoformat(),
50
- "generator": "custom",
51
- "git": config.use_git,
52
- "uv": config.use_uv,
53
- "source": config.custom_source_format or "tree",
54
- },
55
- indent=2,
56
- ),
57
- encoding="utf-8",
58
- )
38
+ def _given_on_command_line(ctx: typer.Context, param: str) -> bool:
39
+ source = ctx.get_parameter_source(param)
40
+ return getattr(source, "name", None) == "COMMANDLINE"
41
+
42
+
43
+ def _display_info(config) -> tuple[str, list[str]] | None:
44
+ if config.template == "custom":
45
+ return "Custom Structure", [f"cd {config.name}", "Start building your project"]
46
+ template_obj = instantiate_template(config)
47
+ if template_obj is None:
48
+ return None
49
+ return template_obj.name, template_obj.next_steps
59
50
 
60
51
 
61
52
  @app.command()
62
53
  def create(
54
+ ctx: typer.Context,
63
55
  name: str = typer.Option(
64
56
  None, "--name", help="Project name (enables non-interactive mode)"
65
57
  ),
@@ -126,7 +118,7 @@ def create(
126
118
  )
127
119
  else:
128
120
  if template is None:
129
- raise SpawnError(
121
+ raise InvalidInputError(
130
122
  "--template is required when using --name without --config."
131
123
  )
132
124
  extras_list = (
@@ -149,55 +141,43 @@ def create(
149
141
  )
150
142
 
151
143
  except SpawnError as e:
152
- console.print(f"[red]❌ {e}[/red]")
153
- raise typer.Exit(1)
154
-
155
- if dry_run:
156
- console.print("[green]✓ Config valid[/green]")
157
- console.print(config)
158
- return
144
+ fail(e)
145
+
146
+ if config_file is not None:
147
+ for param, value, flag in (
148
+ ("git", git, "--no-git"),
149
+ ("uv", uv, "--no-uv"),
150
+ ):
151
+ if not value and _given_on_command_line(ctx, param):
152
+ console.print(
153
+ f"[yellow]{flag} ignored: --config takes precedence[/yellow]"
154
+ )
159
155
  else:
160
- config = get_project_config()
156
+ try:
157
+ config = get_project_config()
158
+ except SpawnError as e:
159
+ fail(e)
161
160
 
162
- try:
163
- if config.template == "custom":
164
- from spawn.generators.custom_structure import CustomStructureGenerator
161
+ if dry_run:
162
+ console.print("[green]✓ Config valid[/green]")
163
+ console.print(config)
164
+ return
165
165
 
166
- project_path = CustomStructureGenerator().generate(
167
- project_name=config.name,
168
- entries=config.custom_entries or [],
169
- use_git=config.use_git,
170
- use_uv=config.use_uv,
171
- dependencies=config.custom_dependencies,
172
- dev_setup=config.custom_dev_setup,
173
- gitignore_extra=config.custom_gitignore_extra,
174
- generate_claude_md=config.generate_claude_md,
175
- )
176
- _write_custom_metadata(project_path, config)
177
- next_steps = [
178
- f"cd {config.name}",
179
- "Start building your project",
180
- ]
166
+ try:
167
+ project_path = generate_project(config)
168
+ info = _display_info(config)
169
+ if info is not None:
170
+ template_name, next_steps = info
181
171
  show_success(
182
172
  project_name=config.name,
183
- template_name="Custom Structure",
173
+ template_name=template_name,
184
174
  use_git=config.use_git,
185
175
  next_steps=next_steps,
176
+ use_uv=config.use_uv,
186
177
  )
187
- else:
188
- project_path = ProjectGenerator().generate(config)
189
- template_obj = instantiate_template(config)
190
- if template_obj is not None:
191
- show_success(
192
- project_name=config.name,
193
- template_name=template_obj.name,
194
- use_git=config.use_git,
195
- next_steps=template_obj.next_steps,
196
- )
197
178
 
198
179
  except SpawnError as e:
199
- console.print(f"[red]❌ {e}[/red]")
200
- return
180
+ fail(e)
201
181
 
202
182
  if not config.use_git:
203
183
  console.print(
@@ -225,10 +205,14 @@ def create(
225
205
  console.print("[green]🚀 Published successfully![/green]")
226
206
 
227
207
  except GitHubPublishError as e:
228
- console.print(f"[red]❌ {e}[/red]")
208
+ fail(e)
229
209
  except (KeyboardInterrupt, EOFError, typer.Abort):
230
210
  console.print("\n[yellow]Cancelled.[/yellow]")
231
211
  raise typer.Exit(130)
212
+ except typer.Exit:
213
+ raise
214
+ except Exception as e:
215
+ report_unexpected(e)
232
216
 
233
217
 
234
218
  @app.command()
@@ -360,6 +344,10 @@ def doctor(
360
344
  except (KeyboardInterrupt, EOFError, typer.Abort):
361
345
  console.print("\n[yellow]Cancelled.[/yellow]")
362
346
  raise typer.Exit(130)
347
+ except typer.Exit:
348
+ raise
349
+ except Exception as e:
350
+ report_unexpected(e)
363
351
 
364
352
 
365
353
  def main():