spawnio 1.1.0__tar.gz → 1.1.1__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 (165) hide show
  1. {spawnio-1.1.0 → spawnio-1.1.1}/PKG-INFO +6 -5
  2. {spawnio-1.1.0 → spawnio-1.1.1}/README.md +5 -4
  3. {spawnio-1.1.0 → spawnio-1.1.1}/docs/architecture.md +1 -1
  4. {spawnio-1.1.0 → spawnio-1.1.1}/docs/changelog.md +20 -0
  5. {spawnio-1.1.0 → spawnio-1.1.1}/docs/commands.md +4 -4
  6. {spawnio-1.1.0 → spawnio-1.1.1}/docs/getting-started.md +2 -2
  7. {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/supported-technologies.md +8 -5
  8. {spawnio-1.1.0 → spawnio-1.1.1}/pyproject.toml +1 -1
  9. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/__init__.py +1 -1
  10. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/app.py +7 -2
  11. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/errors.py +5 -2
  12. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/custom_structure.py +47 -31
  13. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/destination.py +54 -15
  14. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/pipeline.py +5 -2
  15. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/project_generator.py +5 -2
  16. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/dependencies.py +2 -0
  17. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/frameworks.py +58 -18
  18. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/agent/content.py +4 -3
  19. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/backend_api/__init__.py +5 -1
  20. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/backend_api/content.py +6 -0
  21. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/chatbot/content.py +6 -2
  22. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/analysis/content.py +1 -1
  23. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/etl/content.py +3 -0
  24. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/ml/content.py +2 -3
  25. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/rag/content.py +10 -3
  26. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/redact.py +3 -1
  27. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/uv.py +5 -4
  28. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_custom_structure.py +147 -0
  29. spawnio-1.1.1/tests/test_destination.py +591 -0
  30. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_errors.py +35 -0
  31. spawnio-1.1.1/tests/test_generated_lint.py +95 -0
  32. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_dependencies.py +64 -0
  33. spawnio-1.1.1/tests/test_inspection_false_positives.py +66 -0
  34. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_frameworks.py +173 -2
  35. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_rag_template.py +17 -0
  36. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_templates.py +39 -0
  37. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_uv.py +29 -0
  38. spawnio-1.1.0/tests/test_destination.py +0 -238
  39. {spawnio-1.1.0 → spawnio-1.1.1}/.gitignore +0 -0
  40. {spawnio-1.1.0 → spawnio-1.1.1}/CONTRIBUTING.md +0 -0
  41. {spawnio-1.1.0 → spawnio-1.1.1}/LICENSE +0 -0
  42. {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/inspection-contract.md +0 -0
  43. {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/language-framework-handoff.md +0 -0
  44. {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/safe-discovery.md +0 -0
  45. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/__init__.py +0 -0
  46. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/inspect_render.py +0 -0
  47. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/noninteractive.py +0 -0
  48. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/prompts.py +0 -0
  49. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/__init__.py +0 -0
  50. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/exceptions.py +0 -0
  51. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/models.py +0 -0
  52. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/planning.py +0 -0
  53. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/project.py +0 -0
  54. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/registry.py +0 -0
  55. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/__init__.py +0 -0
  56. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/metadata.py +0 -0
  57. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/project_files.py +0 -0
  58. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/__init__.py +0 -0
  59. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/exceptions.py +0 -0
  60. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/publisher.py +0 -0
  61. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/validators.py +0 -0
  62. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/__init__.py +0 -0
  63. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/_reading.py +0 -0
  64. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/base.py +0 -0
  65. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/discovery.py +0 -0
  66. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/exceptions.py +0 -0
  67. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/languages.py +0 -0
  68. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/models.py +0 -0
  69. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/orchestrator.py +0 -0
  70. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/serialization.py +0 -0
  71. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/tooling.py +0 -0
  72. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/__init__.py +0 -0
  73. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/agent/__init__.py +0 -0
  74. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/automation/__init__.py +0 -0
  75. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/automation/content.py +0 -0
  76. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/base.py +0 -0
  77. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/chatbot/__init__.py +0 -0
  78. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/cli_application/__init__.py +0 -0
  79. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/cli_application/content.py +0 -0
  80. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/__init__.py +0 -0
  81. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/analysis/__init__.py +0 -0
  82. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/dashboard/__init__.py +0 -0
  83. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/dashboard/content.py +0 -0
  84. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/etl/__init__.py +0 -0
  85. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/ml/__init__.py +0 -0
  86. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/mcp_server/__init__.py +0 -0
  87. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/mcp_server/content.py +0 -0
  88. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/rag/__init__.py +0 -0
  89. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/shared_content.py +0 -0
  90. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/__init__.py +0 -0
  91. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/banner.py +0 -0
  92. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/console.py +0 -0
  93. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/doctor.py +0 -0
  94. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/doctor_fix.py +0 -0
  95. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/git.py +0 -0
  96. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/success.py +0 -0
  97. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/theme.py +0 -0
  98. {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/validators.py +0 -0
  99. {spawnio-1.1.0 → spawnio-1.1.1}/tests/conftest.py +0 -0
  100. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/__init__.py +0 -0
  101. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/archetypes.py +0 -0
  102. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/inspection_corpus.py +0 -0
  103. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/make_sample_report.py +0 -0
  104. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/broken_meta/.spawn/meta.json +0 -0
  105. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/meta_unknown_intent/.spawn/meta.json +0 -0
  106. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/non_spawn_python_repo/pyproject.toml +0 -0
  107. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/non_spawn_python_repo/src/pkg/__init__.py +0 -0
  108. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/non_spawn_python_repo/tests/test_x.py +0 -0
  109. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/sample_report.json +0 -0
  110. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/agent.txt +0 -0
  111. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/automation.txt +0 -0
  112. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/backend-api.txt +0 -0
  113. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/chatbot.txt +0 -0
  114. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/cli.txt +0 -0
  115. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/custom.txt +0 -0
  116. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/data.txt +0 -0
  117. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/mcp.txt +0 -0
  118. {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/rag.txt +0 -0
  119. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_agent_generator.py +0 -0
  120. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_agent_template.py +0 -0
  121. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_app.py +0 -0
  122. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_automation_generator.py +0 -0
  123. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_automation_template.py +0 -0
  124. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_boundaries.py +0 -0
  125. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_chatbot_generator.py +0 -0
  126. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_chatbot_template.py +0 -0
  127. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_cli_application_generator.py +0 -0
  128. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_cli_application_template.py +0 -0
  129. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_console.py +0 -0
  130. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_create_flags.py +0 -0
  131. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_custom_structure_integration.py +0 -0
  132. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_analysis.py +0 -0
  133. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_dashboard.py +0 -0
  134. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_etl.py +0 -0
  135. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_gitignore.py +0 -0
  136. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_ml.py +0 -0
  137. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_docs.py +0 -0
  138. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_doctor.py +0 -0
  139. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_doctor_fix.py +0 -0
  140. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_fixtures.py +0 -0
  141. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_generator.py +0 -0
  142. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_git.py +0 -0
  143. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_github_publisher.py +0 -0
  144. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_github_validators.py +0 -0
  145. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspect_cli.py +0 -0
  146. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_discovery.py +0 -0
  147. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_languages.py +0 -0
  148. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_models.py +0 -0
  149. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_orchestrator.py +0 -0
  150. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_regression.py +0 -0
  151. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_sample.py +0 -0
  152. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_serialization.py +0 -0
  153. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_tooling.py +0 -0
  154. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_mcp_server_generator.py +0 -0
  155. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_mcp_server_template.py +0 -0
  156. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_models.py +0 -0
  157. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_noninteractive.py +0 -0
  158. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_pipeline.py +0 -0
  159. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_planning.py +0 -0
  160. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_project.py +0 -0
  161. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_project_files.py +0 -0
  162. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_prompts.py +0 -0
  163. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_rag_generator.py +0 -0
  164. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_registry.py +0 -0
  165. {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spawnio
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Spawn a ready-to-use Python project in seconds
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -145,7 +145,7 @@ Running `spawn` with no arguments shows the banner, the installed version, and t
145
145
 
146
146
  ```
147
147
  SPAWN — scaffold your next project
148
- v1.1.0
148
+ v1.1.1
149
149
 
150
150
  Commands
151
151
  create Scaffold a new project
@@ -793,10 +793,10 @@ spawn doctor --fix --license mit
793
793
 
794
794
  ```bash
795
795
  spawn version
796
- # → Spawn v1.1.0
796
+ # → Spawn v1.1.1
797
797
 
798
798
  spawn --version
799
- # → Spawn v1.1.0
799
+ # → Spawn v1.1.1
800
800
  ```
801
801
 
802
802
  ### `spawn inspect`
@@ -826,7 +826,7 @@ PROJECT OVERVIEW
826
826
 
827
827
  **Scope.** 25 languages by extension and manifest; React, Next.js, Vue, Nuxt, Angular, Svelte, Express, FastAPI, Flask, Django, Starlette, Pydantic and Laravel; dependencies from `pyproject.toml`, `requirements*.txt` and `package.json`; package managers (uv, poetry, pipenv, pip, npm, yarn, pnpm, bun); tooling (ruff, black, flake8, mypy, pyright, pytest, eslint, prettier, jest, vitest, pre-commit, git), CI, containers and databases. Full matrix: [supported technologies](docs/inspection/supported-technologies.md).
828
828
 
829
- **Limitations.** Detection is static and evidence-based: a finding means an indicator exists, not that the project builds or runs. Frameworks seen only in a dependency declaration are marked *inferred*; imports are matched by regex, so import-like text in comments or strings can false-positive. CI, container and pre-commit detection is presence-only. Databases are inferred from driver packages. Manifests such as `Pipfile`, `setup.py`, `Cargo.toml`, `go.mod`, `Gemfile` and `pom.xml` give no dependency findings. Directories named `build`, `dist`, `env`, `target`, `vendor` and the like are skipped at any depth.
829
+ **Limitations.** Detection is static and evidence-based: a finding means an indicator exists, not that the project builds or runs. Frameworks seen only in a dependency declaration are marked *inferred*; Python imports are read from the AST, so strings and comments never match, while JS/TS imports are matched by regex and can false-positive on import-like text in comments or template literals. CI, container and pre-commit detection is presence-only. Databases are inferred from driver packages. Manifests such as `Pipfile`, `setup.py`, `Cargo.toml`, `go.mod`, `Gemfile` and `pom.xml` give no dependency findings. Directories named `build`, `dist`, `env`, `target`, `vendor` and the like are skipped at any depth.
830
830
 
831
831
  **Safety.** `spawn inspect` is read-only: it never runs repository code, installs anything, uses the network or writes to the repository, and it does not follow symbolic links. It exits 0 for complete and partial reports and 7 for an invalid path or failed inspection. See the [command reference](docs/commands.md#spawn-inspect).
832
832
 
@@ -861,6 +861,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/
861
861
 
862
862
  | Version | Highlight |
863
863
  |---|---|
864
+ | **v1.1.1** | Framework detection fix: Python imports read from the AST, so framework code inside strings, docstrings and comments no longer produces findings |
864
865
  | **v1.1.0** | `spawn inspect`: static, read-only repository inspection (languages, frameworks, dependencies, package managers, tooling, layout) with human and `--json` output; `spawn --version` |
865
866
  | **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 |
866
867
  | **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 |
@@ -134,7 +134,7 @@ Running `spawn` with no arguments shows the banner, the installed version, and t
134
134
 
135
135
  ```
136
136
  SPAWN — scaffold your next project
137
- v1.1.0
137
+ v1.1.1
138
138
 
139
139
  Commands
140
140
  create Scaffold a new project
@@ -782,10 +782,10 @@ spawn doctor --fix --license mit
782
782
 
783
783
  ```bash
784
784
  spawn version
785
- # → Spawn v1.1.0
785
+ # → Spawn v1.1.1
786
786
 
787
787
  spawn --version
788
- # → Spawn v1.1.0
788
+ # → Spawn v1.1.1
789
789
  ```
790
790
 
791
791
  ### `spawn inspect`
@@ -815,7 +815,7 @@ PROJECT OVERVIEW
815
815
 
816
816
  **Scope.** 25 languages by extension and manifest; React, Next.js, Vue, Nuxt, Angular, Svelte, Express, FastAPI, Flask, Django, Starlette, Pydantic and Laravel; dependencies from `pyproject.toml`, `requirements*.txt` and `package.json`; package managers (uv, poetry, pipenv, pip, npm, yarn, pnpm, bun); tooling (ruff, black, flake8, mypy, pyright, pytest, eslint, prettier, jest, vitest, pre-commit, git), CI, containers and databases. Full matrix: [supported technologies](docs/inspection/supported-technologies.md).
817
817
 
818
- **Limitations.** Detection is static and evidence-based: a finding means an indicator exists, not that the project builds or runs. Frameworks seen only in a dependency declaration are marked *inferred*; imports are matched by regex, so import-like text in comments or strings can false-positive. CI, container and pre-commit detection is presence-only. Databases are inferred from driver packages. Manifests such as `Pipfile`, `setup.py`, `Cargo.toml`, `go.mod`, `Gemfile` and `pom.xml` give no dependency findings. Directories named `build`, `dist`, `env`, `target`, `vendor` and the like are skipped at any depth.
818
+ **Limitations.** Detection is static and evidence-based: a finding means an indicator exists, not that the project builds or runs. Frameworks seen only in a dependency declaration are marked *inferred*; Python imports are read from the AST, so strings and comments never match, while JS/TS imports are matched by regex and can false-positive on import-like text in comments or template literals. CI, container and pre-commit detection is presence-only. Databases are inferred from driver packages. Manifests such as `Pipfile`, `setup.py`, `Cargo.toml`, `go.mod`, `Gemfile` and `pom.xml` give no dependency findings. Directories named `build`, `dist`, `env`, `target`, `vendor` and the like are skipped at any depth.
819
819
 
820
820
  **Safety.** `spawn inspect` is read-only: it never runs repository code, installs anything, uses the network or writes to the repository, and it does not follow symbolic links. It exits 0 for complete and partial reports and 7 for an invalid path or failed inspection. See the [command reference](docs/commands.md#spawn-inspect).
821
821
 
@@ -850,6 +850,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/
850
850
 
851
851
  | Version | Highlight |
852
852
  |---|---|
853
+ | **v1.1.1** | Framework detection fix: Python imports read from the AST, so framework code inside strings, docstrings and comments no longer produces findings |
853
854
  | **v1.1.0** | `spawn inspect`: static, read-only repository inspection (languages, frameworks, dependencies, package managers, tooling, layout) with human and `--json` output; `spawn --version` |
854
855
  | **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 |
855
856
  | **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 |
@@ -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.1.0" (keep in sync with pyproject.toml)
26
+ ├── __init__.py # __version__ via importlib.metadata, fallback "1.1.1" (keep in sync with pyproject.toml)
27
27
  ├── cli/
28
28
  │ ├── app.py # Typer app: create, version (and --version), doctor, inspect commands
29
29
  │ ├── inspect_render.py # Rich rendering of an InspectionReport for `spawn inspect`
@@ -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.1.1 — 2026
7
+
8
+ ### Fixed
9
+
10
+ - **False-positive frameworks in `spawn inspect`** — Python framework detection (django, fastapi, flask, pydantic, starlette) now reads imports from the AST of `.py` files. Import-like text in string literals, docstrings and comments no longer produces findings; inspecting Spawn itself no longer reports django, fastapi or flask.
11
+ - Python imports are only matched in `.py` files and JS/TS imports only in JS/TS files (previously every pattern ran on every source file).
12
+ - Now detected: `from fastapi.responses import X`, `import os, flask`, and imports nested in functions or `try` blocks.
13
+ - **Filesystem destination safety** — pre-existing symlinks and Windows junctions are rejected; rollback removes only directories created by the current generation run.
14
+ - **Custom structure directories** — trailing-slash directory semantics are preserved across all supported structure formats.
15
+ - **CLI and inspection hardening** — untrusted Rich error text is escaped, inspection regex work is bounded, and URL credentials are redacted from uv error output.
16
+ - **Generated project quality** — Ruff and formatting issues are fixed across supported variants, the RAG fixture uses the response-aware OpenAI status exception, and generated Django CI uses Django's native test runner.
17
+
18
+ ### Changed
19
+
20
+ - A `.py` file that cannot be parsed (syntax error, null bytes) produces a `framework.unparseable_source` warning and the report status becomes `partial`.
21
+
22
+ ### Known limitations
23
+
24
+ - JavaScript/TypeScript framework imports are still regex-matched; import-like text in comments or template literals can false-positive, and multi-line or destructured imports are missed.
25
+
6
26
  ## v1.1.0 — 2026
7
27
 
8
28
  ### Added
@@ -19,7 +19,7 @@ Running `spawn` with no arguments prints the ASCII banner, the installed version
19
19
  ```
20
20
  <ASCII SPAWN banner — "scaffold your next project">
21
21
 
22
- v1.1.0
22
+ v1.1.1
23
23
 
24
24
  Commands
25
25
  create Scaffold a new project
@@ -400,7 +400,7 @@ If `--claude-md` was passed (or `"claude_md": true` in the config file), a `CLAU
400
400
  "intent": "backend-api",
401
401
  "framework": "fastapi",
402
402
  "provider": null,
403
- "spawn_version": "1.1.0",
403
+ "spawn_version": "1.1.1",
404
404
  "created_at": "2026-01-01T00:00:00+00:00",
405
405
  "generator": "blueprint",
406
406
  "git": true,
@@ -582,9 +582,9 @@ Prints the installed version. `spawn --version` prints the same line. No other a
582
582
 
583
583
  ```bash
584
584
  spawn version
585
- # Spawn v1.1.0
585
+ # Spawn v1.1.1
586
586
  spawn --version
587
- # Spawn v1.1.0
587
+ # Spawn v1.1.1
588
588
  ```
589
589
 
590
590
  ---
@@ -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.1.0
52
+ # Spawn v1.1.1
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.1.0
59
+ v1.1.1
60
60
 
61
61
  Commands
62
62
  create Scaffold a new project
@@ -82,16 +82,19 @@ markers, dynamic metadata, lockfiles and build scripts are not resolved.
82
82
  `Gemfile`, `pom.xml` and `build.gradle*` are not parsed for frameworks, so
83
83
  Rails and JVM frameworks are not detected.
84
84
 
85
- Known false negatives: imports split across several lines
85
+ Known false negatives (JS/TS): imports split across several lines
86
86
  (`import {
87
87
  a,
88
88
  b } from 'react'`), destructured `require()` calls
89
89
  (`const { Router } = require('express')`), and non-`import` forms such as
90
90
  dynamic `import()`.
91
91
 
92
- Known false positives: import-like text inside comments, string literals and
93
- template literals (for example code samples embedded in generator templates) is
94
- matched, because detection is regex-based and does not parse source.
92
+ Python imports (`django`, `fastapi`, `flask`, `pydantic`, `starlette`) are read
93
+ from the AST of `.py` files, so strings, docstrings and comments never match
94
+ and relative imports are ignored. A `.py` file that cannot be parsed yields the
95
+ `framework.unparseable_source` warning and the report status is `partial`.
96
+ JS/TS imports are still regex-matched and can false-positive on import-like
97
+ text in comments or template literals.
95
98
 
96
99
  `pydantic` and `starlette` are emitted as `framework` findings, like the others, so they can become `primary_framework` when they have the most evidence.
97
100
 
@@ -172,7 +175,7 @@ A tool config makes the tool `detected`. A tool declared as a dependency without
172
175
 
173
176
  - Directory exclusions match basenames at any depth, case-insensitively: `build`, `dist`, `env`, `venv`, `target`, `vendor`, `coverage`, `htmlcov`, `node_modules`, `__pycache__`, `.git` and similar are skipped wherever they occur, even when they hold real source.
174
177
  - `.git` is detected only because discovery lists it as skipped; an export without `.git` reports git as `inferred` at best.
175
- - Framework imports are regex-based: import-like text in comments or strings is a false positive (inspecting Spawn itself reports `django` because template sources contain it); multi-line and destructured imports are missed.
178
+ - Python framework imports are read from the AST (no false positives from strings or comments; unparseable files are skipped with a warning). JS/TS framework imports are regex-based, so import-like text in comments or template literals is a false positive; multi-line and destructured JS/TS imports are missed.
176
179
  - A framework found only through a dependency declaration is `inferred`, and `summary.primary_framework` may be such an inferred framework. It is picked by evidence count, which reflects representation, not importance.
177
180
  - CI, container and pre-commit detection is presence-only: no YAML is parsed.
178
181
  - Databases are inferred from driver packages only; READMEs, `.env`, data files and compose images are not evidence.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spawnio"
3
- version = "1.1.0"
3
+ version = "1.1.1"
4
4
  description = "Spawn a ready-to-use Python project in seconds"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.1.0"
6
+ __version__ = "1.1.1"
@@ -3,6 +3,7 @@ import sys
3
3
  from pathlib import Path
4
4
 
5
5
  import typer
6
+ from rich.markup import escape
6
7
  from rich.prompt import Confirm, Prompt
7
8
 
8
9
  from spawn import __version__
@@ -288,10 +289,14 @@ def doctor(
288
289
 
289
290
  project_path = Path(path).resolve()
290
291
  if not project_path.exists():
291
- console.print(f"[red]❌ Path does not exist: {project_path}[/red]")
292
+ console.print(
293
+ f"[red]❌ Path does not exist: {escape(str(project_path))}[/red]"
294
+ )
292
295
  raise typer.Exit(1)
293
296
  if not project_path.is_dir():
294
- console.print(f"[red]❌ Path is not a directory: {project_path}[/red]")
297
+ console.print(
298
+ f"[red]❌ Path is not a directory: {escape(str(project_path))}[/red]"
299
+ )
295
300
  raise typer.Exit(1)
296
301
 
297
302
  from spawn.utils.doctor import ProjectHealthChecker, run_health_check
@@ -2,6 +2,7 @@ import os
2
2
  from typing import NoReturn
3
3
 
4
4
  import typer
5
+ from rich.markup import escape
5
6
 
6
7
  from spawn.core.exceptions import SpawnError
7
8
  from spawn.utils.console import console
@@ -11,7 +12,7 @@ UNEXPECTED_EXIT_CODE = 10
11
12
 
12
13
  def fail(e: SpawnError) -> NoReturn:
13
14
  """Print an expected error and exit with its category's exit code."""
14
- console.print(f"[red]❌ {e}[/red]")
15
+ console.print(f"[red]❌ {escape(str(e))}[/red]")
15
16
  raise typer.Exit(e.exit_code)
16
17
 
17
18
 
@@ -24,6 +25,8 @@ def report_unexpected(e: BaseException) -> NoReturn:
24
25
  "no",
25
26
  ):
26
27
  raise e
27
- console.print(f"[red]❌ Unexpected error ({type(e).__name__}): {e}[/red]")
28
+ console.print(
29
+ f"[red]❌ Unexpected error ({escape(type(e).__name__)}): {escape(str(e))}[/red]"
30
+ )
28
31
  console.print("[dim]Re-run with SPAWN_DEBUG=1 for a traceback.[/dim]")
29
32
  raise typer.Exit(UNEXPECTED_EXIT_CODE)
@@ -18,6 +18,7 @@ from spawn.core.exceptions import FilesystemError, StructureParseError
18
18
  from spawn.generators.destination import (
19
19
  assert_available,
20
20
  cleanup_created,
21
+ record_ownership,
21
22
  resolve_destination,
22
23
  safe_join,
23
24
  )
@@ -77,10 +78,16 @@ def _is_file_entry(name: str) -> bool:
77
78
  return segment.lower() in _KNOWN_EXTENSIONLESS_FILES
78
79
 
79
80
 
80
- def _strip_name(name: str) -> str:
81
- """Remove trailing slash and inline comments."""
81
+ def _strip_name(name: str) -> tuple[str, bool]:
82
+ """Remove trailing slash and inline comments.
83
+
84
+ Returns (stripped_name, had_trailing_slash). The trailing-slash flag
85
+ should be forwarded to _build_entries so that explicit directory markers
86
+ are honoured even for names that look like files (e.g. ``config.d/``).
87
+ """
82
88
  name = re.sub(r"\s*#.*$", "", name).strip()
83
- return name.rstrip("/")
89
+ had_slash = name.endswith("/")
90
+ return name.rstrip("/"), had_slash
84
91
 
85
92
 
86
93
  # ─── Format detection ─────────────────────────────────────────────────────
@@ -100,64 +107,67 @@ def detect_format(raw: str) -> str:
100
107
  # ─── Per-format depth extraction ──────────────────────────────────────────
101
108
 
102
109
 
103
- def _tree_depth_and_name(line: str) -> tuple[int, str] | None:
110
+ def _tree_depth_and_name(line: str) -> tuple[int, str, bool] | None:
104
111
  stripped = line.rstrip()
105
112
  if not stripped:
106
113
  return None
107
114
 
108
115
  # Root line: no tree characters
109
116
  if not any(c in stripped for c in _TREE_CHARS):
110
- name = _strip_name(stripped.lstrip())
117
+ name, had_slash = _strip_name(stripped.lstrip())
111
118
  if not name:
112
119
  return None
113
- return (0, name)
120
+ return (0, name, had_slash)
114
121
 
115
122
  match = re.match(r"^([\s│]*)[├└]──\s*(.+)$", stripped)
116
123
  if not match:
117
124
  return None
118
- prefix, name = match.group(1), match.group(2).rstrip()
119
- name = _strip_name(name)
125
+ prefix, raw_name = match.group(1), match.group(2).rstrip()
126
+ name, had_slash = _strip_name(raw_name)
120
127
  if not name:
121
128
  return None
122
129
  depth = len(prefix) // 4 + 1
123
- return (depth, name)
130
+ return (depth, name, had_slash)
124
131
 
125
132
 
126
- def _markdown_depth_and_name(line: str, indent_unit: int) -> tuple[int, str] | None:
133
+ def _markdown_depth_and_name(
134
+ line: str, indent_unit: int
135
+ ) -> tuple[int, str, bool] | None:
127
136
  match = _MARKDOWN_LINE_RE.match(line)
128
137
  if not match:
129
138
  stripped = line.strip()
130
139
  if not stripped:
131
140
  return None
132
- return (0, _strip_name(stripped))
141
+ name, had_slash = _strip_name(stripped)
142
+ return (0, name, had_slash)
133
143
  spaces = len(match.group(1))
134
- name = _strip_name(line[match.end() :].strip())
144
+ name, had_slash = _strip_name(line[match.end() :].strip())
135
145
  if not name:
136
146
  return None
137
147
  depth = (spaces // indent_unit + 1) if indent_unit > 0 else 1
138
- return (depth, name)
148
+ return (depth, name, had_slash)
139
149
 
140
150
 
141
151
  def _indented_depth_and_name(
142
152
  line: str, indent_unit: int | None
143
- ) -> tuple[int, str, int | None]:
153
+ ) -> tuple[int, str, bool, int | None]:
144
154
  stripped = line.rstrip()
145
155
  if not stripped or not stripped.strip():
146
- return (-1, "", indent_unit)
156
+ return (-1, "", False, indent_unit)
147
157
 
148
158
  leading = len(stripped) - len(stripped.lstrip())
149
- name = _strip_name(stripped.strip())
159
+ name, had_slash = _strip_name(stripped.strip())
150
160
  if not name:
151
- return (-1, "", indent_unit)
161
+ return (-1, "", False, indent_unit)
152
162
 
153
163
  if leading == 0:
154
- return (0, name, indent_unit)
164
+ return (0, name, had_slash, indent_unit)
155
165
 
156
166
  if indent_unit is None:
157
167
  indent_unit = leading
158
168
 
159
169
  depth = leading // indent_unit
160
- return (depth, name, indent_unit)
170
+ return (depth, name, had_slash, indent_unit)
161
171
 
162
172
 
163
173
  # ─── Main parser ──────────────────────────────────────────────────────────
@@ -187,7 +197,7 @@ def parse_structure(raw: str) -> list[ParsedEntry]:
187
197
  return _build_entries(depth_name_pairs)
188
198
 
189
199
 
190
- def _parse_tree_lines(lines: list[str]) -> list[tuple[int, str]]:
200
+ def _parse_tree_lines(lines: list[str]) -> list[tuple[int, str, bool]]:
191
201
  result = []
192
202
  for line in lines:
193
203
  parsed = _tree_depth_and_name(line)
@@ -197,7 +207,7 @@ def _parse_tree_lines(lines: list[str]) -> list[tuple[int, str]]:
197
207
  return result
198
208
 
199
209
 
200
- def _parse_markdown_lines(lines: list[str]) -> list[tuple[int, str]]:
210
+ def _parse_markdown_lines(lines: list[str]) -> list[tuple[int, str, bool]]:
201
211
  indent_unit = 2
202
212
  space_counts = []
203
213
  for line in lines:
@@ -218,14 +228,16 @@ def _parse_markdown_lines(lines: list[str]) -> list[tuple[int, str]]:
218
228
  return result
219
229
 
220
230
 
221
- def _parse_indented_lines(lines: list[str]) -> list[tuple[int, str]]:
231
+ def _parse_indented_lines(lines: list[str]) -> list[tuple[int, str, bool]]:
222
232
  indent_unit: int | None = None
223
233
  result = []
224
234
  for line in lines:
225
- depth, name, indent_unit = _indented_depth_and_name(line, indent_unit)
235
+ depth, name, had_slash, indent_unit = _indented_depth_and_name(
236
+ line, indent_unit
237
+ )
226
238
  if depth < 0 or not name:
227
239
  continue
228
- result.append((depth, name))
240
+ result.append((depth, name, had_slash))
229
241
  return result
230
242
 
231
243
 
@@ -244,22 +256,22 @@ def _check_entry_safe(name: str, full_path: str) -> None:
244
256
 
245
257
 
246
258
  def _build_entries(
247
- depth_name_pairs: list[tuple[int, str]],
259
+ depth_name_pairs: list[tuple[int, str, bool]],
248
260
  ) -> list[ParsedEntry]:
249
261
  if not depth_name_pairs:
250
262
  raise StructureParseError("No parseable entries found in input.")
251
263
 
252
264
  # Normalise so the shallowest entry is always at depth 0
253
- min_depth = min(d for d, _ in depth_name_pairs)
265
+ min_depth = min(d for d, _, _ in depth_name_pairs)
254
266
  if min_depth != 0:
255
- depth_name_pairs = [(d - min_depth, n) for d, n in depth_name_pairs]
267
+ depth_name_pairs = [(d - min_depth, n, s) for d, n, s in depth_name_pairs]
256
268
 
257
269
  entries: list[ParsedEntry] = []
258
270
  seen: set[str] = set()
259
271
  stack: list[str] = [""]
260
272
  prev_depth = -1
261
273
 
262
- for depth, name in depth_name_pairs:
274
+ for depth, name, had_slash in depth_name_pairs:
263
275
  if depth > prev_depth + 1:
264
276
  raise StructureParseError(
265
277
  f"Entry '{name}' is indented {depth} level(s) deep but the "
@@ -272,7 +284,8 @@ def _build_entries(
272
284
  full_path = f"{parent}/{name}".lstrip("/") if parent else name
273
285
  _check_entry_safe(name, full_path)
274
286
 
275
- is_file = _is_file_entry(name)
287
+ # Explicit trailing slash always wins; fall back to heuristic rules.
288
+ is_file = False if had_slash else _is_file_entry(name)
276
289
 
277
290
  if full_path in seen:
278
291
  raise StructureParseError(
@@ -386,9 +399,12 @@ class CustomStructureGenerator:
386
399
  assert_available(project_path, f"Directory '{project_name}' already exists.")
387
400
 
388
401
  created = False
402
+ token = None
389
403
  try:
390
404
  project_path.mkdir()
391
405
  created = True
406
+ token = record_ownership(project_path)
407
+ self._last_ownership_token = token
392
408
 
393
409
  for entry in entries:
394
410
  if entry.path == "pyproject.toml" and use_uv:
@@ -472,12 +488,12 @@ class CustomStructureGenerator:
472
488
 
473
489
  except OSError as e:
474
490
  if created:
475
- cleanup_created(project_path)
491
+ cleanup_created(project_path, token=token)
476
492
  raise FilesystemError(str(e)) from e
477
493
 
478
494
  except BaseException:
479
495
  if created:
480
- cleanup_created(project_path)
496
+ cleanup_created(project_path, token=token)
481
497
  raise
482
498
 
483
499
  return project_path
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import os
6
6
  import re
7
7
  import shutil
8
+ from dataclasses import dataclass
8
9
  from pathlib import Path
9
10
 
10
11
  from spawn.core.exceptions import FilesystemError, InvalidInputError
@@ -19,7 +20,9 @@ def resolve_destination(name: str, destination: Path | None = None) -> Path:
19
20
  f"Invalid project name '{name}': must be a single directory name."
20
21
  )
21
22
  base = Path.cwd() / name if destination is None else Path(destination)
22
- return base.resolve()
23
+ if not base.name:
24
+ return base.resolve()
25
+ return base.parent.resolve() / base.name
23
26
 
24
27
 
25
28
  def assert_available(path: Path, message: str) -> None:
@@ -46,21 +49,57 @@ def safe_join(root: Path, relative: str) -> Path:
46
49
  return root / relative
47
50
 
48
51
 
49
- def cleanup_created(path: Path, root_guard: Path | None = None) -> None:
50
- """Remove *path* only if it is a real directory that is safe to delete.
52
+ @dataclass(frozen=True)
53
+ class OwnershipToken:
54
+ path: Path
55
+ dev: int
56
+ ino: int
57
+
58
+
59
+ def record_ownership(path: Path) -> OwnershipToken:
60
+ """Capture filesystem identity immediately after creating *path*."""
61
+ stat = os.lstat(path)
62
+ return OwnershipToken(path=path, dev=stat.st_dev, ino=stat.st_ino)
63
+
64
+
65
+ def cleanup_created(
66
+ path: Path,
67
+ root_guard: Path | None = None,
68
+ token: OwnershipToken | None = None,
69
+ ) -> None:
70
+ """Remove *path* only if it is the exact real directory created by this run.
51
71
 
52
72
  Callers must only invoke this for directories created by the current run.
53
73
  """
54
- if path.is_symlink() or not path.is_dir():
55
- return
56
- resolved = path.resolve()
57
- cwd = Path.cwd().resolve()
58
- if (
59
- resolved == resolved.parent # filesystem root
60
- or resolved == Path.home().resolve()
61
- or resolved == cwd
62
- or resolved in cwd.parents
63
- or (root_guard is not None and resolved == root_guard.resolve())
64
- ):
74
+ try:
75
+ if os.path.islink(path) or path.is_symlink():
76
+ return
77
+ if getattr(path, "is_junction", lambda: False)():
78
+ return
79
+ if not path.is_dir():
80
+ return
81
+
82
+ stat = os.lstat(path)
83
+ if token is not None:
84
+ if stat.st_dev != token.dev or stat.st_ino != token.ino:
85
+ return
86
+
87
+ cwd = Path.cwd().resolve()
88
+ path_parent = path.parent.resolve()
89
+ path_cmp = path_parent / path.name
90
+ home = Path.home().resolve()
91
+ if (
92
+ path_cmp == path_parent # filesystem root
93
+ or path_cmp == home
94
+ or path_cmp == cwd
95
+ or path_cmp in cwd.parents
96
+ or (
97
+ root_guard is not None
98
+ and path_cmp == (root_guard.parent.resolve() / root_guard.name)
99
+ )
100
+ ):
101
+ return
102
+
103
+ shutil.rmtree(path, ignore_errors=True)
104
+ except OSError:
65
105
  return
66
- shutil.rmtree(resolved, ignore_errors=True)
@@ -11,7 +11,8 @@ from spawn.generators.project_generator import ProjectGenerator
11
11
  def generate_project(config: ProjectConfig) -> Path:
12
12
  """Single entry point: generate a project (custom or template) from config."""
13
13
  if config.template == "custom":
14
- project_path = CustomStructureGenerator().generate(
14
+ generator = CustomStructureGenerator()
15
+ project_path = generator.generate(
15
16
  project_name=config.name,
16
17
  entries=config.custom_entries or [],
17
18
  use_git=config.use_git,
@@ -25,7 +26,9 @@ def generate_project(config: ProjectConfig) -> Path:
25
26
  try:
26
27
  write_project_meta(project_path, config, generator="custom")
27
28
  except OSError as e:
28
- cleanup_created(project_path)
29
+ cleanup_created(
30
+ project_path, token=getattr(generator, "_last_ownership_token", None)
31
+ )
29
32
  raise FilesystemError(str(e)) from e
30
33
  return project_path
31
34
 
@@ -6,6 +6,7 @@ from spawn.core.registry import instantiate_template
6
6
  from spawn.generators.destination import (
7
7
  assert_available,
8
8
  cleanup_created,
9
+ record_ownership,
9
10
  resolve_destination,
10
11
  )
11
12
  from spawn.generators.metadata import write_project_meta
@@ -60,9 +61,11 @@ class ProjectGenerator:
60
61
  assert_available(project_path, f"Directory '{config.name}' already exists.")
61
62
 
62
63
  created = False
64
+ token = None
63
65
  try:
64
66
  project_path.mkdir()
65
67
  created = True
68
+ token = record_ownership(project_path)
66
69
 
67
70
  context = {"project_name": config.name}
68
71
  template.generate(project_path, context)
@@ -121,12 +124,12 @@ class ProjectGenerator:
121
124
 
122
125
  except OSError as e:
123
126
  if created:
124
- cleanup_created(project_path)
127
+ cleanup_created(project_path, token=token)
125
128
  raise FilesystemError(str(e)) from e
126
129
 
127
130
  except BaseException:
128
131
  if created:
129
- cleanup_created(project_path)
132
+ cleanup_created(project_path, token=token)
130
133
  raise
131
134
 
132
135
  return project_path
@@ -107,6 +107,8 @@ def _py_name(name: str) -> str:
107
107
  def _clean(spec: object) -> str | None:
108
108
  if not isinstance(spec, str) or not spec.strip():
109
109
  return None
110
+ # Sanitize before applying the detail cap so truncation cannot leave a
111
+ # credential-bearing URL's userinfo in the displayed prefix.
110
112
  return redact_url(spec.strip())[:_MAX_DETAIL]
111
113
 
112
114