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.
- {spawnio-1.1.0 → spawnio-1.1.1}/PKG-INFO +6 -5
- {spawnio-1.1.0 → spawnio-1.1.1}/README.md +5 -4
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/architecture.md +1 -1
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/changelog.md +20 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/commands.md +4 -4
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/getting-started.md +2 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/supported-technologies.md +8 -5
- {spawnio-1.1.0 → spawnio-1.1.1}/pyproject.toml +1 -1
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/__init__.py +1 -1
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/app.py +7 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/errors.py +5 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/custom_structure.py +47 -31
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/destination.py +54 -15
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/pipeline.py +5 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/project_generator.py +5 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/dependencies.py +2 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/frameworks.py +58 -18
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/agent/content.py +4 -3
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/backend_api/__init__.py +5 -1
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/backend_api/content.py +6 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/chatbot/content.py +6 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/analysis/content.py +1 -1
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/etl/content.py +3 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/ml/content.py +2 -3
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/rag/content.py +10 -3
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/redact.py +3 -1
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/uv.py +5 -4
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_custom_structure.py +147 -0
- spawnio-1.1.1/tests/test_destination.py +591 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_errors.py +35 -0
- spawnio-1.1.1/tests/test_generated_lint.py +95 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_dependencies.py +64 -0
- spawnio-1.1.1/tests/test_inspection_false_positives.py +66 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_frameworks.py +173 -2
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_rag_template.py +17 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_templates.py +39 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_uv.py +29 -0
- spawnio-1.1.0/tests/test_destination.py +0 -238
- {spawnio-1.1.0 → spawnio-1.1.1}/.gitignore +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/CONTRIBUTING.md +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/LICENSE +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/inspection-contract.md +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/language-framework-handoff.md +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/docs/inspection/safe-discovery.md +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/inspect_render.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/noninteractive.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/cli/prompts.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/exceptions.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/models.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/planning.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/project.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/core/registry.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/metadata.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/generators/project_files.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/exceptions.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/publisher.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/github/validators.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/_reading.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/base.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/discovery.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/exceptions.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/languages.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/models.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/orchestrator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/serialization.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/inspection/tooling.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/agent/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/automation/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/automation/content.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/base.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/chatbot/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/cli_application/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/cli_application/content.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/analysis/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/dashboard/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/dashboard/content.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/etl/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/data_project/ml/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/mcp_server/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/mcp_server/content.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/rag/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/templates/shared_content.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/banner.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/console.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/doctor.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/doctor_fix.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/git.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/success.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/theme.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/src/spawn/utils/validators.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/conftest.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/archetypes.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/inspection_corpus.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/make_sample_report.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/broken_meta/.spawn/meta.json +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/meta_unknown_intent/.spawn/meta.json +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/non_spawn_python_repo/pyproject.toml +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/non_spawn_python_repo/src/pkg/__init__.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/non_spawn_python_repo/tests/test_x.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/static/sample_report.json +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/agent.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/automation.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/backend-api.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/chatbot.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/cli.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/custom.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/data.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/mcp.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/fixtures/trees/rag.txt +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_agent_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_agent_template.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_app.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_automation_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_automation_template.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_boundaries.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_chatbot_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_chatbot_template.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_cli_application_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_cli_application_template.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_console.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_create_flags.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_custom_structure_integration.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_analysis.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_dashboard.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_etl.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_gitignore.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_data_project_ml.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_docs.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_doctor.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_doctor_fix.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_fixtures.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_git.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_github_publisher.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_github_validators.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspect_cli.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_discovery.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_languages.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_models.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_orchestrator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_regression.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_sample.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_serialization.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_inspection_tooling.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_mcp_server_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_mcp_server_template.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_models.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_noninteractive.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_pipeline.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_planning.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_project.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_project_files.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_prompts.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_rag_generator.py +0 -0
- {spawnio-1.1.0 → spawnio-1.1.1}/tests/test_registry.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
796
|
+
# → Spawn v1.1.1
|
|
797
797
|
|
|
798
798
|
spawn --version
|
|
799
|
-
# → Spawn v1.1.
|
|
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
|
|
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.
|
|
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.
|
|
785
|
+
# → Spawn v1.1.1
|
|
786
786
|
|
|
787
787
|
spawn --version
|
|
788
|
-
# → Spawn v1.1.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
585
|
+
# Spawn v1.1.1
|
|
586
586
|
spawn --version
|
|
587
|
-
# Spawn v1.1.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
-
|
|
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.
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
119
|
-
name = _strip_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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|