diff --git a/README.md b/README.md index 00b3e71..1f47d86 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ It's repetitive. It's inconsistent. And you haven't written a single line of *re | **Arrow-key menus** | Every prompt in `spawn create` is arrow-key/spacebar driven, not typed numbers | | **GitHub publishing** | Connects your project to an existing GitHub repo and pushes the initial commit | | **spawn doctor** | Scores your project's health out of 100, with per-category breakdowns and a prioritized next step | +| **spawn doctor --fix** | Fixes missing files and config automatically — never overwrites, never installs packages | --- @@ -132,7 +133,7 @@ Running `spawn` with no arguments shows the banner, the installed version, and t ``` SPAWN — scaffold your next project -v1.0.6 +v1.0.8 Commands create Scaffold a new project @@ -179,7 +180,15 @@ Use the arrow keys to move, Enter to select — the same pattern every prompt in **Step 3 — Additional prompts** *(template-dependent — framework, provider, project type, and/or extras, depending on what you picked. See [Project Templates](#project-templates) below for each one's exact flow.)* -**Step 4 — Git** +**Step 4 — License** + +``` +? Choose a license (Use arrow keys) + » MIT + None +``` + +**Step 5 — Git** ``` Initialize Git? [Y/n]: Y @@ -242,9 +251,11 @@ my-api/ ● pytest ○ docker ○ github-actions + ○ pre-commit + ○ mypy ``` -**Available extras:** `ruff` `pytest` `docker` `github-actions` +**Available extras:** `ruff` `pytest` `docker` `github-actions` `pre-commit` `mypy` **Run it:** @@ -295,9 +306,11 @@ my-cli/ ● ruff ● pytest ○ github-actions + ○ pre-commit + ○ mypy ``` -**Available extras:** `ruff` `pytest` `github-actions` +**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy` **Run it:** @@ -334,9 +347,11 @@ my-automation/ ● ruff ● pytest ○ github-actions + ○ pre-commit + ○ mypy ``` -**Available extras:** `ruff` `pytest` `github-actions` +**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy` **Run it:** @@ -386,9 +401,11 @@ my-chatbot/ ● pytest ○ rich ○ github-actions + ○ pre-commit + ○ mypy ``` -**Available extras:** `ruff` `pytest` `rich` `github-actions` +**Available extras:** `ruff` `pytest` `rich` `github-actions` `pre-commit` `mypy` **Run it:** @@ -436,11 +453,13 @@ my-agent/ ● ruff ● pytest ○ github-actions + ○ pre-commit + ○ mypy ``` > If you pick `openai-agents`, only `openai` and `openrouter` appear as provider choices — the list is filtered per framework. -**Available extras:** `ruff` `pytest` `github-actions` +**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy` **Run it:** @@ -481,11 +500,13 @@ my-rag/ ● ruff ● pytest ○ github-actions + ○ pre-commit + ○ mypy ``` RAG System uses a fixed stack — **LlamaIndex + ChromaDB + OpenAI** — so there's no framework or provider prompt, unlike Chatbot and Agent. On first run, it automatically ingests documents from `data/` into a local ChromaDB index, then lets you ask questions against them. Requires an `OPENAI_API_KEY` (used for both the LLM and embeddings). -**Available extras:** `ruff` `pytest` `github-actions` +**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy` **Run it:** @@ -529,9 +550,11 @@ my-data-project/ ● ruff ● pytest ○ github-actions + ○ pre-commit + ○ mypy ``` -**Available extras:** `ruff` `pytest` `github-actions` +**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy` **Run it:** @@ -575,11 +598,13 @@ my-mcp-server/ ● ruff ● pytest ○ github-actions + ○ pre-commit + ○ mypy ``` MCP Server uses a fixed stack — the official `mcp` Python SDK's `FastMCP` — so there's no framework or provider prompt, the same as RAG System. -**Available extras:** `ruff` `pytest` `github-actions` +**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy` **Run it:** @@ -630,6 +655,7 @@ Dependencies (comma separated, optional): fastapi, uvicorn ● Pytest ○ Pre-commit ○ Dockerfile + ○ Mypy Additional ignore patterns (optional, comma separated): data/, *.csv @@ -643,7 +669,7 @@ Proceed? [Y/n]: Y **The Dependencies prompt** (shown only when uv is enabled) installs packages immediately via `uv add`. -**The Optional Setup menu** installs dev tools via `uv add --dev` and generates their config files: `ruff.toml`, `tests/__init__.py`, `.pre-commit-config.yaml`, and/or `Dockerfile`. +**The Optional Setup menu** installs dev tools via `uv add --dev` and generates their config files: `ruff.toml`, `tests/__init__.py`, `.pre-commit-config.yaml`, `Dockerfile`, and/or `[tool.mypy]` in `pyproject.toml`. ### Non-Interactive Mode @@ -652,7 +678,7 @@ Skip all prompts by passing flags directly or pointing to a JSON config file. **Using flags:** ```bash -spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --git +spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --license mit --git ``` **Using a config file:** @@ -663,6 +689,7 @@ spawn create --name my-api --template backend-api --framework fastapi --extras r "template": "backend-api", "framework": "fastapi", "extras": ["ruff", "pytest"], + "license": "mit", "git": true, "uv": true } @@ -730,11 +757,30 @@ spawn doctor ./path/to/project Checks span six categories — Documentation, Version Control, Configuration, Testing, Automation, and Code Quality (Ruff, type checking, pre-commit) — all filesystem-based, nothing is executed or sent over the network. +#### Automatic Remediation with `--fix` + +`spawn doctor --fix` creates missing files and config automatically. It **never overwrites** pre-existing files and **never installs packages** directly (instead printing `uv add` commands for optional tools). + +```bash +# Automatically create missing files and config +spawn doctor --fix +spawn doctor ./path/to/project --fix + +# Preview what would be created without touching disk +spawn doctor --fix --dry-run + +# Run non-interactively without the confirmation prompt +spawn doctor --fix --yes + +# Also add an MIT license if missing +spawn doctor --fix --license mit +``` + ### `spawn version` ```bash spawn version -# → Spawn v1.0.7 +# → Spawn v1.0.8 ``` ### Publish to GitHub @@ -768,6 +814,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/ | Version | Highlight | |---|---| +| **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 | | **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 | | **v1.0.6** | Removed duplicate banner from `spawn create`; `spawn` alone now shows the banner and command overview | | **v1.0.5** | Published to PyPI as `spawnio`; arrow-key selection menus, no-args banner, consistent Ctrl+C handling | diff --git a/docs/architecture.md b/docs/architecture.md index 1f380b6..e15592e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,16 +23,19 @@ Entry point: `spawn.cli.app:main` (defined in `pyproject.toml` as `[project.scri ``` src/spawn/ -├── __init__.py # __version__ via importlib.metadata, fallback "1.0.6" +├── __init__.py # __version__ via importlib.metadata, fallback "1.0.8" ├── cli/ │ ├── app.py # Typer app: create, version, doctor commands +│ ├── noninteractive.py # Build config from CLI flags or JSON config file │ └── prompts.py # Interactive prompts; derives menu from registry ├── core/ │ ├── exceptions.py # SpawnError — base exception -│ ├── models.py # ProjectConfig dataclass (name, template, use_git, framework, extras) +│ ├── models.py # ProjectConfig dataclass (name, template, use_git, framework, extras, license) │ └── registry.py # TemplateMetadata, TEMPLATES dict, get_template(), │ # instantiate_template(), get_metadata(), list_templates() ├── generators/ +│ ├── custom_structure.py # Custom structure parser and generator +│ ├── project_files.py # Standalone file writers for LICENSE, README, configs, CI │ └── project_generator.py # Orchestrates generation, dependency install, meta.json write ├── github/ │ ├── exceptions.py # GitHubPublishError @@ -40,7 +43,7 @@ src/spawn/ │ └── validators.py # GitHub URL validation ├── templates/ │ ├── base.py # BaseTemplate dataclass -│ ├── shared_content.py # README_CONTENT, GITIGNORE_CONTENT +│ ├── shared_content.py # README_CONTENT, GITIGNORE_CONTENT, CI, and tool configs │ ├── backend_api/ │ │ ├── __init__.py # BackendAPITemplate (dispatches by framework) │ │ └── content.py # All FastAPI/Flask/Django/Docker/CI content strings @@ -60,8 +63,10 @@ src/spawn/ ├── banner.py # show_banner() — ASCII wordmark with ice-fade colours ├── console.py # Shared Rich Console instance ├── doctor.py # ProjectHealthChecker, run_health_check() + ├── doctor_fix.py # FixAction, FixResult, plan_fixes(), apply_fixes(), tool_hints() ├── git.py # initialize_git(), run_git_command(), add_all(), commit() etc. ├── success.py # show_success() — renders the success panel + ├── theme.py # UI theme colors and styles ├── uv.py # initialize_uv(), install_packages() └── validators.py # validate_project_name() ``` @@ -137,14 +142,14 @@ Each template lives in its own subdirectory with an `__init__.py` (class) and `c | Key | Template class | Frameworks | Extras | |---|---|---|---| -| `backend-api` | `BackendAPITemplate` | fastapi, flask, django | ruff, pytest, docker, github-actions | -| `cli` | `CLITemplate` | typer, click, argparse | ruff, pytest, github-actions | -| `automation` | `AutomationTemplate` | none | ruff, pytest, github-actions | -| `chatbot` | `ChatbotTemplate` | pydantic-ai, openai-sdk | ruff, pytest, github-actions | -| `agent` | `AgentTemplate` | pydantic-ai, openai-agents | ruff, pytest, github-actions | -| `rag` | `RAGTemplate` | none | ruff, pytest, github-actions | -| `data` | `DataProjectTemplate` | none | ruff, pytest, github-actions | -| `mcp` | `MCPServerTemplate` | none | ruff, pytest, github-actions | +| `backend-api` | `BackendAPITemplate` | fastapi, flask, django | ruff, pytest, docker, github-actions, pre-commit, mypy | +| `cli` | `CLITemplate` | typer, click, argparse | ruff, pytest, github-actions, pre-commit, mypy | +| `automation` | `AutomationTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy | +| `chatbot` | `ChatbotTemplate` | pydantic-ai, openai-sdk, litellm | ruff, pytest, rich, github-actions, pre-commit, mypy | +| `agent` | `AgentTemplate` | pydantic-ai, openai-agents | ruff, pytest, github-actions, pre-commit, mypy | +| `rag` | `RAGTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy | +| `data` | `DataProjectTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy | +| `mcp` | `MCPServerTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy | `get_template(slug)` returns a default-constructed instance. `instantiate_template(config)` forwards `framework`, `extras`, and `cli_type` from `ProjectConfig` to templates whose constructors accept them, using signature introspection. diff --git a/docs/changelog.md b/docs/changelog.md index bb6c0e7..70ec2ce 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,20 @@ All notable changes to Spawn are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## v1.0.8 — 2026 + +### Added + +- **`spawn doctor --fix`** — `spawn doctor --fix [--dry-run] [--yes] [--license mit]` creates missing files and config, never overwrites, never installs packages. +- **`--license` selection on `spawn create`** — added `--license (mit/none)` CLI option and an interactive license prompt (MIT default). +- **`CHANGELOG.md` generation** — generated for every new project. +- **`pre-commit` and `mypy` extras** — available across all 8 project templates. +- **Custom Structure Mypy option** — added Mypy to optional setup choices in Custom Structure. + +### Fixed + +- **`--template` help text** — now lists all 8 supported template slugs including `mcp`. + ## v1.0.7 — 2026 ### Fixed diff --git a/docs/commands.md b/docs/commands.md index a4cdd59..0fdb746 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -17,7 +17,7 @@ Running `spawn` with no arguments prints the banner, the installed version, and ``` SPAWN — scaffold your next project -v1.0.6 +v1.0.8 Commands create Scaffold a new project @@ -52,11 +52,12 @@ spawn create | 5 | `Choose Framework` | Backend API, CLI Application, AI Chatbot, AI Agent | | 6 | `Choose Provider` | AI Chatbot, AI Agent (filtered by framework) | | 7 | `Select extras` | Templates with available extras | -| 8 | `Initialize Git? [Y/n]` | Always | -| 9 | `Also generate CLAUDE.md for Claude Code? [y/N]` | Always | -| 10 | `Publish to GitHub? [y/N]` | Only when Git was enabled and not in non-interactive mode | +| 8 | `Choose a license` | Always (`MIT`, `None`) | +| 9 | `Initialize Git? [Y/n]` | Always | +| 10 | `Also generate CLAUDE.md for Claude Code? [y/N]` | Always | +| 11 | `Publish to GitHub? [y/N]` | Only when Git was enabled and not in non-interactive mode | -Every menu (steps 2–7) is **arrow-key / spacebar** driven — no typed numbers. +Every menu (steps 2–8) is **arrow-key / spacebar** driven — no typed numbers. **Template list** @@ -81,6 +82,8 @@ Every menu (steps 2–7) is **arrow-key / spacebar** driven — no typed numbers ● pytest ○ docker ○ github-actions + ○ pre-commit + ○ mypy ``` Space toggles, Enter confirms. Empty selection is valid (skip all extras). @@ -100,7 +103,7 @@ Space toggles, Enter confirms. Empty selection is valid (skip all extras). Pass `--name` to skip all prompts. `--template` is required when using `--name`. ```bash -spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest +spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --license mit ``` Pass `--config` to read settings from a JSON file instead of flags: @@ -120,6 +123,7 @@ spawn create --config spawn.json "cli_type": null, "data_type": null, "extras": ["ruff", "pytest"], + "license": "mit", "git": true, "uv": true, "claude_md": false @@ -137,6 +141,7 @@ spawn create --config spawn.json | `--cli-type` | `utility` | `utility` or `interactive` (CLI Application only) | | `--data-type` | `Data Analysis` | Sub-type for Data Project | | `--extras` | none | Comma-separated extras, e.g. `ruff,pytest` | +| `--license` | `mit` | Project license (`mit` or `none`) | | `--git` / `--no-git` | `--git` | Initialize a Git repository | | `--uv` / `--no-uv` | `--uv` | Initialize uv and install dependencies | | `--claude-md` / `--no-claude-md` | `--no-claude-md` | Also write `CLAUDE.md` alongside `AGENTS.md` | @@ -148,14 +153,14 @@ spawn create --config spawn.json | Slug | Display name | Frameworks | Providers | CLI types | Project types | Extras | |---|---|---|---|---|---|---| -| `backend-api` | Backend API | fastapi, flask, django | — | — | — | ruff, pytest, docker, github-actions | -| `cli` | CLI Application | typer, click, argparse | — | utility, interactive | — | ruff, pytest, github-actions | -| `automation` | Automation Tool | — | — | — | — | ruff, pytest, github-actions | -| `chatbot` | AI Chatbot | pydantic-ai, openai-sdk, litellm | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, rich, github-actions | -| `agent` | AI Agent | pydantic-ai, openai-agents | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, github-actions | -| `rag` | RAG System | — | — | — | — | ruff, pytest, github-actions | -| `data` | Data Project | — | — | — | Data Analysis, Dashboard, ETL Pipeline, Machine Learning | ruff, pytest, github-actions | -| `mcp` | MCP Server | — | — | — | — | ruff, pytest, github-actions | +| `backend-api` | Backend API | fastapi, flask, django | — | — | — | ruff, pytest, docker, github-actions, pre-commit, mypy | +| `cli` | CLI Application | typer, click, argparse | — | utility, interactive | — | ruff, pytest, github-actions, pre-commit, mypy | +| `automation` | Automation Tool | — | — | — | — | ruff, pytest, github-actions, pre-commit, mypy | +| `chatbot` | AI Chatbot | pydantic-ai, openai-sdk, litellm | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, rich, github-actions, pre-commit, mypy | +| `agent` | AI Agent | pydantic-ai, openai-agents | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, github-actions, pre-commit, mypy | +| `rag` | RAG System | — | — | — | — | ruff, pytest, github-actions, pre-commit, mypy | +| `data` | Data Project | — | — | — | Data Analysis, Dashboard, ETL Pipeline, Machine Learning | ruff, pytest, github-actions, pre-commit, mypy | +| `mcp` | MCP Server | — | — | — | — | ruff, pytest, github-actions, pre-commit, mypy | > `openai-agents` only supports `openai` and `openrouter` as providers — the list is filtered per framework. @@ -178,6 +183,8 @@ spawn create --config spawn.json ○ pytest ○ docker ○ github-actions + ○ pre-commit + ○ mypy ``` | Framework | Run command | @@ -204,6 +211,8 @@ spawn create --config spawn.json ○ ruff ○ pytest ○ github-actions + ○ pre-commit + ○ mypy ``` | CLI type | Run command | @@ -220,6 +229,8 @@ spawn create --config spawn.json ○ ruff ○ pytest ○ github-actions + ○ pre-commit + ○ mypy ``` Run: `uv run python -m src.main` @@ -247,6 +258,8 @@ Run: `uv run python -m src.main` ○ pytest ○ rich ○ github-actions + ○ pre-commit + ○ mypy ``` Run: add provider API key to `.env`, then `uv run python -m src.main` @@ -269,6 +282,8 @@ Run: add provider API key to `.env`, then `uv run python -m src.main` ○ ruff ○ pytest ○ github-actions + ○ pre-commit + ○ mypy ``` Run: add provider API key to `.env`, then `uv run python -m src.main` @@ -284,6 +299,8 @@ No framework or provider prompt. Requires `OPENAI_API_KEY` in `.env`. ○ ruff ○ pytest ○ github-actions + ○ pre-commit + ○ mypy ``` Run: `uv run python -m src.main` (auto-ingests `data/` on first run) @@ -303,6 +320,8 @@ Run: `uv run python -m src.main` (auto-ingests `data/` on first run) ○ ruff ○ pytest ○ github-actions + ○ pre-commit + ○ mypy ``` | Type | Run command | @@ -323,6 +342,8 @@ No framework or provider prompt. ○ ruff ○ pytest ○ github-actions + ○ pre-commit + ○ mypy ``` Run: `uv run python -m src.server` (waits on stdio for an MCP client to connect) @@ -339,7 +360,7 @@ The last option in the template picker. After selecting it, Spawn switches to a 4. `Also generate CLAUDE.md for Claude Code? [y/N]` 5. `Initialize uv? [Y/n]` 6. *(if uv)* `Dependencies (comma separated, optional):` -7. *(if uv)* Optional Setup checkbox — `Ruff`, `Pytest`, `Pre-commit`, `Dockerfile` +7. *(if uv)* Optional Setup checkbox — `Ruff`, `Pytest`, `Pre-commit`, `Dockerfile`, `Mypy` 8. `Additional ignore patterns (optional, comma separated):` 9. `Proceed? [Y/n]` @@ -355,6 +376,8 @@ Every generated project includes: |---|---| | `README.md` | Template-specific content with project name, structure, setup, and run instructions | | `AGENTS.md` | Agent context file with project structure, setup, and conventions | +| `CHANGELOG.md` | Keep a Changelog starter documenting the initial release | +| `LICENSE` | MIT license (when `--license mit`, the default) | | `.gitignore` | Python defaults (`.venv/`, `__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, etc.) | | `.spawn/meta.json` | `intent`, `framework`, `provider`, `spawn_version`, `created_at`, `git`, `uv` | @@ -367,7 +390,7 @@ If `--claude-md` was passed (or `"claude_md": true` in the config file), a `CLAU "intent": "backend-api", "framework": "fastapi", "provider": null, - "spawn_version": "1.0.6", + "spawn_version": "1.0.8", "created_at": "2026-01-01T00:00:00+00:00", "generator": "blueprint", "git": true, @@ -419,6 +442,40 @@ spawn doctor spawn doctor ./path/to/project ``` +### Remediation with `--fix` + +`spawn doctor --fix` scans the project and automatically creates missing standard files and configuration. + +```bash +# Automatically create missing files and config +spawn doctor --fix +spawn doctor ./path/to/project --fix + +# Preview what would change without writing to disk +spawn doctor --fix --dry-run + +# Run non-interactively without the confirmation prompt +spawn doctor --fix --yes + +# Also add an MIT license if missing +spawn doctor --fix --license mit +``` + +**Doctor flags** + +| Flag | Default | Description | +|---|---|---| +| `path` | `.` | Path to the project directory to check | +| `--fix` | false | Create missing files and config. Never overwrites, never installs packages | +| `--dry-run` | false | With `--fix`: show what would change without writing | +| `--yes` / `-y` | false | With `--fix`: skip the confirmation prompt | +| `--license` | — | With `--fix`: also add a LICENSE (`mit`) | + +**Guarantees** + +- **Never overwrites**: If a file or configuration already exists, `doctor --fix` never touches or replaces it. Only missing items are created. +- **Never installs packages**: `doctor --fix` will write standard configuration (such as `ruff.toml`, `[tool.mypy]`, or `.pre-commit-config.yaml`), but will never run package managers or install dependencies into your environment. Instead, it prints helpful `uv add --dev ...` commands so you remain in control. + **All checks** | Check | Category | Weight | @@ -458,7 +515,7 @@ Prints the installed version. ```bash spawn version -# Spawn v1.0.6 +# Spawn v1.0.8 ``` --- @@ -469,5 +526,5 @@ spawn version |---|---| | Any command succeeds | 0 | | `spawn doctor` — path does not exist or is not a directory | 1 | -| Non-interactive validation error | 1 | +| Non-interactive validation error / flag error | 1 | | Ctrl+C / EOF at any point | 130 | diff --git a/docs/getting-started.md b/docs/getting-started.md index b1a3219..3ed9869 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,14 +49,14 @@ After installation, `spawn` is available anywhere on your machine. ```bash spawn version -# Spawn v1.0.7 +# Spawn v1.0.8 ``` Or just run `spawn` with no arguments to see the command overview: ``` SPAWN — scaffold your next project -v1.0.6 +v1.0.8 Commands create Scaffold a new project @@ -95,6 +95,10 @@ Project Name: my-api ○ docker ○ github-actions +? Choose a license (Use arrow keys) + » MIT + None + Initialize Git? [Y/n]: Y Also generate CLAUDE.md for Claude Code? [y/N]: N ``` @@ -151,6 +155,10 @@ Project Name: my-cli ○ pytest ○ github-actions +? Choose a license (Use arrow keys) + » MIT + None + Initialize Git? [Y/n]: Y Also generate CLAUDE.md for Claude Code? [y/N]: N ``` @@ -165,7 +173,7 @@ uv run python -m src.main hello ### Option C — Non-interactive (zero prompts) ```bash -spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --no-git +spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --license mit --no-git ``` Or from a JSON config file: @@ -176,6 +184,7 @@ Or from a JSON config file: "template": "backend-api", "framework": "fastapi", "extras": ["ruff", "pytest"], + "license": "mit", "git": false, "uv": true } @@ -203,6 +212,8 @@ Every generated project includes: |---|---| | `README.md` | Project README with setup and run instructions | | `AGENTS.md` | Agent context file (structure, setup, conventions) | +| `CHANGELOG.md` | Keep a Changelog starter documenting project history | +| `LICENSE` | MIT license (default) | | `.gitignore` | Python defaults | | `.spawn/meta.json` | Spawn metadata (intent, framework, version, timestamps) | | `pyproject.toml` | Project config + installed dependencies | @@ -223,6 +234,8 @@ my-api/ │ └── test_health.py ├── .env.example ├── AGENTS.md +├── CHANGELOG.md +├── LICENSE ├── README.md ├── .gitignore └── pyproject.toml @@ -258,6 +271,14 @@ spawn doctor Spawn scores the project out of 135 across Documentation, Version Control, Configuration, Testing, Automation, and Code Quality, and shows a prioritized recommendation for the most impactful next improvement. +You can also run `spawn doctor --fix` (with optional `--dry-run`, `--yes`, or `--license mit`) to automatically create missing standard files and baseline configuration: + +```bash +spawn doctor --fix +``` + +It **never overwrites** existing files and **never installs packages** into your environment directly. + --- ## 8. Next steps diff --git a/pyproject.toml b/pyproject.toml index 1ffd28a..b95d604 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spawnio" -version = "1.0.7" +version = "1.0.8" description = "Spawn a ready-to-use Python project in seconds" readme = "README.md" requires-python = ">=3.12" diff --git a/src/spawn/__init__.py b/src/spawn/__init__.py index 707c73f..85d8e4c 100644 --- a/src/spawn/__init__.py +++ b/src/spawn/__init__.py @@ -1,6 +1,6 @@ -from importlib.metadata import version, PackageNotFoundError +from importlib.metadata import PackageNotFoundError, version try: __version__ = version("spawnio") except PackageNotFoundError: - __version__ = "1.0.7" + __version__ = "1.0.8" diff --git a/src/spawn/cli/app.py b/src/spawn/cli/app.py index 1ffbb5c..67054bf 100644 --- a/src/spawn/cli/app.py +++ b/src/spawn/cli/app.py @@ -8,14 +8,14 @@ from spawn import __version__ from spawn.cli.noninteractive import build_config_from_args, build_config_from_file from spawn.cli.prompts import get_project_config +from spawn.core.exceptions import SpawnError +from spawn.core.registry import instantiate_template from spawn.generators.project_generator import ProjectGenerator -from spawn.github.publisher import GitHubPublisher from spawn.github.exceptions import GitHubPublishError +from spawn.github.publisher import GitHubPublisher from spawn.utils.banner import show_banner -from spawn.utils.success import show_success from spawn.utils.console import console -from spawn.core.exceptions import SpawnError -from spawn.core.registry import instantiate_template +from spawn.utils.success import show_success app = typer.Typer() @@ -46,7 +46,7 @@ def _write_custom_metadata(project_path, config) -> None: "framework": None, "provider": None, "spawn_version": __version__, - "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "created_at": datetime.datetime.now(datetime.UTC).isoformat(), "generator": "custom", "git": config.use_git, "uv": config.use_uv, @@ -66,7 +66,7 @@ def create( template: str = typer.Option( None, "--template", - help="Template slug: backend-api, cli, automation, chatbot, agent, rag, data", + help="Template slug: backend-api, cli, automation, chatbot, agent, rag, data, mcp", ), framework: str = typer.Option( None, "--framework", help="Framework choice for templates that support it" @@ -96,6 +96,11 @@ def create( "--claude-md/--no-claude-md", help="Also generate CLAUDE.md alongside AGENTS.md", ), + license_kind: str = typer.Option( + "mit", + "--license", + help="License for the new project: mit or none", + ), config_file: str = typer.Option( None, "--config", help="Path to a JSON config file (overrides other flags)" ), @@ -115,7 +120,9 @@ def create( try: if config_file is not None: config = build_config_from_file( - Path(config_file), use_claude_md=claude_md + Path(config_file), + use_claude_md=claude_md, + license_kind=license_kind, ) else: if template is None: @@ -138,7 +145,9 @@ def create( use_git=git, use_uv=uv, use_claude_md=claude_md, + license=license_kind, ) + except SpawnError as e: console.print(f"[red]❌ {e}[/red]") raise typer.Exit(1) @@ -234,20 +243,123 @@ def doctor( default=".", help="Path to the project directory to check. Defaults to current directory.", ), + fix: bool = typer.Option( + False, + "--fix", + help="Create missing files and config. Never overwrites, never installs packages", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="With --fix: show what would change without writing", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="With --fix: skip the confirmation prompt", + ), + license_kind: str = typer.Option( + None, + "--license", + help="With --fix: also add a LICENSE (mit)", + ), ) -> None: """Check the health of a project directory.""" - from pathlib import Path - - from spawn.utils.doctor import run_health_check - - project_path = Path(path).resolve() - if not project_path.exists(): - console.print(f"[red]❌ Path does not exist: {project_path}[/red]") - raise typer.Exit(1) - if not project_path.is_dir(): - console.print(f"[red]❌ Path is not a directory: {project_path}[/red]") - raise typer.Exit(1) - run_health_check(project_path) + try: + if not fix: + if dry_run: + console.print("[red]❌ --dry-run requires --fix.[/red]") + raise typer.Exit(1) + if yes: + console.print("[red]❌ --yes requires --fix.[/red]") + raise typer.Exit(1) + if license_kind is not None: + console.print("[red]❌ --license requires --fix.[/red]") + raise typer.Exit(1) + + if license_kind is not None and license_kind != "mit": + console.print( + f"[red]❌ Unsupported license: '{license_kind}'. Valid options: mit[/red]" + ) + raise typer.Exit(1) + + project_path = Path(path).resolve() + if not project_path.exists(): + console.print(f"[red]❌ Path does not exist: {project_path}[/red]") + raise typer.Exit(1) + if not project_path.is_dir(): + console.print(f"[red]❌ Path is not a directory: {project_path}[/red]") + raise typer.Exit(1) + + from spawn.utils.doctor import ProjectHealthChecker, run_health_check + + if not fix: + run_health_check(project_path) + return + + from spawn.utils.doctor_fix import apply_fixes, plan_fixes, tool_hints + + checker = ProjectHealthChecker(project_path) + checks = checker.run_all_checks() + checker.format_report(checks) + + actions, manual = plan_fixes(project_path, checks, license_kind=license_kind) + + if not actions and not manual: + console.print("[green]✓ Nothing to fix.[/green]") + return + + if actions: + console.print("[bold]Planned fixes:[/bold]") + for a in actions: + console.print(f" • {a.description}") + + if manual: + if actions: + console.print() + console.print("[bold]Needs manual action:[/bold]") + for check_name, reason in manual: + console.print(f" • {check_name}: {reason}") + + if dry_run: + return + + if not actions: + return + + if not yes and not typer.confirm(f"Apply {len(actions)} fixes?", default=True): + console.print("[yellow]Cancelled.[/yellow]") + return + + score_before, max_before = checker.calculate_score(checks) + pct_before = int((score_before / max_before * 100) if max_before > 0 else 0) + + results = apply_fixes(actions) + console.print() + for r in results: + if r.status == "applied": + console.print(f"[green]✓[/green] {r.action.description}") + elif r.status == "skipped": + detail_msg = f" ({r.detail})" if r.detail else "" + console.print(f"[yellow]○[/yellow] {r.action.description}{detail_msg}") + else: + console.print(f"[red]✗[/red] {r.action.description}: {r.detail}") + + hints = tool_hints(results) + if hints: + console.print() + for hint in hints: + console.print(hint) + + new_checks = checker.run_all_checks() + score_after, max_after = checker.calculate_score(new_checks) + pct_after = int((score_after / max_after * 100) if max_after > 0 else 0) + console.print(f"\n[bold]Score: {pct_before}% → {pct_after}%[/bold]") + + except (KeyboardInterrupt, EOFError, typer.Abort): + console.print("\n[yellow]Cancelled.[/yellow]") + raise typer.Exit(130) def main(): diff --git a/src/spawn/cli/noninteractive.py b/src/spawn/cli/noninteractive.py index cff34da..e04218d 100644 --- a/src/spawn/cli/noninteractive.py +++ b/src/spawn/cli/noninteractive.py @@ -15,6 +15,7 @@ from spawn.core.exceptions import SpawnError from spawn.core.models import ProjectConfig from spawn.core.registry import get_metadata, list_templates +from spawn.generators.project_files import SUPPORTED_LICENSES from spawn.templates.agent import get_supported_providers as get_agent_providers from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers from spawn.utils.validators import validate_project_name @@ -31,6 +32,7 @@ def build_config_from_args( use_git: bool = True, use_uv: bool = True, use_claude_md: bool = False, + license: str = "mit", ) -> ProjectConfig: """ Build and return a validated ProjectConfig from explicit arguments. @@ -131,10 +133,14 @@ def build_config_from_args( validated_extras.append(item) seen.add(item) + if license not in SUPPORTED_LICENSES: + raise SpawnError(f"Invalid license: '{license}'. Valid options: mit, none") + return ProjectConfig( name=name, template=template, use_git=use_git, + license=license, framework=framework, extras=validated_extras, cli_type=cli_type, @@ -145,7 +151,11 @@ def build_config_from_args( ) -def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectConfig: +def build_config_from_file( + path: Path, + use_claude_md: bool = False, + license_kind: str = "mit", +) -> ProjectConfig: """ Build and return a validated ProjectConfig from a JSON config file. @@ -188,6 +198,9 @@ def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectCo git: bool = data.get("git", True) uv: bool = data.get("uv", True) claude_md: bool = data.get("claude_md", use_claude_md) + license_val = data.get("license", license_kind) + if not isinstance(license_val, str): + raise SpawnError("'license' in config file must be a string.") return build_config_from_args( name=name, @@ -200,4 +213,5 @@ def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectCo use_git=git, use_uv=uv, use_claude_md=claude_md, + license=license_val, ) diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py index f9f1a67..9aec4e0 100644 --- a/src/spawn/cli/prompts.py +++ b/src/spawn/cli/prompts.py @@ -1,16 +1,16 @@ -import typer -import questionary from pathlib import Path + +import questionary +import typer from rich.prompt import Confirm -from spawn.utils.console import console +from spawn.core.exceptions import SpawnError from spawn.core.models import ProjectConfig -from spawn.core.registry import list_templates, get_metadata -from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers +from spawn.core.registry import get_metadata, list_templates from spawn.templates.agent import get_supported_providers as get_agent_providers +from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers +from spawn.utils.console import console from spawn.utils.validators import validate_project_name -from spawn.core.exceptions import SpawnError - # --------------------------------------------------------------------------- # Questionary helpers @@ -52,11 +52,12 @@ def _multiselect(message: str, choices: list[str]) -> list[str]: # Custom Structure optional-setup multi-select # --------------------------------------------------------------------------- -_OPTIONAL_SETUP_CHOICES = ["Ruff", "Pytest", "Pre-commit", "Dockerfile"] +_OPTIONAL_SETUP_CHOICES = ["Ruff", "Pytest", "Pre-commit", "Mypy", "Dockerfile"] _OPTIONAL_SETUP_KEY = { "Ruff": "ruff", "Pytest": "pytest", "Pre-commit": "precommit", + "Mypy": "mypy", "Dockerfile": "dockerfile", } @@ -153,6 +154,9 @@ def get_project_config() -> ProjectConfig: meta.available_extras, ) + license_choice = _select("Choose a license", ["MIT", "None"], default="MIT") + license_mapped = {"MIT": "mit", "None": "none"}[license_choice] + # --- Git --- use_git = typer.confirm( typer.style("Initialize Git?", fg=typer.colors.CYAN), @@ -167,6 +171,7 @@ def get_project_config() -> ProjectConfig: template=template, use_git=use_git, generate_claude_md=generate_claude_md, + license=license_mapped, framework=selected_framework, extras=selected_extras, cli_type=selected_cli_type, @@ -189,7 +194,7 @@ def _get_custom_structure_config(project_name: str) -> ProjectConfig: import sys from spawn.core.exceptions import StructureParseError - from spawn.generators.custom_structure import parse_structure, detect_format + from spawn.generators.custom_structure import detect_format, parse_structure console.print() console.print( diff --git a/src/spawn/core/models.py b/src/spawn/core/models.py index fe64e8c..f138cf1 100644 --- a/src/spawn/core/models.py +++ b/src/spawn/core/models.py @@ -13,6 +13,7 @@ class ProjectConfig: provider: str | None = None use_uv: bool = True generate_claude_md: bool = False + license: str = "mit" custom_entries: list | None = None # list[ParsedEntry] when template == "custom" custom_dependencies: list[str] = field(default_factory=list) custom_dev_setup: list[str] = field(default_factory=list) diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index bec4d70..d5c1c8b 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -2,15 +2,15 @@ from dataclasses import dataclass, field from spawn.core.models import ProjectConfig -from spawn.templates.backend_api import BackendAPITemplate -from spawn.templates.cli_application import CLITemplate +from spawn.templates.agent import AgentTemplate from spawn.templates.automation import AutomationTemplate +from spawn.templates.backend_api import BackendAPITemplate +from spawn.templates.base import BaseTemplate from spawn.templates.chatbot import ChatbotTemplate -from spawn.templates.agent import AgentTemplate -from spawn.templates.rag import RAGTemplate +from spawn.templates.cli_application import CLITemplate from spawn.templates.data_project import DataProjectTemplate from spawn.templates.mcp_server import MCPServerTemplate -from spawn.templates.base import BaseTemplate +from spawn.templates.rag import RAGTemplate @dataclass @@ -38,7 +38,14 @@ class TemplateMetadata: description="Production-ready backend with FastAPI, Flask, or Django", template_class=BackendAPITemplate, available_frameworks=["fastapi", "flask", "django"], - available_extras=["ruff", "pytest", "docker", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "docker", + "github-actions", + "pre-commit", + "mypy", + ], ), "cli": TemplateMetadata( slug="cli", @@ -46,7 +53,13 @@ class TemplateMetadata: description="Command-line application with Typer, Click, or Argparse", template_class=CLITemplate, available_frameworks=["typer", "click", "argparse"], - available_extras=["ruff", "pytest", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "github-actions", + "pre-commit", + "mypy", + ], available_cli_types=["utility", "interactive"], ), "automation": TemplateMetadata( @@ -54,7 +67,13 @@ class TemplateMetadata: display_name="Automation Tool", description="Workflow-based automation with logging, tasks, and integrations", template_class=AutomationTemplate, - available_extras=["ruff", "pytest", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "github-actions", + "pre-commit", + "mypy", + ], ), "chatbot": TemplateMetadata( slug="chatbot", @@ -70,7 +89,14 @@ class TemplateMetadata: "ollama", "groq", ], - available_extras=["ruff", "pytest", "rich", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "rich", + "github-actions", + "pre-commit", + "mypy", + ], ), "agent": TemplateMetadata( slug="agent", @@ -86,21 +112,39 @@ class TemplateMetadata: "ollama", "groq", ], - available_extras=["ruff", "pytest", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "github-actions", + "pre-commit", + "mypy", + ], ), "rag": TemplateMetadata( slug="rag", display_name="RAG System", description="Retrieval-Augmented Generation with LlamaIndex and ChromaDB", template_class=RAGTemplate, - available_extras=["ruff", "pytest", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "github-actions", + "pre-commit", + "mypy", + ], ), "data": TemplateMetadata( slug="data", display_name="Data Project", description="Data Analysis, Dashboard, ETL Pipeline, or Machine Learning project", template_class=DataProjectTemplate, - available_extras=["ruff", "pytest", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "github-actions", + "pre-commit", + "mypy", + ], available_data_types=[ "Data Analysis", "Dashboard", @@ -113,7 +157,13 @@ class TemplateMetadata: display_name="MCP Server", description="Model Context Protocol server with the official Python SDK", template_class=MCPServerTemplate, - available_extras=["ruff", "pytest", "github-actions"], + available_extras=[ + "ruff", + "pytest", + "github-actions", + "pre-commit", + "mypy", + ], ), } diff --git a/src/spawn/generators/custom_structure.py b/src/spawn/generators/custom_structure.py index b781caf..e9901ea 100644 --- a/src/spawn/generators/custom_structure.py +++ b/src/spawn/generators/custom_structure.py @@ -16,6 +16,8 @@ from pathlib import Path from spawn.core.exceptions import SpawnError, StructureParseError +from spawn.generators.project_files import write_mypy_ini +from spawn.templates.shared_content import PRECOMMIT_CONFIG_CONTENT from spawn.utils.console import console from spawn.utils.git import initialize_git from spawn.utils.uv import initialize_uv, install_packages @@ -67,9 +69,7 @@ def _is_file_entry(name: str) -> bool: return True if "." in segment: return True - if segment.lower() in _KNOWN_EXTENSIONLESS_FILES: - return True - return False + return segment.lower() in _KNOWN_EXTENSIONLESS_FILES def _strip_name(name: str) -> str: @@ -468,6 +468,8 @@ def _apply_dev_setup(self, project_path: Path, dev_setup: list[str]) -> None: dev_deps.append("pytest") if "precommit" in dev_setup: dev_deps.append("pre-commit") + if "mypy" in dev_setup: + dev_deps.append("mypy") if dev_deps: console.print("[yellow]Installing dev tools...[/yellow]") @@ -486,14 +488,13 @@ def _apply_dev_setup(self, project_path: Path, dev_setup: list[str]) -> None: if "precommit" in dev_setup: (project_path / ".pre-commit-config.yaml").write_text( - "repos:\n" - " - repo: https://github.com/astral-sh/ruff-pre-commit\n" - " rev: v0.15.16\n" - " hooks:\n" - " - id: ruff\n", + PRECOMMIT_CONFIG_CONTENT, encoding="utf-8", ) + if "mypy" in dev_setup: + write_mypy_ini(project_path) + if "dockerfile" in dev_setup: (project_path / "Dockerfile").write_text( "FROM python:3.12-slim\n" diff --git a/src/spawn/generators/project_files.py b/src/spawn/generators/project_files.py new file mode 100644 index 0000000..5c2b6b3 --- /dev/null +++ b/src/spawn/generators/project_files.py @@ -0,0 +1,191 @@ +import datetime +import re +import tomllib +from pathlib import Path + +from spawn.core.exceptions import SpawnError +from spawn.templates.shared_content import ( + AGENTS_MD_FIX_CONTENT, + CHANGELOG_CONTENT, + GITHUB_ACTIONS_CI_BASE, + GITHUB_ACTIONS_CI_PYTEST_STEP, + GITHUB_ACTIONS_CI_RUFF_STEP, + GITIGNORE_CONTENT, + MIT_LICENSE_CONTENT, + MYPY_INI_CONTENT, + MYPY_PYPROJECT_SECTION, + PRECOMMIT_CONFIG_CONTENT, + PYTEST_PYPROJECT_SECTION, + RUFF_PYPROJECT_SECTION, +) + +SUPPORTED_LICENSES = ["mit", "none"] + + +def _write_new(path: Path, content: str) -> bool: + if path.exists(): + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return True + + +def _append_section(project_path: Path, marker: str, section: str) -> bool: + pyproject = project_path / "pyproject.toml" + if not pyproject.exists(): + return False + text = pyproject.read_text(encoding="utf-8") + if marker in text: + return False + pyproject.write_text(text + section, encoding="utf-8") + return True + + +def write_license( + project_path: Path, + kind: str, + holder: str, + year: int | None = None, +) -> bool: + if kind == "none": + return False + if kind == "mit": + target_year = year or datetime.datetime.now(datetime.UTC).year + content = MIT_LICENSE_CONTENT.format(year=target_year, holder=holder) + return _write_new(project_path / "LICENSE", content) + raise SpawnError(f"Unsupported license: '{kind}'.") + + +def write_changelog(project_path: Path) -> bool: + return _write_new(project_path / "CHANGELOG.md", CHANGELOG_CONTENT) + + +def write_precommit_config(project_path: Path) -> bool: + return _write_new( + project_path / ".pre-commit-config.yaml", PRECOMMIT_CONFIG_CONTENT + ) + + +def write_gitignore(project_path: Path) -> bool: + return _write_new(project_path / ".gitignore", GITIGNORE_CONTENT) + + +def add_ruff_config(project_path: Path) -> bool: + return _append_section(project_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) + + +def add_mypy_config(project_path: Path) -> bool: + return _append_section(project_path, "[tool.mypy]", MYPY_PYPROJECT_SECTION) + + +def add_pytest_config(project_path: Path) -> bool: + return _append_section(project_path, "[tool.pytest", PYTEST_PYPROJECT_SECTION) + + +def write_mypy_ini(project_path: Path) -> bool: + return _write_new(project_path / "mypy.ini", MYPY_INI_CONTENT) + + +def declares_dependency(project_path: Path, name: str) -> bool: + pyproject = project_path / "pyproject.toml" + if not pyproject.exists(): + return False + try: + with pyproject.open("rb") as f: + data = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return False + + requirements: list[str] = [] + project = data.get("project") + if isinstance(project, dict): + deps = project.get("dependencies") + if isinstance(deps, list): + for item in deps: + if isinstance(item, str): + requirements.append(item) + + dep_groups = data.get("dependency-groups") + if isinstance(dep_groups, dict): + for group in dep_groups.values(): + if isinstance(group, list): + for item in group: + if isinstance(item, str): + requirements.append(item) + + tool = data.get("tool") + if isinstance(tool, dict): + uv = tool.get("uv") + if isinstance(uv, dict): + uv_dev = uv.get("dev-dependencies") + if isinstance(uv_dev, list): + for item in uv_dev: + if isinstance(item, str): + requirements.append(item) + + target = name.lower() + for req in requirements: + match = re.match(r"[A-Za-z0-9_.-]+", req) + if match and match.group(0).lower() == target: + return True + return False + + +def write_ci_workflow(project_path: Path) -> bool: + workflows_dir = project_path / ".github" / "workflows" + if workflows_dir.is_dir() and ( + any(workflows_dir.glob("*.yml")) or any(workflows_dir.glob("*.yaml")) + ): + return False + content = GITHUB_ACTIONS_CI_BASE + if declares_dependency(project_path, "ruff"): + content += GITHUB_ACTIONS_CI_RUFF_STEP + if declares_dependency(project_path, "pytest"): + content += GITHUB_ACTIONS_CI_PYTEST_STEP + return _write_new(workflows_dir / "ci.yml", content) + + +def write_readme_from_project(project_path: Path) -> bool: + name = project_path.name + description = None + pyproject = project_path / "pyproject.toml" + if pyproject.exists(): + try: + with pyproject.open("rb") as f: + data = tomllib.load(f) + project = data.get("project") + if isinstance(project, dict): + proj_name = project.get("name") + if isinstance(proj_name, str) and proj_name.strip(): + name = proj_name + proj_desc = project.get("description") + if isinstance(proj_desc, str) and proj_desc: + description = proj_desc + except (OSError, tomllib.TOMLDecodeError): + pass + + content = f"# {name}\n" + if description: + content += f"\n{description}\n" + return _write_new(project_path / "README.md", content) + + +def write_agents_md_generic(project_path: Path) -> bool: + uv = (project_path / "uv.lock").exists() + install_cmd = "uv sync" if uv else "pip install -e ." + test_cmd = "uv run pytest" if uv else "pytest" + content = AGENTS_MD_FIX_CONTENT.format( + project_name=project_path.name, + install_cmd=install_cmd, + test_cmd=test_cmd, + ) + return _write_new(project_path / "AGENTS.md", content) + + +def write_tests_dir(project_path: Path) -> bool: + tests_dir = project_path / "tests" + if tests_dir.exists(): + return False + tests_dir.mkdir(parents=True, exist_ok=True) + (tests_dir / "__init__.py").write_text("", encoding="utf-8") + return True diff --git a/src/spawn/generators/project_generator.py b/src/spawn/generators/project_generator.py index 0ad992e..259e80b 100644 --- a/src/spawn/generators/project_generator.py +++ b/src/spawn/generators/project_generator.py @@ -4,20 +4,40 @@ from pathlib import Path from spawn import __version__ +from spawn.core.exceptions import SpawnError +from spawn.core.models import ProjectConfig +from spawn.core.registry import instantiate_template +from spawn.generators.project_files import ( + add_mypy_config, + write_changelog, + write_license, + write_precommit_config, +) from spawn.templates.shared_content import ( - README_CONTENT, - GITIGNORE_CONTENT, AGENTS_MD_CONTENT, + GITIGNORE_CONTENT, + README_CONTENT, ) -from spawn.core.models import ProjectConfig -from spawn.core.registry import instantiate_template -from spawn.utils.git import initialize_git -from spawn.utils.uv import initialize_uv, install_packages -from spawn.core.exceptions import SpawnError from spawn.utils.console import console +from spawn.utils.git import get_git_user_name, initialize_git +from spawn.utils.uv import initialize_uv, install_packages class ProjectGenerator: + def _apply_quality_extras(self, project_path: Path, extras: list[str]) -> None: + dev_deps = [] + if "mypy" in extras: + dev_deps.append("mypy") + if "pre-commit" in extras: + dev_deps.append("pre-commit") + if dev_deps: + console.print("[yellow]Installing dev tools...[/yellow]") + install_packages(project_path, dev_deps, dev=True) + if "mypy" in extras: + add_mypy_config(project_path) + if "pre-commit" in extras: + write_precommit_config(project_path) + def generate(self, config: ProjectConfig) -> Path: template = instantiate_template(config) @@ -53,6 +73,11 @@ def generate(self, config: ProjectConfig) -> Path: claude_md_path = project_path / "CLAUDE.md" claude_md_path.write_text(agents_md_content, encoding="utf-8") + write_changelog(project_path) + if config.license != "none": + holder = get_git_user_name() or f"{config.name} contributors" + write_license(project_path, config.license, holder) + gitignore_path = project_path / ".gitignore" gitignore_path.write_text( @@ -72,6 +97,7 @@ def generate(self, config: ProjectConfig) -> Path: install_packages(project_path, deps) template.post_install(project_path) + self._apply_quality_extras(project_path, config.extras) meta_dir = project_path / ".spawn" meta_dir.mkdir() @@ -83,9 +109,7 @@ def generate(self, config: ProjectConfig) -> Path: "framework": config.framework, "provider": config.provider, "spawn_version": __version__, - "created_at": datetime.datetime.now( - datetime.timezone.utc - ).isoformat(), + "created_at": datetime.datetime.now(datetime.UTC).isoformat(), "generator": "blueprint", "git": config.use_git, "uv": True, diff --git a/src/spawn/templates/shared_content.py b/src/spawn/templates/shared_content.py index 1bcbfaf..8f1379e 100644 --- a/src/spawn/templates/shared_content.py +++ b/src/spawn/templates/shared_content.py @@ -87,3 +87,98 @@ - Run `uv run ruff check .` before committing, if Ruff is configured for this project (see `pyproject.toml`). """ + +RUFF_PRECOMMIT_REV = "v0.15.16" + +MIT_LICENSE_CONTENT = """MIT License + +Copyright (c) {year} {holder} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +CHANGELOG_CONTENT = """# Changelog + +All notable changes to this project are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] +""" + +PRECOMMIT_CONFIG_CONTENT = ( + "repos:\n" + " - repo: https://github.com/astral-sh/ruff-pre-commit\n" + f" rev: {RUFF_PRECOMMIT_REV}\n" + " hooks:\n" + " - id: ruff\n" +) + +RUFF_PYPROJECT_SECTION = "\n[tool.ruff]\nline-length = 88\n" +MYPY_PYPROJECT_SECTION = ( + '\n[tool.mypy]\npython_version = "3.12"\nignore_missing_imports = true\n' +) +PYTEST_PYPROJECT_SECTION = '\n[tool.pytest.ini_options]\ntestpaths = ["tests"]\n' +MYPY_INI_CONTENT = "[mypy]\npython_version = 3.12\nignore_missing_imports = True\n" + +AGENTS_MD_FIX_CONTENT = ( + "# Agent Context: {project_name}\n\n" + "This file orients coding agents working in this repository.\n\n" + "## Project structure\n\n" + "See the folder layout in `README.md`.\n\n" + "## Setup\n\n" + "```bash\n" + "{install_cmd}\n" + "```\n\n" + "## Running tests\n\n" + "```bash\n" + "{test_cmd}\n" + "```\n" +) + +GITHUB_ACTIONS_CI_BASE = """\ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync +""" + +GITHUB_ACTIONS_CI_RUFF_STEP = """\ + - name: Lint + run: uv run ruff check . +""" + +GITHUB_ACTIONS_CI_PYTEST_STEP = """\ + - name: Test + run: uv run pytest +""" diff --git a/src/spawn/utils/doctor_fix.py b/src/spawn/utils/doctor_fix.py new file mode 100644 index 0000000..286f24d --- /dev/null +++ b/src/spawn/utils/doctor_fix.py @@ -0,0 +1,226 @@ +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from spawn.core.exceptions import SpawnError +from spawn.generators.project_files import ( + add_mypy_config, + add_pytest_config, + add_ruff_config, + write_agents_md_generic, + write_changelog, + write_ci_workflow, + write_gitignore, + write_license, + write_precommit_config, + write_readme_from_project, + write_tests_dir, +) +from spawn.utils.doctor import HealthCheck +from spawn.utils.git import get_git_user_name, initialize_git + + +@dataclass +class FixAction: + check: str + description: str + apply: Callable[[], bool] + + +@dataclass +class FixResult: + action: FixAction + status: str + detail: str = "" + + +_ALWAYS_MANUAL: dict[str, str] = { + "Dockerfile": "needs your app's entrypoint", + ".env.example": "depends on your project's variables", + "pyproject.toml": "run 'uv init'", +} + +_DEV_TOOL_PACKAGES: list[tuple[str, str]] = [ + ("Pytest", "pytest"), + ("Ruff", "ruff"), + ("Type Checking", "mypy"), + ("Pre-commit", "pre-commit"), +] + + +def plan_fixes( + project_path: Path, + checks: list[HealthCheck], + license_kind: str | None = None, +) -> tuple[list[FixAction], list[tuple[str, str]]]: + failed = {c.name for c in checks if not c.passed} + has_pyproject = (project_path / "pyproject.toml").is_file() + + actions: list[FixAction] = [] + manual: list[tuple[str, str]] = [] + + if "README.md" in failed: + actions.append( + FixAction( + check="README.md", + description="Create README.md", + apply=lambda: write_readme_from_project(project_path), + ) + ) + + if "AGENTS.md" in failed: + actions.append( + FixAction( + check="AGENTS.md", + description="Create AGENTS.md", + apply=lambda: write_agents_md_generic(project_path), + ) + ) + + if ".gitignore" in failed: + actions.append( + FixAction( + check=".gitignore", + description="Create .gitignore", + apply=lambda: write_gitignore(project_path), + ) + ) + + if "CHANGELOG.md" in failed: + actions.append( + FixAction( + check="CHANGELOG.md", + description="Create CHANGELOG.md", + apply=lambda: write_changelog(project_path), + ) + ) + + if "LICENSE" in failed: + if license_kind == "mit": + + def _apply_license() -> bool: + holder = get_git_user_name() or f"{project_path.name} contributors" + return write_license(project_path, "mit", holder) + + actions.append( + FixAction( + check="LICENSE", + description="Create LICENSE (MIT)", + apply=_apply_license, + ) + ) + else: + manual.append(("LICENSE", "pass --license mit to add one")) + + if "Tests" in failed: + actions.append( + FixAction( + check="Tests", + description="Create tests/ directory", + apply=lambda: write_tests_dir(project_path), + ) + ) + + if "Pytest" in failed: + if has_pyproject: + actions.append( + FixAction( + check="Pytest", + description="Add Pytest configuration to pyproject.toml", + apply=lambda: add_pytest_config(project_path), + ) + ) + else: + manual.append(("Pytest", "no pyproject.toml")) + + if "Ruff" in failed: + if has_pyproject: + actions.append( + FixAction( + check="Ruff", + description="Add Ruff configuration to pyproject.toml", + apply=lambda: add_ruff_config(project_path), + ) + ) + else: + manual.append(("Ruff", "no pyproject.toml")) + + if "Type Checking" in failed: + if has_pyproject: + actions.append( + FixAction( + check="Type Checking", + description="Add Mypy configuration to pyproject.toml", + apply=lambda: add_mypy_config(project_path), + ) + ) + else: + manual.append(("Type Checking", "no pyproject.toml")) + + if "Pre-commit" in failed: + actions.append( + FixAction( + check="Pre-commit", + description="Create .pre-commit-config.yaml", + apply=lambda: write_precommit_config(project_path), + ) + ) + + if "GitHub Actions" in failed: + if has_pyproject: + actions.append( + FixAction( + check="GitHub Actions", + description="Create GitHub Actions CI workflow", + apply=lambda: write_ci_workflow(project_path), + ) + ) + else: + manual.append(("GitHub Actions", "no pyproject.toml")) + + if "Git Repository" in failed: + + def _apply_git() -> bool: + initialize_git(project_path) + return True + + actions.append( + FixAction( + check="Git Repository", + description="Initialize Git repository", + apply=_apply_git, + ) + ) + + for check_name, reason in _ALWAYS_MANUAL.items(): + if check_name in failed: + manual.append((check_name, reason)) + + return actions, manual + + +def apply_fixes(actions: list[FixAction]) -> list[FixResult]: + results: list[FixResult] = [] + for action in actions: + try: + ok = action.apply() + if ok: + results.append(FixResult(action=action, status="applied", detail="")) + else: + results.append( + FixResult(action=action, status="skipped", detail="already exists") + ) + except (SpawnError, OSError) as e: + results.append(FixResult(action=action, status="failed", detail=str(e))) + return results + + +def tool_hints(results: list[FixResult]) -> list[str]: + applied = {r.action.check for r in results if r.status == "applied"} + tools = [pkg for check, pkg in _DEV_TOOL_PACKAGES if check in applied] + hints: list[str] = [] + if tools: + hints.append(f"uv add --dev {' '.join(tools)}") + if "Pre-commit" in applied: + hints.append("pre-commit install") + return hints diff --git a/src/spawn/utils/git.py b/src/spawn/utils/git.py index 7832b69..0a76ace 100644 --- a/src/spawn/utils/git.py +++ b/src/spawn/utils/git.py @@ -127,3 +127,18 @@ def is_git_repository( ) -> bool: """Check if the given path is inside a Git repository.""" return (project_path / ".git").is_dir() + + +def get_git_user_name() -> str | None: + try: + result = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + check=True, + ) + name = result.stdout.strip() + return name if name else None + + except (FileNotFoundError, subprocess.CalledProcessError): + return None diff --git a/tests/test_app.py b/tests/test_app.py index 6d1a4ad..4143a46 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,6 +6,8 @@ from pathlib import Path from unittest.mock import patch + +import typer from typer.testing import CliRunner from spawn.cli.app import app @@ -192,6 +194,103 @@ def test_doctor_path_is_file_not_directory(tmp_path): assert "Path is not a directory" in result.output +# --------------------------------------------------------------------------- +# spawn doctor — --fix, --dry-run, --yes, --license +# --------------------------------------------------------------------------- + + +def test_doctor_without_flags_unchanged(tmp_path): + result = runner.invoke(app, ["doctor", str(tmp_path)]) + assert result.exit_code == 0 + assert "Project Health Report" in result.output + assert "→" not in result.output + assert "Planned fixes" not in result.output + + +def test_doctor_dry_run_alone_exits_1(tmp_path): + result = runner.invoke(app, ["doctor", str(tmp_path), "--dry-run"]) + assert result.exit_code == 1 + assert "--dry-run requires --fix" in result.output + + +def test_doctor_fix_dry_run_writes_nothing(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + files_before = set(tmp_path.iterdir()) + result = runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--dry-run"]) + assert result.exit_code == 0 + assert "Planned fixes" in result.output + assert set(tmp_path.iterdir()) == files_before + + +def test_doctor_fix_yes_creates_files_and_score_improves(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + + def fake_git(p): + (p / ".git").mkdir() + + with patch("spawn.utils.doctor_fix.initialize_git", side_effect=fake_git): + result = runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--yes"]) + assert result.exit_code == 0 + assert (tmp_path / "README.md").is_file() + assert (tmp_path / ".gitignore").is_file() + assert "Score:" in result.output + assert "→" in result.output + + +def test_doctor_second_run_prints_nothing_to_fix_or_manual_items(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + + def fake_git(p): + (p / ".git").mkdir() + + with patch("spawn.utils.doctor_fix.initialize_git", side_effect=fake_git): + runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--yes"]) + result = runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--yes"]) + assert result.exit_code == 0 + assert "Nothing to fix" in result.output or "Needs manual action" in result.output + assert "Planned fixes" not in result.output + + +def test_doctor_fix_yes_license_mit_writes_license(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + with patch("spawn.utils.doctor_fix.initialize_git"): + result = runner.invoke( + app, ["doctor", str(tmp_path), "--fix", "--yes", "--license", "mit"] + ) + assert result.exit_code == 0 + assert (tmp_path / "LICENSE").is_file() + assert "MIT License" in (tmp_path / "LICENSE").read_text(encoding="utf-8") + + +def test_doctor_fix_with_input_n_applies_nothing(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + files_before = set(tmp_path.iterdir()) + result = runner.invoke(app, ["doctor", str(tmp_path), "--fix"], input="n\n") + assert result.exit_code == 0 + assert "Cancelled." in result.output + assert set(tmp_path.iterdir()) == files_before + + +def test_doctor_typer_abort_exits_130_with_cancelled(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + with patch("spawn.cli.app.typer.confirm", side_effect=typer.Abort): + result = runner.invoke(app, ["doctor", str(tmp_path), "--fix"]) + assert result.exit_code == 130 + assert "Cancelled." in result.output + + # --------------------------------------------------------------------------- # spawn create — non-interactive (--name / --template flags) # --------------------------------------------------------------------------- @@ -422,6 +521,33 @@ def capture_generate(config): assert captured["config"].generate_claude_md is False +@patch("spawn.cli.app.get_project_config") +@patch("spawn.cli.app.ProjectGenerator") +@patch("spawn.cli.app.show_success") +@patch("spawn.cli.app.instantiate_template") +def test_license_none_reaches_generator_config( + mock_instantiate, mock_show_success, mock_generator_cls, mock_get_config +): + """--license none must result in license='none' on the config passed to generate().""" + captured = {} + + def capture_generate(config): + captured["config"] = config + return Path("demo") + + mock_generator_cls.return_value.generate.side_effect = capture_generate + mock_instantiate.return_value = None + + result = runner.invoke( + app, + ["create", "--name", "demo", "--template", "automation", "--license", "none"], + ) + + assert result.exit_code == 0 + assert "config" in captured + assert captured["config"].license == "none" + + # --------------------------------------------------------------------------- # Bug regressions — Bug 1 (config-file path for --claude-md) # --------------------------------------------------------------------------- diff --git a/tests/test_custom_structure.py b/tests/test_custom_structure.py index 158cbbb..301b4c1 100644 --- a/tests/test_custom_structure.py +++ b/tests/test_custom_structure.py @@ -367,11 +367,13 @@ def patched_mkdir(self, *args, **kwargs): raise OSError("simulated disk error") return original_mkdir(self, *args, **kwargs) - with patch.object(Path, "mkdir", patched_mkdir): - with pytest.raises((SpawnError, OSError)): - CustomStructureGenerator().generate( - "my-project", entries, use_git=False, use_uv=False - ) + with ( + patch.object(Path, "mkdir", patched_mkdir), + pytest.raises((SpawnError, OSError)), + ): + CustomStructureGenerator().generate( + "my-project", entries, use_git=False, use_uv=False + ) assert not (tmp_path / "my-project").exists(), ( "rollback failed — directory still exists" @@ -507,6 +509,25 @@ def test_dockerfile_selected_creates_dockerfile(tmp_path, monkeypatch): assert "python:3.12-slim" in df.read_text(encoding="utf-8") +def test_mypy_selected_creates_mypy_ini(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + project_path = _generate_with_dev_setup(tmp_path, ["mypy"]) + cfg = project_path / "mypy.ini" + assert cfg.is_file() + assert "[mypy]" in cfg.read_text(encoding="utf-8") + + +def test_mypy_in_optional_setup_menu(): + from spawn.cli.prompts import _OPTIONAL_SETUP_CHOICES, _OPTIONAL_SETUP_KEY + + assert "Mypy" in _OPTIONAL_SETUP_CHOICES + assert ( + _OPTIONAL_SETUP_CHOICES.index("Mypy") + == _OPTIONAL_SETUP_CHOICES.index("Pre-commit") + 1 + ) + assert _OPTIONAL_SETUP_KEY["Mypy"] == "mypy" + + def test_dev_setup_skipped_when_use_uv_false(tmp_path, monkeypatch): """Even with dev_setup=['ruff'], nothing is created when use_uv=False.""" monkeypatch.chdir(tmp_path) @@ -534,6 +555,24 @@ def test_install_packages_called_with_dev_flag(tmp_path, monkeypatch): ) +def test_install_packages_called_with_mypy(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + entries = parse_structure(_TREE_RAW) + with patch(_PATCH_UV), patch(_PATCH_GIT), patch(_PATCH_INST) as mock_install: + CustomStructureGenerator().generate( + "dev-project", + entries, + use_git=False, + use_uv=True, + dev_setup=["mypy"], + ) + mock_install.assert_called_once_with( + Path("dev-project"), + ["mypy"], + dev=True, + ) + + def test_dockerfile_no_dependency_install(tmp_path, monkeypatch): """Dockerfile-only selection must NOT trigger install_packages at all.""" monkeypatch.chdir(tmp_path) @@ -588,7 +627,7 @@ def test_readme_populated_when_present(tmp_path, monkeypatch): def test_readme_contains_structure_tree(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - project_path, entries = _gen_with_readme(tmp_path, _README_RAW) + project_path, _entries = _gen_with_readme(tmp_path, _README_RAW) content = (project_path / "README.md").read_text(encoding="utf-8") # names from the parsed entries should appear in the tree section assert "app/" in content diff --git a/tests/test_doctor_fix.py b/tests/test_doctor_fix.py new file mode 100644 index 0000000..9e498bb --- /dev/null +++ b/tests/test_doctor_fix.py @@ -0,0 +1,223 @@ +from unittest.mock import patch + +from spawn.core.exceptions import SpawnError +from spawn.generators.project_files import ( + write_gitignore, + write_readme_from_project, +) +from spawn.utils.doctor import ProjectHealthChecker +from spawn.utils.doctor_fix import ( + FixAction, + FixResult, + apply_fixes, + plan_fixes, + tool_hints, +) + + +def test_bare_project_plan_apply_and_recheck(tmp_path): + project = tmp_path / "my_proj" + project.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname = "my_proj"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (project / "src").mkdir() + (project / "src" / "main.py").write_text('print("hello")\n', encoding="utf-8") + + checker = ProjectHealthChecker(project) + initial_checks = checker.run_all_checks() + + def fake_git(p): + (p / ".git").mkdir() + + with patch("spawn.utils.doctor_fix.initialize_git", side_effect=fake_git): + actions, _manual = plan_fixes(project, initial_checks, license_kind="mit") + results = apply_fixes(actions) + + assert all(r.status == "applied" for r in results) + + fresh_checker = ProjectHealthChecker(project) + post_checks = {c.name: c for c in fresh_checker.run_all_checks()} + + fixed_check_names = [a.check for a in actions] + for name in fixed_check_names: + assert post_checks[name].passed is True + + second_checks = fresh_checker.run_all_checks() + second_actions, _ = plan_fixes(project, second_checks, license_kind="mit") + assert second_actions == [] + + +def test_preexisting_files_byte_identical(tmp_path): + project = tmp_path / "my_proj" + project.mkdir() + readme = project / "README.md" + readme_content = "# Custom Readme\nDo not overwrite.\n" + readme.write_text(readme_content, encoding="utf-8") + + gitignore = project / ".gitignore" + gi_content = "*.custom\n" + gitignore.write_text(gi_content, encoding="utf-8") + + (project / "pyproject.toml").write_text( + '[project]\nname = "my_proj"\n', + encoding="utf-8", + ) + + action_readme = FixAction( + "README.md", "desc", lambda: write_readme_from_project(project) + ) + action_gi = FixAction(".gitignore", "desc", lambda: write_gitignore(project)) + results = apply_fixes([action_readme, action_gi]) + + assert all(r.status == "skipped" for r in results) + assert all(r.detail == "already exists" for r in results) + assert readme.read_text(encoding="utf-8") == readme_content + assert gitignore.read_text(encoding="utf-8") == gi_content + + +def test_no_pyproject_sends_config_fixers_to_manual(tmp_path): + project = tmp_path / "no_pyproject" + project.mkdir() + + checker = ProjectHealthChecker(project) + checks = checker.run_all_checks() + + actions, manual = plan_fixes(project, checks) + manual_dict = dict(manual) + + assert manual_dict["Pytest"] == "no pyproject.toml" + assert manual_dict["Ruff"] == "no pyproject.toml" + assert manual_dict["Type Checking"] == "no pyproject.toml" + assert manual_dict["GitHub Actions"] == "no pyproject.toml" + assert manual_dict["pyproject.toml"] == "run 'uv init'" + + action_checks = {a.check for a in actions} + assert "Pytest" not in action_checks + assert "Ruff" not in action_checks + assert "Type Checking" not in action_checks + assert "GitHub Actions" not in action_checks + + +def test_license_manual_without_license_kind(tmp_path): + project = tmp_path / "lic_test" + project.mkdir() + checker = ProjectHealthChecker(project) + checks = checker.run_all_checks() + + actions, manual = plan_fixes(project, checks, license_kind=None) + manual_dict = dict(manual) + assert manual_dict["LICENSE"] == "pass --license mit to add one" + assert not any(a.check == "LICENSE" for a in actions) + + +def test_license_written_with_mit(tmp_path): + project = tmp_path / "lic_test_mit" + project.mkdir() + checker = ProjectHealthChecker(project) + checks = checker.run_all_checks() + + actions, _ = plan_fixes(project, checks, license_kind="mit") + lic_action = next(a for a in actions if a.check == "LICENSE") + results = apply_fixes([lic_action]) + + assert results[0].status == "applied" + lic_file = project / "LICENSE" + assert lic_file.is_file() + assert "MIT License" in lic_file.read_text(encoding="utf-8") + + +def test_ci_workflow_step_inclusion(tmp_path): + p1 = tmp_path / "p1" + p1.mkdir() + (p1 / "pyproject.toml").write_text('[project]\nname = "p1"\n', encoding="utf-8") + checker1 = ProjectHealthChecker(p1) + actions1, _ = plan_fixes(p1, checker1.run_all_checks()) + ci_action1 = next(a for a in actions1 if a.check == "GitHub Actions") + apply_fixes([ci_action1]) + ci_content1 = (p1 / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + assert "pytest" not in ci_content1 + assert "ruff" not in ci_content1 + + p2 = tmp_path / "p2" + p2.mkdir() + (p2 / "pyproject.toml").write_text( + '[project]\nname = "p2"\ndependencies = ["ruff", "pytest"]\n', + encoding="utf-8", + ) + checker2 = ProjectHealthChecker(p2) + actions2, _ = plan_fixes(p2, checker2.run_all_checks()) + ci_action2 = next(a for a in actions2 if a.check == "GitHub Actions") + apply_fixes([ci_action2]) + ci_content2 = (p2 / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + assert "uv run pytest" in ci_content2 + assert "uv run ruff check" in ci_content2 + + +def test_git_init_mock_and_failure_path(tmp_path): + project = tmp_path / "git_fail" + project.mkdir() + checker = ProjectHealthChecker(project) + actions, _ = plan_fixes(project, checker.run_all_checks()) + git_action = next(a for a in actions if a.check == "Git Repository") + + with patch("spawn.utils.doctor_fix.initialize_git") as mock_init: + res = apply_fixes([git_action]) + assert res[0].status == "applied" + assert res[0].detail == "" + mock_init.assert_called_once_with(project) + + with patch( + "spawn.utils.doctor_fix.initialize_git", + side_effect=SpawnError("Git not found"), + ): + res = apply_fixes([git_action]) + assert res[0].status == "failed" + assert res[0].detail == "Git not found" + + with patch( + "spawn.utils.doctor_fix.initialize_git", + side_effect=OSError("Disk error"), + ): + res = apply_fixes([git_action]) + assert res[0].status == "failed" + assert res[0].detail == "Disk error" + + +def test_tool_hints(): + a_pytest = FixAction("Pytest", "", lambda: True) + a_ruff = FixAction("Ruff", "", lambda: True) + a_mypy = FixAction("Type Checking", "", lambda: True) + a_precommit = FixAction("Pre-commit", "", lambda: True) + a_readme = FixAction("README.md", "", lambda: True) + + results = [ + FixResult(a_pytest, "applied"), + FixResult(a_ruff, "applied"), + FixResult(a_mypy, "applied"), + FixResult(a_precommit, "applied"), + ] + assert tool_hints(results) == [ + "uv add --dev pytest ruff mypy pre-commit", + "pre-commit install", + ] + + results_sub = [ + FixResult(a_pytest, "applied"), + FixResult(a_precommit, "applied"), + ] + assert tool_hints(results_sub) == [ + "uv add --dev pytest pre-commit", + "pre-commit install", + ] + + results_non_tool = [ + FixResult(a_readme, "applied"), + ] + assert tool_hints(results_non_tool) == [] + + results_skipped = [ + FixResult(a_pytest, "skipped", "already exists"), + ] + assert tool_hints(results_skipped) == [] diff --git a/tests/test_generator.py b/tests/test_generator.py index 1f242da..05b6bea 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,13 +1,15 @@ -import pytest - +from pathlib import Path from unittest.mock import patch +import pytest + from spawn.core.exceptions import SpawnError from spawn.core.models import ProjectConfig from spawn.generators.project_generator import ProjectGenerator - +from spawn.templates.shared_content import RUFF_PRECOMMIT_REV # --------------------------------------------------------------------------- + # Helpers # --------------------------------------------------------------------------- @@ -122,9 +124,8 @@ def test_existing_directory_raises_error(mock_uv, mock_install, tmp_path, monkey def test_uv_failure_cleans_up_directory(mock_uv, mock_install, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_uv.side_effect = SpawnError("uv not found") - with _patch_post_install(): - with pytest.raises(SpawnError): - ProjectGenerator().generate(_cli_config()) + with _patch_post_install(), pytest.raises(SpawnError): + ProjectGenerator().generate(_cli_config()) assert not (tmp_path / "demo").exists() @@ -134,10 +135,12 @@ def test_git_failure_cleans_up_directory(mock_git, mock_uv, tmp_path, monkeypatc monkeypatch.chdir(tmp_path) mock_git.side_effect = SpawnError("Git is not installed or not available in PATH.") config = ProjectConfig(name="demo", template="cli", use_git=True) - with _patch_post_install(): - with patch("spawn.generators.project_generator.install_packages"): - with pytest.raises(SpawnError): - ProjectGenerator().generate(config) + with ( + _patch_post_install(), + patch("spawn.generators.project_generator.install_packages"), + pytest.raises(SpawnError), + ): + ProjectGenerator().generate(config) assert not (tmp_path / "demo").exists() @@ -245,9 +248,11 @@ def _failing_write_text(self, *args, **kwargs): monkeypatch.setattr(Path, "write_text", _failing_write_text) - with _patch_post_install(): - with pytest.raises(SpawnError, match="No space left on device"): - ProjectGenerator().generate(_cli_config()) + with ( + _patch_post_install(), + pytest.raises(SpawnError, match="No space left on device"), + ): + ProjectGenerator().generate(_cli_config()) assert not (tmp_path / "demo").exists() @@ -457,9 +462,8 @@ def failing_mkdir(self, *args, **kwargs): monkeypatch.setattr(Path, "mkdir", failing_mkdir) - with _patch_post_install(): - with pytest.raises(SpawnError): - ProjectGenerator().generate(_cli_config()) + with _patch_post_install(), pytest.raises(SpawnError): + ProjectGenerator().generate(_cli_config()) assert not (tmp_path / "demo").exists() @@ -778,3 +782,90 @@ def test_claude_md_created_when_flag_set(mock_uv, mock_install, tmp_path, monkey agents = (tmp_path / "demo" / "AGENTS.md").read_text(encoding="utf-8") claude = (tmp_path / "demo" / "CLAUDE.md").read_text(encoding="utf-8") assert claude == agents + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_default_generation_creates_license_and_changelog( + mock_uv, mock_install, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + with _patch_post_install(): + ProjectGenerator().generate(_cli_config()) + assert (tmp_path / "demo" / "LICENSE").is_file() + assert (tmp_path / "demo" / "CHANGELOG.md").is_file() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_license_none_produces_no_license(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_post_install(): + ProjectGenerator().generate(_cli_config(license="none")) + assert not (tmp_path / "demo" / "LICENSE").exists() + assert (tmp_path / "demo" / "CHANGELOG.md").is_file() + + +@patch( + "spawn.generators.project_generator.get_git_user_name", + return_value="Alice Developer", +) +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_license_holder_from_git_user_name( + mock_uv, mock_install, mock_git_user, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + with _patch_post_install(): + ProjectGenerator().generate(_cli_config()) + content = (tmp_path / "demo" / "LICENSE").read_text(encoding="utf-8") + assert "Alice Developer" in content + + +@patch("spawn.generators.project_generator.get_git_user_name", return_value=None) +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_license_holder_fallback_when_git_user_name_none( + mock_uv, mock_install, mock_git_user, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + with _patch_post_install(): + ProjectGenerator().generate(_cli_config(name="my-app")) + content = (tmp_path / "my-app" / "LICENSE").read_text(encoding="utf-8") + assert "my-app contributors" in content + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_extras_mypy_installs_and_configures( + mock_uv, mock_install, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + + def _create_pyproject(path): + (path / "pyproject.toml").write_text( + "[project]\nname = 'demo'\n", encoding="utf-8" + ) + + mock_uv.side_effect = _create_pyproject + with _patch_post_install(): + ProjectGenerator().generate(_cli_config(extras=["mypy"])) + + mock_install.assert_any_call(Path("demo"), ["mypy"], dev=True) + content = (tmp_path / "demo" / "pyproject.toml").read_text(encoding="utf-8") + assert "[tool.mypy]" in content + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_extras_precommit_installs_and_writes_config( + mock_uv, mock_install, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + with _patch_post_install(): + ProjectGenerator().generate(_cli_config(extras=["pre-commit"])) + + mock_install.assert_any_call(Path("demo"), ["pre-commit"], dev=True) + cfg_file = tmp_path / "demo" / ".pre-commit-config.yaml" + assert cfg_file.is_file() + assert RUFF_PRECOMMIT_REV in cfg_file.read_text(encoding="utf-8") diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 06813b5..bb597a1 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -7,7 +7,6 @@ from spawn.cli.noninteractive import build_config_from_args, build_config_from_file from spawn.core.exceptions import SpawnError - # --------------------------------------------------------------------------- # build_config_from_args — valid paths # --------------------------------------------------------------------------- @@ -77,6 +76,18 @@ def test_use_git_and_use_uv_forwarded(): assert config.use_uv is False +def test_license_defaults_to_mit(): + config = build_config_from_args(name="my-tool", template="automation") + assert config.license == "mit" + + +def test_license_none_passes(): + config = build_config_from_args( + name="my-tool", template="automation", license="none" + ) + assert config.license == "none" + + # --------------------------------------------------------------------------- # build_config_from_args — validation errors # --------------------------------------------------------------------------- @@ -134,6 +145,13 @@ def test_invalid_project_name_raises(): build_config_from_args(name="has spaces", template="automation") +def test_invalid_license_raises(): + with pytest.raises( + SpawnError, match="Invalid license: 'bogus'. Valid options: mit, none" + ): + build_config_from_args(name="my-tool", template="automation", license="bogus") + + # --------------------------------------------------------------------------- # build_config_from_file — valid paths # --------------------------------------------------------------------------- @@ -172,6 +190,17 @@ def test_json_file_all_optional_fields(tmp_path, monkeypatch): assert config.use_uv is False +def test_json_file_license_read(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg_file = tmp_path / "spawn.json" + cfg_file.write_text( + json.dumps({"name": "demo", "template": "automation", "license": "none"}), + encoding="utf-8", + ) + config = build_config_from_file(cfg_file) + assert config.license == "none" + + # --------------------------------------------------------------------------- # build_config_from_file — error paths # --------------------------------------------------------------------------- @@ -217,3 +246,13 @@ def test_extras_not_a_list_raises(tmp_path): ) with pytest.raises(SpawnError, match="list of strings"): build_config_from_file(f) + + +def test_license_not_a_string_raises(tmp_path): + f = tmp_path / "bad_license.json" + f.write_text( + json.dumps({"name": "demo", "template": "automation", "license": 123}), + encoding="utf-8", + ) + with pytest.raises(SpawnError, match="'license' in config file must be a string."): + build_config_from_file(f) diff --git a/tests/test_project_files.py b/tests/test_project_files.py new file mode 100644 index 0000000..845bf21 --- /dev/null +++ b/tests/test_project_files.py @@ -0,0 +1,371 @@ +import datetime +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from spawn.core.exceptions import SpawnError +from spawn.generators.project_files import ( + _append_section, + _write_new, + add_mypy_config, + add_pytest_config, + add_ruff_config, + declares_dependency, + write_agents_md_generic, + write_changelog, + write_ci_workflow, + write_gitignore, + write_license, + write_mypy_ini, + write_precommit_config, + write_readme_from_project, + write_tests_dir, +) +from spawn.templates.shared_content import ( + CHANGELOG_CONTENT, + GITIGNORE_CONTENT, + MYPY_INI_CONTENT, + MYPY_PYPROJECT_SECTION, + PRECOMMIT_CONFIG_CONTENT, + PYTEST_PYPROJECT_SECTION, + RUFF_PYPROJECT_SECTION, +) +from spawn.utils.git import get_git_user_name + + +def test_write_new_creates_file_and_parents(tmp_path): + target = tmp_path / "deep" / "nested" / "file.txt" + assert _write_new(target, "hello world") is True + assert target.read_text(encoding="utf-8") == "hello world" + + +def test_write_new_skips_when_exists(tmp_path): + target = tmp_path / "file.txt" + target.write_text("initial", encoding="utf-8") + assert _write_new(target, "overwritten") is False + assert target.read_text(encoding="utf-8") == "initial" + + +def test_append_section_success_and_idempotency(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[project]\nname = 'demo'\n", encoding="utf-8") + + assert _append_section(tmp_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) is True + content_after_first = pyproject.read_text(encoding="utf-8") + assert "[tool.ruff]" in content_after_first + assert "line-length = 88" in content_after_first + + assert _append_section(tmp_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) is False + assert pyproject.read_text(encoding="utf-8") == content_after_first + + +def test_append_section_missing_pyproject(tmp_path): + assert _append_section(tmp_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) is False + + +def test_write_license_mit_success(tmp_path): + assert write_license(tmp_path, "mit", "Test Holder", year=2025) is True + license_file = tmp_path / "LICENSE" + assert license_file.exists() + content = license_file.read_text(encoding="utf-8") + assert "Copyright (c) 2025 Test Holder" in content + assert "MIT License" in content + + +def test_write_license_mit_default_year(tmp_path): + assert write_license(tmp_path, "mit", "Test Holder") is True + current_year = datetime.datetime.now(datetime.UTC).year + content = (tmp_path / "LICENSE").read_text(encoding="utf-8") + assert f"Copyright (c) {current_year} Test Holder" in content + + +def test_write_license_skips_when_file_exists(tmp_path): + license_file = tmp_path / "LICENSE" + license_file.write_text("custom license", encoding="utf-8") + assert write_license(tmp_path, "mit", "Test Holder", year=2025) is False + assert license_file.read_text(encoding="utf-8") == "custom license" + + +def test_write_license_none_returns_false(tmp_path): + assert write_license(tmp_path, "none", "Test Holder") is False + assert not (tmp_path / "LICENSE").exists() + + +def test_write_license_unsupported_raises(tmp_path): + with pytest.raises(SpawnError, match="Unsupported license: 'apache'"): + write_license(tmp_path, "apache", "Test Holder") + + +def test_write_changelog(tmp_path): + assert write_changelog(tmp_path) is True + changelog = tmp_path / "CHANGELOG.md" + assert changelog.read_text(encoding="utf-8") == CHANGELOG_CONTENT + assert write_changelog(tmp_path) is False + assert changelog.read_text(encoding="utf-8") == CHANGELOG_CONTENT + + +def test_write_precommit_config(tmp_path): + assert write_precommit_config(tmp_path) is True + cfg = tmp_path / ".pre-commit-config.yaml" + assert cfg.read_text(encoding="utf-8") == PRECOMMIT_CONFIG_CONTENT + assert write_precommit_config(tmp_path) is False + assert cfg.read_text(encoding="utf-8") == PRECOMMIT_CONFIG_CONTENT + + +def test_write_gitignore(tmp_path): + assert write_gitignore(tmp_path) is True + gitignore = tmp_path / ".gitignore" + assert gitignore.read_text(encoding="utf-8") == GITIGNORE_CONTENT + assert write_gitignore(tmp_path) is False + assert gitignore.read_text(encoding="utf-8") == GITIGNORE_CONTENT + + +def test_add_ruff_config(tmp_path): + pyproject = tmp_path / "pyproject.toml" + assert add_ruff_config(tmp_path) is False + pyproject.write_text("[project]\nname = 'test'\n", encoding="utf-8") + assert add_ruff_config(tmp_path) is True + assert "[tool.ruff]" in pyproject.read_text(encoding="utf-8") + assert add_ruff_config(tmp_path) is False + + +def test_add_mypy_config(tmp_path): + pyproject = tmp_path / "pyproject.toml" + assert add_mypy_config(tmp_path) is False + pyproject.write_text("[project]\nname = 'test'\n", encoding="utf-8") + assert add_mypy_config(tmp_path) is True + content = pyproject.read_text(encoding="utf-8") + assert "[tool.mypy]" in content + assert MYPY_PYPROJECT_SECTION.strip() in content + assert add_mypy_config(tmp_path) is False + + +def test_add_pytest_config(tmp_path): + pyproject = tmp_path / "pyproject.toml" + assert add_pytest_config(tmp_path) is False + pyproject.write_text("[project]\nname = 'test'\n", encoding="utf-8") + assert add_pytest_config(tmp_path) is True + content = pyproject.read_text(encoding="utf-8") + assert "[tool.pytest.ini_options]" in content + assert PYTEST_PYPROJECT_SECTION.strip() in content + assert add_pytest_config(tmp_path) is False + + +def test_write_mypy_ini(tmp_path): + assert write_mypy_ini(tmp_path) is True + ini = tmp_path / "mypy.ini" + assert ini.read_text(encoding="utf-8") == MYPY_INI_CONTENT + assert write_mypy_ini(tmp_path) is False + assert ini.read_text(encoding="utf-8") == MYPY_INI_CONTENT + + +def test_declares_dependency(tmp_path): + assert declares_dependency(tmp_path, "ruff") is False + + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("invalid toml [[]", encoding="utf-8") + assert declares_dependency(tmp_path, "ruff") is False + + pyproject.write_text( + """[project] +dependencies = ["fastapi>=0.115.0", "uvicorn[standard]>=0.30.0"] + +[dependency-groups] +dev = ["ruff>=0.15.16", "pytest>=9.0.3"] + +[tool.uv] +dev-dependencies = ["mypy>=1.14.0"] +""", + encoding="utf-8", + ) + + assert declares_dependency(tmp_path, "fastapi") is True + assert declares_dependency(tmp_path, "uvicorn") is True + assert declares_dependency(tmp_path, "ruff") is True + assert declares_dependency(tmp_path, "RUFF") is True + assert declares_dependency(tmp_path, "pytest") is True + assert declares_dependency(tmp_path, "mypy") is True + assert declares_dependency(tmp_path, "pytest-cov") is False + assert declares_dependency(tmp_path, "flask") is False + + +def test_write_ci_workflow_no_dependencies(tmp_path): + assert write_ci_workflow(tmp_path) is True + ci_file = tmp_path / ".github" / "workflows" / "ci.yml" + assert ci_file.exists() + content = ci_file.read_text(encoding="utf-8") + assert "name: CI" in content + assert "Install dependencies" in content + assert "uv run ruff check ." not in content + assert "uv run pytest" not in content + + +def test_write_ci_workflow_step_selection(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[project] +dependencies = ["ruff>=0.15.16", "pytest>=9.0.3"] +""", + encoding="utf-8", + ) + + assert write_ci_workflow(tmp_path) is True + ci_file = tmp_path / ".github" / "workflows" / "ci.yml" + content = ci_file.read_text(encoding="utf-8") + assert "uv run ruff check ." in content + assert "uv run pytest" in content + + +def test_write_ci_workflow_ruff_only(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[project] +dependencies = ["ruff>=0.15.16"] +""", + encoding="utf-8", + ) + + assert write_ci_workflow(tmp_path) is True + ci_file = tmp_path / ".github" / "workflows" / "ci.yml" + content = ci_file.read_text(encoding="utf-8") + assert "uv run ruff check ." in content + assert "uv run pytest" not in content + + +def test_write_ci_workflow_skips_when_workflow_exists(tmp_path): + workflows_dir = tmp_path / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + existing_yaml = workflows_dir / "build.yaml" + existing_yaml.write_text("existing workflow", encoding="utf-8") + + assert write_ci_workflow(tmp_path) is False + assert not (workflows_dir / "ci.yml").exists() + assert existing_yaml.read_text(encoding="utf-8") == "existing workflow" + + +def test_write_ci_workflow_skips_when_ci_yml_exists(tmp_path): + workflows_dir = tmp_path / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + ci_yml = workflows_dir / "ci.yml" + ci_yml.write_text("existing ci", encoding="utf-8") + + assert write_ci_workflow(tmp_path) is False + assert ci_yml.read_text(encoding="utf-8") == "existing ci" + + +def test_write_readme_from_project_with_pyproject(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[project] +name = "awesome-app" +description = "An awesome application." +""", + encoding="utf-8", + ) + + assert write_readme_from_project(tmp_path) is True + readme = tmp_path / "README.md" + assert ( + readme.read_text(encoding="utf-8") + == "# awesome-app\n\nAn awesome application.\n" + ) + + assert write_readme_from_project(tmp_path) is False + assert ( + readme.read_text(encoding="utf-8") + == "# awesome-app\n\nAn awesome application.\n" + ) + + +def test_write_readme_from_project_without_pyproject(tmp_path): + project_dir = tmp_path / "my-project" + project_dir.mkdir() + assert write_readme_from_project(project_dir) is True + readme = project_dir / "README.md" + assert readme.read_text(encoding="utf-8") == "# my-project\n" + + +def test_write_readme_from_project_without_description(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[project] +name = "simple-pkg" +""", + encoding="utf-8", + ) + assert write_readme_from_project(tmp_path) is True + readme = tmp_path / "README.md" + assert readme.read_text(encoding="utf-8") == "# simple-pkg\n" + + +def test_write_agents_md_generic_with_uv(tmp_path): + (tmp_path / "uv.lock").touch() + project_dir = tmp_path / "agent-project" + project_dir.mkdir() + (project_dir / "uv.lock").touch() + + assert write_agents_md_generic(project_dir) is True + agents_md = project_dir / "AGENTS.md" + content = agents_md.read_text(encoding="utf-8") + assert "# Agent Context: agent-project" in content + assert "uv sync" in content + assert "uv run pytest" in content + + assert write_agents_md_generic(project_dir) is False + + +def test_write_agents_md_generic_without_uv(tmp_path): + project_dir = tmp_path / "pip-project" + project_dir.mkdir() + + assert write_agents_md_generic(project_dir) is True + agents_md = project_dir / "AGENTS.md" + content = agents_md.read_text(encoding="utf-8") + assert "# Agent Context: pip-project" in content + assert "pip install -e ." in content + assert "```bash\npytest\n```" in content + + +def test_write_tests_dir(tmp_path): + assert write_tests_dir(tmp_path) is True + tests_dir = tmp_path / "tests" + assert tests_dir.is_dir() + init_file = tests_dir / "__init__.py" + assert init_file.exists() + assert init_file.read_text(encoding="utf-8") == "" + + existing_test = tests_dir / "test_example.py" + existing_test.write_text("def test_one(): pass", encoding="utf-8") + assert write_tests_dir(tmp_path) is False + assert existing_test.read_text(encoding="utf-8") == "def test_one(): pass" + + +@patch("subprocess.run") +def test_get_git_user_name_success(mock_run): + mock_run.return_value = MagicMock(stdout="Jane Doe\n") + assert get_git_user_name() == "Jane Doe" + mock_run.assert_called_once_with( + ["git", "config", "user.name"], + capture_output=True, + text=True, + check=True, + ) + + +@patch("subprocess.run") +def test_get_git_user_name_empty(mock_run): + mock_run.return_value = MagicMock(stdout=" \n") + assert get_git_user_name() is None + + +@patch("subprocess.run") +def test_get_git_user_name_file_not_found(mock_run): + mock_run.side_effect = FileNotFoundError + assert get_git_user_name() is None + + +@patch("subprocess.run") +def test_get_git_user_name_called_process_error(mock_run): + mock_run.side_effect = subprocess.CalledProcessError(1, ["git", "config"]) + assert get_git_user_name() is None diff --git a/tests/test_prompts.py b/tests/test_prompts.py index b6396ab..a5dbf5d 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -1,15 +1,14 @@ """Tests for cli/prompts.py — get_project_config().""" -from unittest.mock import patch, MagicMock import io +from unittest.mock import MagicMock, patch import pytest -from spawn.cli.prompts import get_project_config, _select, _multiselect +from spawn.cli.prompts import _multiselect, _select, get_project_config from spawn.core.models import ProjectConfig from spawn.core.registry import get_metadata - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -87,7 +86,12 @@ def test_valid_name_and_template_returns_config(): patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): # Template → CLI Application; CLI type → utility; Framework → typer - mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"] + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "MIT", + ] mock_chk.return_value.ask.return_value = [] # no extras config = get_project_config() @@ -105,7 +109,7 @@ def test_git_false_reflected_in_config(): patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): # Template → Backend API; Framework → fastapi - mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"] + mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -121,13 +125,37 @@ def test_generate_claude_md_true_reflected_in_config(monkeypatch): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"] + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "MIT", + ] mock_chk.return_value.ask.return_value = [] config = get_project_config() assert config.generate_claude_md is True +def test_license_none_reflected_in_config(): + with ( + patch("spawn.cli.prompts.typer.prompt", return_value="my-project"), + patch("spawn.cli.prompts.typer.confirm", return_value=True), + patch("spawn.cli.prompts.questionary.select") as mock_sel, + patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, + ): + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "None", + ] + mock_chk.return_value.ask.return_value = [] + config = get_project_config() + + assert config.license == "none" + + # --------------------------------------------------------------------------- # Invalid project name retried until valid # --------------------------------------------------------------------------- @@ -141,7 +169,12 @@ def test_invalid_name_retried_until_valid(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"] + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "MIT", + ] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -157,7 +190,12 @@ def test_name_with_space_retried(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"] + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "MIT", + ] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -204,6 +242,8 @@ def test_all_template_choices(display_name, expected_template): get_supported_providers(meta.available_frameworks[0])[0] ) + select_side_effects.append("MIT") + with ( patch("spawn.cli.prompts.typer.prompt", return_value="project"), patch("spawn.cli.prompts.typer.confirm", return_value=False), @@ -229,7 +269,7 @@ def test_backend_api_with_framework_and_extras(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"] + mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"] mock_chk.return_value.ask.return_value = ["ruff", "pytest"] config = get_project_config() @@ -247,7 +287,7 @@ def test_backend_api_with_no_extras(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"] + mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -263,7 +303,7 @@ def test_backend_api_flask_framework_selected(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["Backend API", "flask"] + mock_sel.return_value.ask.side_effect = ["Backend API", "flask", "MIT"] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -279,7 +319,7 @@ def test_backend_api_django_framework_selected(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["Backend API", "django"] + mock_sel.return_value.ask.side_effect = ["Backend API", "django", "MIT"] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -294,7 +334,7 @@ def test_backend_api_docker_and_github_actions_extras(): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"] + mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"] mock_chk.return_value.ask.return_value = ["docker", "github-actions"] config = get_project_config() @@ -317,7 +357,12 @@ def test_existing_directory_name_retried(tmp_path, monkeypatch): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"] + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "MIT", + ] mock_chk.return_value.ask.return_value = [] config = get_project_config() @@ -335,7 +380,12 @@ def test_existing_directory_shows_error_message(tmp_path, monkeypatch): patch("spawn.cli.prompts.questionary.select") as mock_sel, patch("spawn.cli.prompts.questionary.checkbox") as mock_chk, ): - mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"] + mock_sel.return_value.ask.side_effect = [ + "CLI Application", + "utility", + "typer", + "MIT", + ] mock_chk.return_value.ask.return_value = [] get_project_config() diff --git a/tests/test_registry.py b/tests/test_registry.py index 20b6611..0ce0b83 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,7 +1,12 @@ -from spawn.core.registry import get_template, get_metadata, list_templates +from unittest.mock import patch + +from spawn.core.models import ProjectConfig +from spawn.core.registry import get_metadata, get_template, list_templates +from spawn.generators.project_generator import ProjectGenerator def test_invalid_template_returns_none(): + assert get_template("banana") is None @@ -157,8 +162,8 @@ def test_chatbot_in_list_templates(): def test_agent_template_is_registered(): - from spawn.templates.agent import AgentTemplate from spawn.core.registry import get_template + from spawn.templates.agent import AgentTemplate t = get_template("agent") assert t is not None @@ -191,3 +196,49 @@ def test_rag_metadata(): assert not meta.available_providers assert "ruff" in meta.available_extras assert "pytest" in meta.available_extras + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_generate_all_templates_with_full_available_extras( + mock_uv, mock_install, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + + def _create_pyproject(path): + (path / "pyproject.toml").write_text( + "[project]\nname = 'test'\n", encoding="utf-8" + ) + + mock_uv.side_effect = _create_pyproject + + defaults = { + "backend-api": {"framework": "fastapi"}, + "cli": {"framework": "typer", "cli_type": "utility"}, + "automation": {}, + "chatbot": {"framework": "pydantic-ai", "provider": "openai"}, + "agent": {"framework": "pydantic-ai", "provider": "openai"}, + "rag": {}, + "data": {"data_type": "Data Analysis"}, + "mcp": {}, + } + + for meta in list_templates(): + assert "pre-commit" in meta.available_extras + assert "mypy" in meta.available_extras + slug = meta.slug + extra_kwargs = defaults.get(slug, {}) + config = ProjectConfig( + name=f"proj-{slug}", + template=slug, + use_git=False, + extras=list(meta.available_extras), + **extra_kwargs, + ) + gen = ProjectGenerator() + path = gen.generate(config) + assert path.exists() + assert (path / "LICENSE").exists() + assert (path / "CHANGELOG.md").exists() + assert (path / ".pre-commit-config.yaml").exists() + assert "[tool.mypy]" in (path / "pyproject.toml").read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index 090ea8a..d805f58 100644 --- a/uv.lock +++ b/uv.lock @@ -279,7 +279,7 @@ wheels = [ [[package]] name = "spawnio" -version = "1.0.7" +version = "1.0.8" source = { editable = "." } dependencies = [ { name = "questionary" },